From b644d287e1b320e2e77390c44aa6ae8642361bc3 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 22:40:30 +0800 Subject: [PATCH 1/6] feat: add diagnostic logs panel with debug capture mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dedicated 'Diagnostic Logs' tab in settings sidebar (between Data Management and Network) - Implement dual-layer logging architecture: - Base layer (always on): key events + warn/error with HTTP context (status, durationMs) - Debug layer (toggle-controlled): full HTTP request details for every API call - Add frontend/backend debug mode toggles with sessionStorage persistence - Add event type filtering (Sync Repos, AI Analysis, Refresh Trending, etc.) - Add real-time log updates via CustomEvent (gsm:diagnostic-log-added/cleared) - Enhance HTTP request instrumentation in githubApi, aiService, backendAdapter, webdavService - Add DELETE /api/logs and POST /api/logs/debug endpoints for backend - Remove old log export section from DataManagementPanel (moved to new panel) - Add logger.isDebugMode() guard for debug-level logs to optimize performance - Extract logAIRequestDebug() helper to reduce code duplication in aiService Technical details: - Frontend logger: 2000-entry ring buffer with write-time sanitization - Backend logger: mirrored design with Morgan HTTP access log integration - Event types auto-inferred from (module, message) patterns - Search across module + message fields - Level pills: debug/info/warn/error - Scope toggle: all/frontend/backend - HTTP summary display: 'GET /path → 200 (245ms)' format with color-coded status Files modified: - server/src/routes/logs.ts: add DELETE, GET/POST debug endpoints - server/src/services/logger.ts: add getLevel(), isDebugMode(), getModules() - src/components/SettingsPanel.tsx: add 'logs' tab with ScrollText icon - src/components/settings/DataManagementPanel.tsx: remove log export section - src/components/settings/DiagnosticLogsPanel.tsx: new component (~450 lines) - src/components/settings/index.ts: export DiagnosticLogsPanel - src/services/aiService.ts: add debug logging, extract logAIRequestDebug() - src/services/autoSync.ts: add durationMs to sync events - src/services/backendAdapter.ts: add debug logging in fetchWithTimeout/Retry - src/services/githubApi.ts: add debug logging in makeRequest - src/services/logger.ts: add CustomEvent dispatch, getModules(), isDebugMode() - src/services/webdavService.ts: add debug logging for PUT/GET - src/utils/logEventTypes.ts: new file for event type mapping Co-Authored-By: Claude Opus 4.8 --- server/src/routes/logs.ts | 31 + server/src/services/logger.ts | 16 + src/components/SettingsPanel.tsx | 11 +- .../settings/DataManagementPanel.tsx | 254 ------- .../settings/DiagnosticLogsPanel.tsx | 671 ++++++++++++++++++ src/components/settings/index.ts | 1 + src/services/aiService.ts | 83 ++- src/services/autoSync.ts | 12 +- src/services/backendAdapter.ts | 19 +- src/services/githubApi.ts | 47 +- src/services/logger.ts | 20 + src/services/webdavService.ts | 26 +- src/utils/logEventTypes.ts | 46 ++ 13 files changed, 944 insertions(+), 293 deletions(-) create mode 100644 src/components/settings/DiagnosticLogsPanel.tsx create mode 100644 src/utils/logEventTypes.ts diff --git a/server/src/routes/logs.ts b/server/src/routes/logs.ts index 0bd0ade1..a4a55231 100644 --- a/server/src/routes/logs.ts +++ b/server/src/routes/logs.ts @@ -5,6 +5,8 @@ const router = Router(); const ALLOWED_LEVELS: readonly LogLevel[] = ['debug', 'info', 'warn', 'error']; +// Note: Authentication is handled by authMiddleware applied to all /api/* routes in index.ts + // GET /api/logs — returns recent backend log entries router.get('/api/logs', (req, res) => { try { @@ -40,4 +42,33 @@ router.get('/api/logs', (req, res) => { } }); +// DELETE /api/logs — clear all backend log entries +router.delete('/api/logs', (_req, res) => { + try { + logger.clear(); + res.json({ success: true }); + } catch (err) { + logger.errorFromError('logs.clearLogs', 'Failed to clear logs', err); + res.status(500).json({ error: 'Failed to clear logs', code: 'CLEAR_LOGS_FAILED' }); + } +}); + +// GET /api/logs/debug — return current debug mode status +router.get('/api/logs/debug', (_req, res) => { + res.json({ debugMode: logger.isDebugMode() }); +}); + +// POST /api/logs/debug — toggle backend debug mode +router.post('/api/logs/debug', (req, res) => { + try { + const enabled = req.body.enabled === true; + logger.setLevel(enabled ? 'debug' : 'info'); + logger.info('logs.debug', enabled ? 'Backend debug mode enabled' : 'Backend debug mode disabled'); + res.json({ success: true, debugMode: logger.isDebugMode() }); + } catch (err) { + logger.errorFromError('logs.debugToggle', 'Failed to toggle debug mode', err); + res.status(500).json({ error: 'Failed to toggle debug mode', code: 'DEBUG_TOGGLE_FAILED' }); + } +}); + export default router; \ No newline at end of file diff --git a/server/src/services/logger.ts b/server/src/services/logger.ts index fd51f130..e9fb73f8 100644 --- a/server/src/services/logger.ts +++ b/server/src/services/logger.ts @@ -127,6 +127,22 @@ class Logger { setLevel(level: LogLevel): void { this.minLevel = level; } + + getLevel(): LogLevel { + return this.minLevel; + } + + isDebugMode(): boolean { + return this.minLevel === 'debug'; + } + + getModules(): string[] { + const modules = new Set(); + for (const entry of this.buffer) { + modules.add(entry.module); + } + return Array.from(modules).sort(); + } } export const logger = new Logger(); diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 3747c446..123be595 100644 --- a/src/components/SettingsPanel.tsx +++ b/src/components/SettingsPanel.tsx @@ -10,6 +10,7 @@ import { X, Trash2, Wifi, + ScrollText, } from 'lucide-react'; import { useAppStore } from '../store/useAppStore'; import { isElectron } from '../services/electronProxy'; @@ -23,9 +24,10 @@ import { CategoryPanel, DataManagementPanel, NetworkPanel, + DiagnosticLogsPanel, } from './settings'; -type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'data' | 'network'; +type SettingsTab = 'general' | 'ai' | 'webdav' | 'backup' | 'backend' | 'category' | 'data' | 'logs' | 'network'; interface SettingsTabItem { id: SettingsTab; @@ -286,6 +288,11 @@ export const SettingsPanel: React.FC = ({ label: t('数据管理', 'Data Management'), icon: , }, + { + id: 'logs', + label: t('诊断日志', 'Diagnostic Logs'), + icon: , + }, ...((isElectron() || backend.isAvailable) ? [{ id: 'network' as SettingsTab, label: t('网络设置', 'Network'), @@ -310,6 +317,8 @@ export const SettingsPanel: React.FC = ({ return ; case 'data': return ; + case 'logs': + return ; case 'network': return ; default: diff --git a/src/components/settings/DataManagementPanel.tsx b/src/components/settings/DataManagementPanel.tsx index 2590b1c2..23b5f677 100644 --- a/src/components/settings/DataManagementPanel.tsx +++ b/src/components/settings/DataManagementPanel.tsx @@ -21,14 +21,10 @@ import { HardDrive, RefreshCw, Rss, - FileText, - ShieldCheck, } from 'lucide-react'; import { useAppStore } from '../../store/useAppStore'; import { indexedDBStorage } from '../../services/indexedDbStorage'; -import { logger, LogLevel } from '../../services/logger'; import { backend } from '../../services/backendAdapter'; -import { maskUrlDomain } from '../../utils/logSanitizer'; import { version as appVersion } from '../../../package.json'; import type { Repository, @@ -151,7 +147,6 @@ export const DataManagementPanel: React.FC = ({ t }) = const [, setSearchHistoryVersion] = useState(0); const [showErrorMessage, setShowErrorMessage] = useState(null); const [isExporting, setIsExporting] = useState(false); - const [isExportingLogs, setIsExportingLogs] = useState(false); const [isImporting, setIsImporting] = useState(false); const [importPreview, setImportPreview] = useState<{ data: ExportData | null; @@ -170,159 +165,8 @@ export const DataManagementPanel: React.FC = ({ t }) = setOperationLogs((prev) => [newLog, ...prev].slice(0, 50)); }, []); - // Log export: counts and state - const [frontendLogCount, setFrontendLogCount] = useState(0); - const [backendLogCount, setBackendLogCount] = useState(0); - const [logCounts, setLogCounts] = useState>({ debug: 0, info: 0, warn: 0, error: 0 }); const backendAvailable = backend.isAvailable; - useEffect(() => { - const updateCounts = () => { - const counts = logger.getCounts(); - setFrontendLogCount(counts.total); - setLogCounts({ debug: counts.debug, info: counts.info, warn: counts.warn, error: counts.error }); - }; - updateCounts(); - const interval = setInterval(updateCounts, 2000); - return () => clearInterval(interval); - }, []); - - useEffect(() => { - if (!backendAvailable) { - setBackendLogCount(0); - return; - } - const fetchBackendCount = async () => { - try { - const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); - const res = await fetch('/api/logs?limit=1', { - headers: { Authorization: `Bearer ${secret}` }, - }); - if (res.ok) { - // Use the total count from the response header - const totalHeader = res.headers.get('X-Log-Count'); - if (totalHeader) { - setBackendLogCount(parseInt(totalHeader) || 0); - } else { - // Fallback: fetch all logs to count (heavy but works) - const allRes = await fetch('/api/logs?limit=2000', { - headers: { Authorization: `Bearer ${secret}` }, - }); - if (allRes.ok) { - const logs = await allRes.json(); - setBackendLogCount(Array.isArray(logs) ? logs.length : 0); - } - } - } - } catch { - // Backend not reachable — keep count at 0 - } - }; - fetchBackendCount(); - const interval = setInterval(fetchBackendCount, 10000); - return () => clearInterval(interval); - }, [backendAvailable]); - - const handleExportLogs = async () => { - setIsExportingLogs(true); - try { - // Gather selected scopes - const scopeCheckboxes = document.querySelectorAll('.log-scope-checkbox:checked'); - const selectedScopes = Array.from(scopeCheckboxes).map(cb => (cb as HTMLInputElement).dataset.scope as string); - // Gather selected levels - const levelCheckboxes = document.querySelectorAll('.log-level-checkbox:checked'); - const selectedLevels = Array.from(levelCheckboxes).map(cb => (cb as HTMLInputElement).dataset.level as LogLevel); - - if (selectedLevels.length === 0) { - showError(t('请至少选择一个日志级别', 'Please select at least one log level')); - setIsExportingLogs(false); - return; - } - - if (selectedScopes.length === 0) { - showError(t('请至少选择一个日志范围', 'Please select at least one log scope')); - setIsExportingLogs(false); - return; - } - - // Determine min level for filtering - const levelOrder: Record = { debug: 0, info: 1, warn: 2, error: 3 }; - const minLevel = selectedLevels.reduce((min, lvl) => Math.min(min, levelOrder[lvl]), 3); - const minLevelName = (Object.entries(levelOrder).find(([_, v]) => v === minLevel)?.[0] as LogLevel) || 'debug'; - - // Fetch frontend logs - let frontendLogs = selectedScopes.includes('frontend') - ? logger.getEntries({ level: minLevelName }) - : []; - // Filter by explicit membership to honor exact level selection - frontendLogs = frontendLogs.filter((e) => selectedLevels.includes(e.level)); - - // Fetch backend logs - let backendLogs: Array<{ level: string }> = []; - if (selectedScopes.includes('backend') && backendAvailable) { - try { - const res = await fetch(`/api/logs?limit=2000&level=${minLevelName}`, { - headers: { Authorization: `Bearer ${sessionStorage.getItem('github-stars-manager-backend-secret')}` }, - }); - if (res.ok) { - const raw = await res.json(); - backendLogs = Array.isArray(raw) ? raw.filter((e: { level: string }) => selectedLevels.includes(e.level as LogLevel)) : []; - } - } catch { - // Backend unreachable — skip - } - } - - // Build environment info - const state = useAppStore.getState(); - const isElectron = typeof window !== 'undefined' && window.electronAPI; - const environment = { - platform: isElectron ? 'electron' : 'web', - deployMode: isElectron ? 'electron' : 'web', - electronVersion: isElectron ? navigator.userAgent.match(/Electron\/([\d.]+)/)?.[1] ?? 'unknown' : null, - osPlatform: navigator.platform, - screenResolution: `${screen.width}x${screen.height}`, - backendAvailable: backendAvailable, - backendUrl: backendAvailable ? maskUrlDomain(backend.backendUrl || '') : null, - language: state.language, - repoCount: state.repositories?.length ?? 0, - aiConfigCount: state.aiConfigs?.length ?? 0, - webdavConfigCount: state.webdavConfigs?.length ?? 0, - storeHydrated: true, - lastSyncTime: state.lastSync ?? null, - appVersion, - }; - - const exportData = { - format: 'github-stars-manager-logs-v1', - exportDate: new Date().toISOString(), - appVersion, - environment, - sanitizationNote: t('所有 Token、API Key、密码、邮箱已脱敏为 ***格式', 'All tokens, API keys, passwords, and emails have been masked as ***'), - frontendLogs, - backendLogs, - }; - - // Download - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `github-stars-manager-logs-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - - addLog(t('导出日志', 'Export logs'), true); - } catch (err) { - addLog(t('导出日志', 'Export logs'), false, String(err)); - showError(t('导出日志失败', 'Failed to export logs')); - } finally { - setIsExportingLogs(false); - } - }; - const showSuccess = useCallback((message: string) => { setShowSuccessMessage(message); setTimeout(() => setShowSuccessMessage(null), 3000); @@ -1448,104 +1292,6 @@ export const DataManagementPanel: React.FC = ({ t }) = - {/* Log Export */} -
-

- - {t('日志导出', 'Log Export')} -

-
-
-
- -
-
-

{t('导出应用日志', 'Export App Logs')}

-

{t('导出日志用于问题排查,所有敏感信息已自动脱敏', 'Export logs for troubleshooting. All sensitive info is automatically sanitized.')}

-
-
- - {/* Log scope */} -
-

{t('日志范围', 'Log Scope')}

-
- - -
-
- - {/* Log level */} -
-

{t('日志级别', 'Log Level')}

-
- {([ - { level: 'info' as LogLevel, label: t('Info', 'Info'), defaultChecked: true }, - { level: 'warn' as LogLevel, label: t('Warn', 'Warn'), defaultChecked: true }, - { level: 'error' as LogLevel, label: t('Error', 'Error'), defaultChecked: true }, - { level: 'debug' as LogLevel, label: t('Debug', 'Debug'), defaultChecked: false }, - ]).map((item) => ( - - ))} -
-
- - {/* Sanitization notice */} -
- - {t('所有敏感信息(Token、API Key、密码、邮箱等)已自动脱敏为 ***格式', 'All sensitive info (Token, API Key, password, email) is automatically masked as ***')} -
- - {/* Export button */} - -
-
- {/* Data Cleanup Suggestions */} {cleanupSuggestions.length > 0 && (
diff --git a/src/components/settings/DiagnosticLogsPanel.tsx b/src/components/settings/DiagnosticLogsPanel.tsx new file mode 100644 index 00000000..a34fad05 --- /dev/null +++ b/src/components/settings/DiagnosticLogsPanel.tsx @@ -0,0 +1,671 @@ +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { + ScrollText, + Search, + Download, + Trash2, + Loader2, + ShieldCheck, + RefreshCw, + ToggleLeft, + ToggleRight, + AlertTriangle, + ChevronDown, + ChevronRight, +} from 'lucide-react'; +import { logger, LogLevel, LogEntry } from '../../services/logger'; +import { backend } from '../../services/backendAdapter'; +import { maskUrlDomain } from '../../utils/logSanitizer'; +import { inferEventType, EVENT_TYPE_LABELS, LogEventType } from '../../utils/logEventTypes'; +import { version as appVersion } from '../../../package.json'; +import { useAppStore } from '../../store/useAppStore'; + +interface DiagnosticLogsPanelProps { + t: (zh: string, en: string) => string; +} + +const LEVEL_COLORS: Record = { + debug: 'bg-gray-100 text-gray-600 dark:bg-white/[0.06] dark:text-gray-400', + info: 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-400', + warn: 'bg-amber-100 text-amber-700 dark:bg-amber-500/20 dark:text-amber-400', + error: 'bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-400', +}; + +const STATUS_COLORS: Record = { + '2': 'text-green-600 dark:text-green-400', + '4': 'text-amber-600 dark:text-amber-400', + '5': 'text-red-600 dark:text-red-400', +}; + +function formatRelativeTime(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + if (diff < 60000) return `${Math.floor(diff / 1000)}s`; + if (diff < 3600000) return `${Math.floor(diff / 60000)}m`; + if (diff < 86400000) return `${Math.floor(diff / 3600000)}h`; + return `${Math.floor(diff / 86400000)}d`; +} + +function getHttpSummary(data: unknown): string | null { + if (!data || typeof data !== 'object' || Array.isArray(data)) return null; + const d = data as Record; + if (!d.method && !d.status && !d.durationMs) return null; + const parts: string[] = []; + if (d.method) parts.push(String(d.method)); + if (d.endpoint || d.path) parts.push(String(d.endpoint ?? d.path)); + if (d.status) { + parts.push(`→ ${String(d.status)}`); + } + if (d.durationMs) parts.push(`(${d.durationMs}ms)`); + return parts.length > 0 ? parts.join(' ') : null; +} + +function getStatusColor(status: unknown): string { + if (!status) return ''; + const s = String(status); + const firstDigit = s.charAt(0); + return STATUS_COLORS[firstDigit] || ''; +} + +export const DiagnosticLogsPanel: React.FC = ({ t }) => { + const language = useAppStore.getState().language; + + // Debug mode state + const [frontendDebug, setFrontendDebug] = useState(() => { + const saved = sessionStorage.getItem('gsm:frontend-debug'); + if (saved === 'true') { + logger.setLevel('debug'); + return true; + } + return false; + }); + const [backendDebug, setBackendDebug] = useState(() => sessionStorage.getItem('gsm:backend-debug') === 'true'); + const backendAvailable = backend.isAvailable; + + // Log entries state + const [entries, setEntries] = useState(() => logger.getEntries()); + const [backendEntries, setBackendEntries] = useState([]); + const [backendLogCount, setBackendLogCount] = useState(0); + const [isExporting, setIsExporting] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [expandedData, setExpandedData] = useState>(new Set()); + + // Filter state + const [searchQuery, setSearchQuery] = useState(''); + const [selectedLevels, setSelectedLevels] = useState>(new Set(['info', 'warn', 'error'])); + const [selectedScope, setSelectedScope] = useState<'all' | 'frontend' | 'backend'>('all'); + const [selectedEventTypes, setSelectedEventTypes] = useState>(new Set()); + const [showEventTypeDropdown, setShowEventTypeDropdown] = useState(false); + + // Real-time frontend log subscription + useEffect(() => { + const handleLogAdded = (e: Event) => { + const entry = (e as CustomEvent).detail; + if (entry) { + setEntries(prev => { + const next = [...prev, entry]; + return next.length > 2000 ? next.slice(-2000) : next; + }); + } + }; + const handleLogsCleared = () => { + setEntries([]); + }; + window.addEventListener('gsm:diagnostic-log-added', handleLogAdded); + window.addEventListener('gsm:diagnostic-logs-cleared', handleLogsCleared); + return () => { + window.removeEventListener('gsm:diagnostic-log-added', handleLogAdded); + window.removeEventListener('gsm:diagnostic-logs-cleared', handleLogsCleared); + }; + }, []); + + // Backend log fetching + useEffect(() => { + if (selectedScope === 'frontend') { + setBackendEntries([]); + setBackendLogCount(0); + return; + } + if (!backendAvailable) return; + + const fetchBackend = async () => { + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + const res = await fetch('/api/logs?limit=2000', { + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) { + const raw = await res.json(); + const logs = Array.isArray(raw) ? raw : []; + setBackendEntries(logs); + const totalHeader = res.headers.get('X-Log-Count'); + setBackendLogCount(totalHeader ? parseInt(totalHeader) || 0 : logs.length); + } + } catch { + // Backend unreachable + } + }; + fetchBackend(); + const interval = setInterval(fetchBackend, 10000); + return () => clearInterval(interval); + }, [selectedScope, backendAvailable]); + + // Merge entries + const allEntries = useMemo(() => { + if (selectedScope === 'frontend') return entries; + if (selectedScope === 'backend') return backendEntries; + return [...entries, ...backendEntries].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + }, [entries, backendEntries, selectedScope]); + + // Derived: available event types + const availableEventTypes = useMemo(() => { + const types = new Set(); + for (const entry of allEntries) { + types.add(inferEventType(entry.module, entry.message)); + } + return Array.from(types).sort(); + }, [allEntries]); + + // Derived: available modules + const availableModules = useMemo(() => { + const mods = new Set(); + for (const entry of allEntries) { + mods.add(entry.module); + } + return Array.from(mods).sort(); + }, [allEntries]); + + // Filter entries + const filteredEntries = useMemo(() => { + return allEntries.filter(entry => { + if (!selectedLevels.has(entry.level)) return false; + if (selectedEventTypes.size > 0 && !selectedEventTypes.has(inferEventType(entry.module, entry.message))) return false; + if (searchQuery) { + const q = searchQuery.toLowerCase(); + if (!entry.module.toLowerCase().includes(q) && !entry.message.toLowerCase().includes(q)) return false; + } + return true; + }); + }, [allEntries, selectedLevels, selectedEventTypes, searchQuery]); + + // Frontend counts + const frontendCounts = useMemo(() => logger.getCounts(), [entries]); + + // Toggle frontend debug mode + const toggleFrontendDebug = useCallback(() => { + const next = !frontendDebug; + setFrontendDebug(next); + logger.setLevel(next ? 'debug' : 'info'); + sessionStorage.setItem('gsm:frontend-debug', String(next)); + if (next) { + setSelectedLevels(prev => new Set([...prev, 'debug'])); + } + }, [frontendDebug]); + + // Toggle backend debug mode + const toggleBackendDebug = useCallback(async () => { + const next = !backendDebug; + setBackendDebug(next); + sessionStorage.setItem('gsm:backend-debug', String(next)); + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + await fetch('/api/logs/debug', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${secret}`, + }, + body: JSON.stringify({ enabled: next }), + }); + } catch { + // Backend unreachable + } + }, [backendDebug]); + + // Clear logs + const handleClear = useCallback(async () => { + if (selectedScope === 'frontend' || selectedScope === 'all') { + logger.clear(); + setEntries([]); + } + if ((selectedScope === 'backend' || selectedScope === 'all') && backendAvailable) { + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + await fetch('/api/logs', { + method: 'DELETE', + headers: { Authorization: `Bearer ${secret}` }, + }); + setBackendEntries([]); + setBackendLogCount(0); + } catch { + // Backend unreachable + } + } + }, [selectedScope, backendAvailable]); + + // Refresh backend entries + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + const res = await fetch('/api/logs?limit=2000', { + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) { + const raw = await res.json(); + setBackendEntries(Array.isArray(raw) ? raw : []); + const totalHeader = res.headers.get('X-Log-Count'); + setBackendLogCount(totalHeader ? parseInt(totalHeader) || 0 : raw.length); + } + } catch { + // Backend unreachable + } finally { + setIsRefreshing(false); + } + }, []); + + // Export logs + const handleExport = useCallback(async () => { + setIsExporting(true); + try { + const levelOrder: Record = { debug: 0, info: 1, warn: 2, error: 3 }; + const minLevel = selectedLevels.size > 0 + ? (Object.entries(levelOrder).find(([l]) => selectedLevels.has(l as LogLevel))?.[1] ?? 3) + : 3; + const minLevelName = (Object.entries(levelOrder).find(([_, v]) => v === minLevel)?.[0] as LogLevel) || 'info'; + + let frontendLogs = selectedScope !== 'backend' + ? logger.getEntries({ level: minLevelName }).filter(e => selectedLevels.has(e.level)) + : []; + let backendLogs: LogEntry[] = []; + if (selectedScope !== 'frontend' && backendAvailable) { + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + const res = await fetch(`/api/logs?limit=2000&level=${minLevelName}`, { + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) { + const raw = await res.json(); + backendLogs = Array.isArray(raw) ? raw.filter((e: LogEntry) => selectedLevels.has(e.level)) : []; + } + } catch { + // Backend unreachable + } + } + + const state = useAppStore.getState(); + const isElectron = typeof window !== 'undefined' && window.electronAPI; + const environment = { + platform: isElectron ? 'electron' : 'web', + electronVersion: isElectron ? navigator.userAgent.match(/Electron\/([\d.]+)/)?.[1] ?? 'unknown' : null, + osPlatform: navigator.platform, + screenResolution: `${screen.width}x${screen.height}`, + backendAvailable, + backendUrl: backendAvailable ? maskUrlDomain(backend.backendUrl || '') : null, + language: state.language, + repoCount: state.repositories?.length ?? 0, + frontendDebugMode: frontendDebug, + backendDebugMode: backendDebug, + appVersion, + }; + + const exportData = { + format: 'github-stars-manager-logs-v1', + exportDate: new Date().toISOString(), + appVersion, + environment, + sanitizationNote: t( + '所有 Token、API Key、密码、邮箱已脱敏为 ***格式', + 'All tokens, API keys, passwords, and emails have been masked as ***' + ), + frontendLogs, + backendLogs, + }; + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `github-stars-manager-logs-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch { + // Export failed + } finally { + setIsExporting(false); + } + }, [selectedScope, selectedLevels, backendAvailable, frontendDebug, backendDebug, t]); + + // Toggle level + const toggleLevel = useCallback((level: LogLevel) => { + setSelectedLevels(prev => { + const next = new Set(prev); + if (next.has(level)) next.delete(level); + else next.add(level); + return next; + }); + }, []); + + // Toggle event type + const toggleEventType = useCallback((eventType: LogEventType) => { + setSelectedEventTypes(prev => { + const next = new Set(prev); + if (next.has(eventType)) next.delete(eventType); + else next.add(eventType); + return next; + }); + }, []); + + // Toggle data expansion + const toggleDataExpand = useCallback((id: string) => { + setExpandedData(prev => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }, []); + + const totalCount = allEntries.length; + + return ( +
+ {/* Debug Mode Section */} +
+

+ + {t('调试模式', 'Debug Mode')} +

+
+ {/* Frontend debug toggle */} +
+
+
+ {t('前端调试', 'Frontend Debug')} + + {frontendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')} + +
+

+ {t('开启后将记录所有前端 HTTP 请求详情(方法、路径、状态码、耗时)', 'Records all frontend HTTP request details (method, path, status, duration)')} +

+
+ +
+ + {/* Backend debug toggle */} +
+
+
+ + {t('后端调试', 'Backend Debug')} + + + {backendAvailable + ? (backendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')) + : t('后端未连接', 'Not connected')} + +
+

+ {t('开启后将记录所有后端 HTTP 请求详情', 'Records all backend HTTP request details')} +

+
+ +
+ + {/* Warning */} +
+ + {t('调试模式会产生大量日志,仅用于排障时短暂开启', 'Debug mode produces many logs — enable briefly only for troubleshooting')} +
+
+
+ + {/* Privacy Notice */} +
+ + + {t('日志仅记录端点、模型、状态、耗时和错误摘要。所有 Token、API Key、密码、邮箱已自动脱敏为 ***格式', 'Logs store only endpoints, models, status, duration, and error summaries. All sensitive info (Token, API Key, password, email) is automatically masked as ***')} + +
+ + {/* Toolbar */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + placeholder={t('搜索模块或消息...', 'Search module or message...')} + className="w-full pl-10 pr-4 py-2 rounded-lg border border-black/[0.06] dark:border-white/[0.04] bg-light-surface dark:bg-white/[0.04] text-gray-900 dark:text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-brand-violet" + /> +
+ + {/* Level pills */} +
+ {t('级别', 'Level')}: + {(['debug', 'info', 'warn', 'error'] as LogLevel[]).map(level => { + const disabled = level === 'debug' && !frontendDebug && selectedScope !== 'backend'; + const selected = selectedLevels.has(level); + return ( + + ); + })} +
+ + {/* Scope + Event type + Actions row */} +
+ {/* Scope toggle */} +
+ {(['all', 'frontend', 'backend'] as const).map(scope => ( + + ))} +
+ + {/* Event type dropdown */} +
+ + {showEventTypeDropdown && ( +
+ {availableEventTypes.map(et => ( + + ))} +
+ )} +
+ + {/* Action buttons */} +
+ + + +
+
+ + {/* Count summary */} +
+ {t(`显示 ${filteredEntries.length} / ${totalCount} 条`, `Showing ${filteredEntries.length} / ${totalCount} entries`)} + {(frontendDebug || backendDebug) && ( + + {t('调试模式已开启', 'Debug mode ON')} + + )} + {selectedScope !== 'backend' && ( + + · {t(`前端 ${frontendCounts.total}`, `Frontend ${frontendCounts.total}`)} + + )} + {selectedScope !== 'frontend' && backendAvailable && ( + + · {t(`后端 ${backendLogCount}`, `Backend ${backendLogCount}`)} + + )} +
+
+ + {/* Log Entry List */} +
+ {filteredEntries.length === 0 ? ( +
+ + {totalCount === 0 + ? t('暂无日志', 'No logs yet') + : t('无匹配日志', 'No matching logs')} +
+ ) : ( +
+ {filteredEntries.map(entry => { + const eventType = inferEventType(entry.module, entry.message); + const httpSummary = getHttpSummary(entry.data); + const entryData = entry.data as Record | undefined; + const hasData = entryData && typeof entryData === 'object' && !Array.isArray(entryData) && Object.keys(entryData).length > 0; + const isExpanded = expandedData.has(entry.id); + const statusColor = entryData?.status ? getStatusColor(entryData.status) : ''; + + return ( +
+ {/* Header row */} +
+ + {entry.level} + + + {entry.source === 'frontend' ? t('前端', 'FE') : t('后端', 'BE')} + + + {language === 'zh' ? EVENT_TYPE_LABELS[eventType].zh : EVENT_TYPE_LABELS[eventType].en} + + + {formatRelativeTime(entry.timestamp)} + + + {entry.module} + +
+ + {/* Message */} +

+ {entry.message} +

+ + {/* HTTP summary line */} + {httpSummary && ( +
+ {String(entryData?.method ?? '')} + {String(entryData?.endpoint ?? entryData?.path ?? '')} + {entryData?.status && ( + → {String(entryData.status)} + )} + {entryData?.durationMs && ( + {String(entryData.durationMs)}ms + )} +
+ )} + + {/* Expandable data block */} + {hasData && ( +
+ + {isExpanded && ( +
+                          {JSON.stringify(entryData, null, 2)}
+                        
+ )} +
+ )} +
+ ); + })} +
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/src/components/settings/index.ts b/src/components/settings/index.ts index c975730e..29b6c1e4 100644 --- a/src/components/settings/index.ts +++ b/src/components/settings/index.ts @@ -6,3 +6,4 @@ export { CategoryPanel } from './CategoryPanel'; export { GeneralPanel } from './GeneralPanel'; export { DataManagementPanel } from './DataManagementPanel'; export { NetworkPanel } from './NetworkPanel'; +export { DiagnosticLogsPanel } from './DiagnosticLogsPanel'; diff --git a/src/services/aiService.ts b/src/services/aiService.ts index fa3ab091..52e9ac25 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -67,6 +67,23 @@ export class AIService { this.language = language; } + /** + * Log AI request details at debug level (only logged when debug mode is on). + */ + private logAIRequestDebug( + startTime: number, + context: { apiType: string; model: string; configId: string }, + result: { responseLength: number } | { error: string } + ): void { + if (logger.isDebugMode()) { + logger.debug('ai', 'AI request', { + ...context, + durationMs: Date.now() - startTime, + ...result, + }); + } + } + /** * 清理用户内容中可能导致 JSON 序列化问题的字符 * - 移除 null 字节和控制字符(保留 \n \r \t) @@ -122,7 +139,10 @@ export class AIService { maxTokens: number; signal?: AbortSignal; }): Promise { + const startTime = Date.now(); const apiType = this.getApiType(); + const model = this.config.model; + const configId = this.config.id; const reasoning = this.getOpenAIReasoningPayload(); if (apiType === 'openai' || apiType === 'openai-responses' || apiType === 'openai-compatible') { @@ -170,6 +190,7 @@ export class AIService { }); if (!response.ok) { const errorDetail = await this.extractErrorDetail(response); + this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }); throw new Error(`AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); } data = await response.json(); @@ -178,7 +199,10 @@ export class AIService { if (apiType === 'openai-responses') { const typedData = data as OpenAIResponse; const outputText = typedData.output_text; - if (outputText) return outputText; + if (outputText) { + this.logAIRequestDebug(startTime, { apiType, model, configId }, { responseLength: outputText.length }); + return outputText; + } const output = typedData.output; if (Array.isArray(output)) { @@ -186,19 +210,29 @@ export class AIService { .flatMap((item) => Array.isArray(item?.content) ? item.content : []) .map((part) => part?.text || '') .join(''); - if (text) return text; + if (text) { + this.logAIRequestDebug(startTime, { apiType, model, configId }, { responseLength: text.length }); + return text; + } } } else { const typedData = data as { choices?: OpenAIResponseChoice[] }; const choices = typedData.choices; const message = choices?.[0]?.message; const content = message?.content; - if (content) return content; + if (content) { + this.logAIRequestDebug(startTime, { apiType, model, configId }, { responseLength: content.length }); + return content; + } const reasoningContent = message?.reasoning_content; - if (reasoningContent) return reasoningContent; + if (reasoningContent) { + this.logAIRequestDebug(startTime, { apiType, model, configId }, { responseLength: reasoningContent.length }); + return reasoningContent; + } } + this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }); throw new Error('No content received from AI service'); } @@ -229,6 +263,7 @@ export class AIService { }); if (!response.ok) { const errorDetail = await this.extractErrorDetail(response); + this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }); throw new Error(`AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); } data = await response.json(); @@ -243,14 +278,18 @@ export class AIService { return block.type === 'text' && typeof block.text === 'string' ? block.text : ''; }) .join(''); - if (text) return text; + if (text) { + this.logAIRequestDebug(startTime, { apiType, model, configId }, { responseLength: text.length }); + return text; + } } + this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }); throw new Error('No content received from AI service'); } // gemini const rawModel = this.config.model.trim(); - const model = rawModel.startsWith('models/') ? rawModel.slice('models/'.length) : rawModel; + const geminiModel = rawModel.startsWith('models/') ? rawModel.slice('models/'.length) : rawModel; const prompt = options.system ? `${options.system} ${options.user}` : options.user; @@ -271,7 +310,7 @@ ${options.user}` : options.user; if (backend.isAvailable) { data = await backend.proxyAIRequestWithFallback(this.config.id, this.config, requestBody, options.signal); } else { - const path = `v1beta/models/${encodeURIComponent(model)}:generateContent`; + const path = `v1beta/models/${encodeURIComponent(geminiModel)}:generateContent`; const urlObj = new URL(buildApiUrl(this.config.baseUrl, path)); urlObj.searchParams.set('key', this.config.apiKey); const response = await fetch(urlObj.toString(), { @@ -285,6 +324,7 @@ ${options.user}` : options.user; }); if (!response.ok) { const errorDetail = await this.extractErrorDetail(response); + this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }); throw new Error(`AI API error: ${response.status} ${response.statusText}${errorDetail ? ` - ${errorDetail}` : ''}`); } data = await response.json(); @@ -304,9 +344,13 @@ ${options.user}` : options.user; return typeof part.text === 'string' ? part.text : ''; }) .join(''); - if (text) return text; + if (text) { + this.logAIRequestDebug(startTime, { apiType, model, configId }, { responseLength: text.length }); + return text; + } } } + this.logAIRequestDebug(startTime, { apiType, model, configId }, { error: 'request failed' }); throw new Error('No content received from AI service'); } @@ -315,10 +359,17 @@ ${options.user}` : options.user; tags: string[]; platforms: string[]; }> { + const startTime = Date.now(); + const configId = this.config.id; + const { full_name } = repository; + const owner = full_name.split('/')[0] || ''; + const repo = full_name.split('/')[1] || full_name; + logger.info('ai', 'AI analysis started', { owner, repo, configId }); + const prompt = this.config.useCustomPrompt && this.config.customPrompt ? this.createCustomAnalysisPrompt(repository, readmeContent, customCategories) : this.createAnalysisPrompt(repository, readmeContent, customCategories); - + try { const system = this.language === 'zh' ? '你是一个专业的GitHub仓库分析助手。请严格按照用户指定的语言进行分析,无论原始内容是什么语言。请用中文简洁地分析仓库,提供实用的概述、分类标签和支持的平台类型。只输出合法JSON,不要输出思考过程、Markdown、代码块标记或任何额外文本。' @@ -332,9 +383,11 @@ ${options.user}` : options.user; signal, }); - return this.parseAIResponse(content); + const result = this.parseAIResponse(content); + logger.info('ai', 'AI analysis completed', { owner, repo, configId, durationMs: Date.now() - startTime }); + return result; } catch (error) { - logger.errorFromError('ai', 'AI analysis failed', error, { configId: this.config.id }); + logger.errorFromError('ai', 'AI analysis failed', error, { configId, durationMs: Date.now() - startTime }); // 抛出错误,让调用方处理失败状态 throw error; } @@ -648,6 +701,7 @@ ${repoInfo} } async searchRepositories(repositories: Repository[], query: string): Promise { + const startTime = Date.now(); if (!query.trim()) return repositories; try { @@ -670,7 +724,7 @@ ${repoInfo} return this.performEnhancedSearch(repositories, query, searchTerms); } } catch (error) { - logger.warn('ai', 'AI search failed, falling back to basic search', { configId: this.config.id }); + logger.warn('ai', 'AI search failed, falling back to basic search', { configId: this.config.id, durationMs: Date.now() - startTime }); } // Fallback to basic search @@ -688,6 +742,7 @@ ${repoInfo} * @returns Filtered and ranked repositories matching the query */ async searchRepositoriesWithReranking(repositories: Repository[], query: string): Promise { + const startTime = Date.now(); logger.info('ai', 'Starting enhanced search', { query }); if (!query.trim()) return repositories; @@ -708,11 +763,11 @@ ${repoInfo} if (content) { const searchTerms = this.parseSearchResponse(content); const results = this.performEnhancedSearch(repositories, query, searchTerms); - logger.info('ai', 'AI semantic search completed', { resultCount: results.length, apiType: this.getApiType(), model: this.config.model }); + logger.info('ai', 'AI semantic search completed', { resultCount: results.length, apiType: this.getApiType(), model: this.config.model, durationMs: Date.now() - startTime }); return results; } } catch (error) { - logger.warn('ai', 'AI semantic search failed, falling back to enhanced basic search', { apiType: this.getApiType(), model: this.config.model, configId: this.config.id }); + logger.warn('ai', 'AI semantic search failed, falling back to enhanced basic search', { apiType: this.getApiType(), model: this.config.model, configId: this.config.id, durationMs: Date.now() - startTime }); } logger.info('ai', 'Using enhanced basic search with intelligent ranking'); diff --git a/src/services/autoSync.ts b/src/services/autoSync.ts index 747497b3..918d4785 100644 --- a/src/services/autoSync.ts +++ b/src/services/autoSync.ts @@ -63,6 +63,7 @@ export async function syncFromBackend(): Promise { _isSyncingFromBackendActive = true; + const startTime = Date.now(); try { const [reposResult, releasesResult, aiResult, webdavResult, settingsResult] = await Promise.allSettled([ backend.fetchRepositories(), @@ -227,9 +228,9 @@ export async function syncFromBackend(): Promise { _lastHash.settings = hashes.settings; } - logger.info('sync.pullFromBackend', 'Synced from backend (data changed)', changed); + logger.info('sync.pullFromBackend', 'Synced from backend (data changed)', { ...changed, durationMs: Date.now() - startTime }); } catch (err) { - logger.errorFromError('sync.pullFromBackend', 'Failed to sync from backend', err); + logger.errorFromError('sync.pullFromBackend', 'Failed to sync from backend', err, { durationMs: Date.now() - startTime }); } finally { setRepositorySyncVisualState(false); _isSyncingFromBackend = false; @@ -259,6 +260,7 @@ export async function syncToBackend(): Promise { _isPushingToBackend = true; _hasPendingPush = false; setRepositorySyncVisualState(true); + const pushStartTime = Date.now(); try { const state = useAppStore.getState(); @@ -281,10 +283,10 @@ export async function syncToBackend(): Promise { const failures = results.filter(r => r.status === 'rejected'); if (failures.length > 0) { - logger.warn('sync.pushToBackend', `Synced to backend with ${failures.length} error(s)`, { failureCount: failures.length }); + logger.warn('sync.pushToBackend', `Synced to backend with ${failures.length} error(s)`, { failureCount: failures.length, durationMs: Date.now() - pushStartTime }); _hasPendingLocalChanges = true; } else { - logger.info('sync.pushToBackend', 'Synced to backend'); + logger.info('sync.pushToBackend', 'Synced to backend', { durationMs: Date.now() - pushStartTime }); _hasPendingLocalChanges = false; } @@ -305,7 +307,7 @@ export async function syncToBackend(): Promise { }); } } catch (err) { - logger.errorFromError('sync.pushToBackend', 'Failed to sync to backend', err); + logger.errorFromError('sync.pushToBackend', 'Failed to sync to backend', err, { durationMs: Date.now() - pushStartTime }); } finally { setRepositorySyncVisualState(false); _isPushingToBackend = false; diff --git a/src/services/backendAdapter.ts b/src/services/backendAdapter.ts index 23623307..bd801978 100644 --- a/src/services/backendAdapter.ts +++ b/src/services/backendAdapter.ts @@ -69,6 +69,9 @@ class BackendAdapter { return headers; } private async fetchWithTimeout(url: string, options?: RequestInit, timeoutMs = 30000): Promise { + const startTime = Date.now(); + const method = (options?.method || 'GET').toUpperCase(); + const path = url.replace(/^https?:\/\/[^/]+/, ''); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); @@ -83,7 +86,16 @@ class BackendAdapter { } try { - return await fetch(url, { ...options, signal: controller.signal }); + const response = await fetch(url, { ...options, signal: controller.signal }); + if (logger.isDebugMode()) { + logger.debug('backendAdapter', 'Backend request', { method, path, status: response.status, durationMs: Date.now() - startTime }); + } + return response; + } catch (err) { + if (logger.isDebugMode()) { + logger.debug('backendAdapter', 'Backend request', { method, path, error: 'timeout/network error', durationMs: Date.now() - startTime }); + } + throw err; } finally { clearTimeout(timeoutId); } @@ -94,6 +106,9 @@ class BackendAdapter { * Covers browser fetch (Chrome/Firefox/Safari) and Node.js undici fetch. */ private async fetchWithRetry(url: string, options?: RequestInit, timeoutMs = 30000, maxRetries = 3): Promise { + const retryStartTime = Date.now(); + const method = (options?.method || 'GET').toUpperCase(); + const path = url.replace(/^https?:\/\/[^/]+/, ''); let lastError: Error | undefined; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { @@ -116,7 +131,7 @@ class BackendAdapter { if (!isRetryable || attempt === maxRetries) throw lastError; // Exponential backoff: 1s, 2s, 4s const delay = Math.min(1000 * Math.pow(2, attempt), 4000); - logger.warn('backendAdapter', 'Sync request failed, retrying', { attempt: attempt + 1, maxRetries: maxRetries + 1, delayMs: delay }); + logger.warn('backendAdapter', 'Sync request failed, retrying', { attempt: attempt + 1, maxRetries: maxRetries + 1, delayMs: delay, durationMs: Date.now() - retryStartTime, method, path }); await new Promise(resolve => setTimeout(resolve, delay)); } } diff --git a/src/services/githubApi.ts b/src/services/githubApi.ts index e540b360..bfd7de44 100644 --- a/src/services/githubApi.ts +++ b/src/services/githubApi.ts @@ -58,6 +58,9 @@ export class GitHubApiService { } private async makeRequest(endpoint: string, options: RequestInit = {}, signal?: AbortSignal): Promise { + const startTime = Date.now(); + const method = (options.method || 'GET') as string; + // Check rate limit before making request if (this.rateLimitRemaining !== null && this.rateLimitRemaining < 100 && this.rateLimitReset !== null) { const waitMs = (this.rateLimitReset * 1000) - Date.now(); @@ -83,16 +86,23 @@ export class GitHubApiService { } } - const response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { - ...options, - signal, - headers: { - 'Authorization': `Bearer ${this.token}`, - 'Accept': 'application/vnd.github.v3+json', - 'X-GitHub-Api-Version': '2022-11-28', - ...options.headers, - }, - }); + let response: Response; + try { + response = await fetch(`${GITHUB_API_BASE}${endpoint}`, { + ...options, + signal, + headers: { + 'Authorization': `Bearer ${this.token}`, + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...options.headers, + }, + }); + } catch (fetchError) { + const durationMs = Date.now() - startTime; + logger.error('githubApi', 'API request network error', { method, endpoint, durationMs, error: fetchError instanceof Error ? fetchError.message : String(fetchError) }); + throw fetchError; + } // Parse rate limit headers const remaining = response.headers.get('X-RateLimit-Remaining'); @@ -105,18 +115,26 @@ export class GitHubApiService { } if (!response.ok) { + const durationMs = Date.now() - startTime; if (response.status === 401) { + logger.warn('githubApi', 'API request failed: unauthorized', { method, endpoint, status: response.status, durationMs }); throw new Error('GitHub token expired or invalid'); } if (response.status === 403 && this.rateLimitRemaining === 0) { const resetDate = this.rateLimitReset ? new Date(this.rateLimitReset * 1000).toLocaleString() : 'unknown'; + logger.warn('githubApi', 'API request failed: rate limit exceeded', { method, endpoint, status: response.status, durationMs }); throw new Error(`GitHub API rate limit exceeded. Resets at ${resetDate}`); } + logger.warn('githubApi', 'API request failed', { method, endpoint, status: response.status, durationMs }); throw new Error(`GitHub API error: ${response.status} ${response.statusText}`); } + if (logger.isDebugMode()) { + logger.debug('githubApi', 'API request', { method, endpoint, status: response.status, durationMs: Date.now() - startTime, rateLimitRemaining: response.headers.get('x-ratelimit-remaining') }); + } + const data = response.status === 204 ? null : await response.json(); // 如果是starred repositories的响应,需要处理特殊格式 @@ -275,6 +293,7 @@ export class GitHubApiService { repositories: Repository[], options: ReleaseFetchOptions = {} ): Promise { + const startTime = Date.now(); const { includePreRelease = true } = options; const allReleases: Release[] = []; const failedRepos: { repoId: number; full_name: string; error: string }[] = []; @@ -360,6 +379,8 @@ export class GitHubApiService { new Date(b.published_at).getTime() - new Date(a.published_at).getTime() ); + logger.info('githubApi', 'Update releases completed', { repoCount: repositories.length, releaseCount: sortedReleases.length, durationMs: Date.now() - startTime }); + return { releases: sortedReleases, failedRepos }; } @@ -468,6 +489,7 @@ export class GitHubApiService { } async searchTrending(perPage = 10, timeRange: 'daily' | 'weekly' | 'monthly' = 'weekly'): Promise { + const startTime = Date.now(); // 使用 GitHubTrendingRSS API const rssUrl = `https://mshibanami.github.io/GitHubTrendingRSS/${timeRange}/all.xml`; @@ -558,6 +580,8 @@ export class GitHubApiService { })); } + logger.info('githubApi', 'Refresh trending completed', { repoCount: repos.length, durationMs: Date.now() - startTime }); + return repos; } catch (error) { logger.error('githubApi', 'Failed to fetch trending from RSS', error); @@ -665,6 +689,7 @@ export class GitHubApiService { perPage: number = 20, timeRange: TrendingTimeRange = 'weekly' ): Promise { + const startTime = Date.now(); const rssUrlMap: Record = { daily: 'https://mshibanami.github.io/GitHubTrendingRSS/daily/all.xml', weekly: 'https://mshibanami.github.io/GitHubTrendingRSS/weekly/all.xml', @@ -781,6 +806,8 @@ export class GitHubApiService { // Assign rank based on position repos.forEach((r, idx) => { r.rank = startIndex + idx + 1; }); + logger.info('githubApi', 'Refresh trending completed', { repoCount: repos.length, durationMs: Date.now() - startTime }); + return { repos, hasMore: endIndex < items.length, diff --git a/src/services/logger.ts b/src/services/logger.ts index 8eabb762..26185d5f 100644 --- a/src/services/logger.ts +++ b/src/services/logger.ts @@ -52,6 +52,11 @@ class Logger { // Forward to console for dev experience this.forwardToConsole(entry); + + // Notify UI listeners + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('gsm:diagnostic-log-added', { detail: entry })); + } } debug(module: string, message: string, data?: unknown): void { @@ -126,12 +131,27 @@ class Logger { clear(): void { this.buffer = []; + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('gsm:diagnostic-logs-cleared')); + } } setLevel(level: LogLevel): void { this.minLevel = level; } + isDebugMode(): boolean { + return this.minLevel === 'debug'; + } + + getModules(): string[] { + const modules = new Set(); + for (const entry of this.buffer) { + modules.add(entry.module); + } + return Array.from(modules).sort(); + } + /** * Build the export JSON structure (frontend logs only). * The UI component merges this with backend logs. diff --git a/src/services/webdavService.ts b/src/services/webdavService.ts index 35c69775..c1729de8 100644 --- a/src/services/webdavService.ts +++ b/src/services/webdavService.ts @@ -206,6 +206,8 @@ export class WebDAVService { const uploadOperation = async (): Promise => { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), dynamicTimeout); + const sanitizedPath = this.getFullPath(filename).replace(/^https?:\/\/[^\/]+/, ''); + const startTime = Date.now(); try { const response = await fetch(this.getFullPath(filename), { @@ -220,6 +222,10 @@ export class WebDAVService { clearTimeout(timeoutId); + if (logger.isDebugMode()) { + logger.debug('webdav', 'WebDAV request', { method: 'PUT', path: sanitizedPath, status: response.status, durationMs: Date.now() - startTime }); + } + if (!response.ok) { if (response.status === 401) { throw new Error('身份验证失败。请检查用户名和密码。'); @@ -307,6 +313,8 @@ export class WebDAVService { try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时 + const sanitizedPath = this.getFullPath(filename).replace(/^https?:\/\/[^\/]+/, ''); + const startTime = Date.now(); try { const response = await fetch(this.getFullPath(filename), { @@ -316,34 +324,38 @@ export class WebDAVService { }, signal: controller.signal, }); - + clearTimeout(timeoutId); + if (logger.isDebugMode()) { + logger.debug('webdav', 'WebDAV request', { method: 'GET', path: sanitizedPath, status: response.status, durationMs: Date.now() - startTime }); + } + if (response.ok) { return await response.text(); } - + if (response.status === 404) { return null; // 文件未找到是预期行为 } - + if (response.status === 401) { throw new Error('身份验证失败。请检查用户名和密码。'); } - + throw new Error(`下载失败,HTTP状态码 ${response.status}: ${response.statusText}`); } catch (fetchError: unknown) { clearTimeout(timeoutId); - + if ((fetchError as Error).name === 'AbortError') { throw new Error('下载超时。请检查网络连接。'); } - + throw fetchError; } } catch (error: unknown) { const err = error as Error; - if (err.message.includes('身份验证失败') || + if (err.message.includes('身份验证失败') || err.message.includes('下载超时')) { throw error; } diff --git a/src/utils/logEventTypes.ts b/src/utils/logEventTypes.ts new file mode 100644 index 00000000..d428a96f --- /dev/null +++ b/src/utils/logEventTypes.ts @@ -0,0 +1,46 @@ +export type LogEventType = + | 'sync' + | 'aiAnalysis' + | 'aiSearch' + | 'githubApi' + | 'trending' + | 'release' + | 'backendSync' + | 'webdav' + | 'update' + | 'store' + | 'app' + | 'error' + | 'other'; + +export const EVENT_TYPE_LABELS: Record = { + sync: { zh: '同步仓库', en: 'Sync Repos' }, + aiAnalysis: { zh: 'AI 分析', en: 'AI Analysis' }, + aiSearch: { zh: 'AI 搜索', en: 'AI Search' }, + githubApi: { zh: 'GitHub API', en: 'GitHub API' }, + trending: { zh: '刷新趋势', en: 'Refresh Trending' }, + release: { zh: '更新 Release', en: 'Update Releases' }, + backendSync: { zh: '后端同步', en: 'Backend Sync' }, + webdav: { zh: 'WebDAV 备份', en: 'WebDAV Backup' }, + update: { zh: '应用更新', en: 'App Update' }, + store: { zh: '数据存储', en: 'Data Store' }, + app: { zh: '应用', en: 'App' }, + error: { zh: '错误', en: 'Error' }, + other: { zh: '其他', en: 'Other' }, +}; + +export function inferEventType(module: string, message: string): LogEventType { + if (module.startsWith('sync')) return 'sync'; + if (module === 'ai' && /analysis|analyze/i.test(message)) return 'aiAnalysis'; + if (module === 'ai' && /search/i.test(message)) return 'aiSearch'; + if (module === 'githubApi' && /trending/i.test(message)) return 'trending'; + if (module === 'githubApi' && /release/i.test(message)) return 'release'; + if (module === 'backendAdapter') return 'backendSync'; + if (module === 'webdav') return 'webdav'; + if (module === 'update') return 'update'; + if (module.startsWith('store')) return 'store'; + if (module === 'app') return 'app'; + if (module === 'ui.errorBoundary') return 'error'; + if (module === 'githubApi') return 'githubApi'; + return 'other'; +} \ No newline at end of file From d49c1a7cfa7c0e7f693216c1e5357f8179025bb9 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 22:55:22 +0800 Subject: [PATCH 2/6] fix: address CodeRabbit review comments - Remove unnecessary regex escape in webdavService.ts (2 places) - Initialize backendDebug state from server on mount instead of sessionStorage - Remove unused availableModules useMemo from DiagnosticLogsPanel - Change frontendLogs from let to const in export handler - Fix debug pill disabled logic to allow backend debug in 'all' scope - Remove unused error parameter in aiService catch block Fixes 5 actionable CodeRabbit review comments. --- .../settings/DiagnosticLogsPanel.tsx | 49 ++++++++++++------- src/services/aiService.ts | 2 +- src/services/webdavService.ts | 4 +- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/src/components/settings/DiagnosticLogsPanel.tsx b/src/components/settings/DiagnosticLogsPanel.tsx index a34fad05..b387d37c 100644 --- a/src/components/settings/DiagnosticLogsPanel.tsx +++ b/src/components/settings/DiagnosticLogsPanel.tsx @@ -78,9 +78,30 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = } return false; }); - const [backendDebug, setBackendDebug] = useState(() => sessionStorage.getItem('gsm:backend-debug') === 'true'); + const [backendDebug, setBackendDebug] = useState(false); const backendAvailable = backend.isAvailable; + // Initialize backend debug state from server on mount + useEffect(() => { + if (!backendAvailable) return; + const fetchDebugState = async () => { + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + const res = await fetch('/api/logs/debug', { + headers: { Authorization: `Bearer ${secret}` }, + }); + if (res.ok) { + const data = await res.json(); + setBackendDebug(data.debugMode); + sessionStorage.setItem('gsm:backend-debug', String(data.debugMode)); + } + } catch { + // Backend unreachable, keep false + } + }; + fetchDebugState(); + }, [backendAvailable]); + // Log entries state const [entries, setEntries] = useState(() => logger.getEntries()); const [backendEntries, setBackendEntries] = useState([]); @@ -165,15 +186,6 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = return Array.from(types).sort(); }, [allEntries]); - // Derived: available modules - const availableModules = useMemo(() => { - const mods = new Set(); - for (const entry of allEntries) { - mods.add(entry.module); - } - return Array.from(mods).sort(); - }, [allEntries]); - // Filter entries const filteredEntries = useMemo(() => { return allEntries.filter(entry => { @@ -204,11 +216,9 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = // Toggle backend debug mode const toggleBackendDebug = useCallback(async () => { const next = !backendDebug; - setBackendDebug(next); - sessionStorage.setItem('gsm:backend-debug', String(next)); try { const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); - await fetch('/api/logs/debug', { + const res = await fetch('/api/logs/debug', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -216,8 +226,13 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = }, body: JSON.stringify({ enabled: next }), }); + if (res.ok) { + const data = await res.json(); + setBackendDebug(data.debugMode); + sessionStorage.setItem('gsm:backend-debug', String(data.debugMode)); + } } catch { - // Backend unreachable + // Backend unreachable, don't update state } }, [backendDebug]); @@ -271,9 +286,9 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = const minLevel = selectedLevels.size > 0 ? (Object.entries(levelOrder).find(([l]) => selectedLevels.has(l as LogLevel))?.[1] ?? 3) : 3; - const minLevelName = (Object.entries(levelOrder).find(([_, v]) => v === minLevel)?.[0] as LogLevel) || 'info'; + const minLevelName = (Object.entries(levelOrder).find(([, v]) => v === minLevel)?.[0] as LogLevel) || 'info'; - let frontendLogs = selectedScope !== 'backend' + const frontendLogs = selectedScope !== 'backend' ? logger.getEntries({ level: minLevelName }).filter(e => selectedLevels.has(e.level)) : []; let backendLogs: LogEntry[] = []; @@ -461,7 +476,7 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) =
{t('级别', 'Level')}: {(['debug', 'info', 'warn', 'error'] as LogLevel[]).map(level => { - const disabled = level === 'debug' && !frontendDebug && selectedScope !== 'backend'; + const disabled = level === 'debug' && !frontendDebug && !(selectedScope === 'backend' || (selectedScope === 'all' && backendDebug)); const selected = selectedLevels.has(level); return (
{/* Event type dropdown */} -
+
+
+ + {/* Tabs */} +
+ {MODAL_TABS.map(tab => ( + + ))} +
+ + {/* Content */} +
+ {renderTabContent()} +
+
+ + ); +}; + +const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( +
+ {label} +
{children}
+
+); + +const DataBlock: React.FC<{ data: unknown; t: (zh: string, en: string) => string; emptyText: string }> = ({ data, t, emptyText }) => { + if (data == null) { + return

{emptyText}

; + } + const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + return ( +
+      {text}
+    
+ ); +}; + +// ─── Global Debug Indicator ──────────────────────────────────── +interface DebugModeIndicatorProps { + frontendDebug: boolean; + backendDebug: boolean; + onToggleFrontend: () => void; + t: (zh: string, en: string) => string; } +const DebugModeIndicator: React.FC = ({ frontendDebug, backendDebug, onToggleFrontend, t }) => { + if (!frontendDebug && !backendDebug) return null; + return ( + + ); +}; + +// ─── Main Panel ──────────────────────────────────────────────── export const DiagnosticLogsPanel: React.FC = ({ t }) => { const language = useAppStore.getState().language; @@ -95,9 +284,7 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = setBackendDebug(data.debugMode); sessionStorage.setItem('gsm:backend-debug', String(data.debugMode)); } - } catch { - // Backend unreachable, keep false - } + } catch { /* Backend unreachable */ } }; fetchDebugState(); }, [backendAvailable]); @@ -108,7 +295,10 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = const [backendLogCount, setBackendLogCount] = useState(0); const [isExporting, setIsExporting] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); - const [expandedData, setExpandedData] = useState>(new Set()); + const [detailEntry, setDetailEntry] = useState(null); + + // Pagination + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); // Filter state const [searchQuery, setSearchQuery] = useState(''); @@ -148,9 +338,7 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = }); } }; - const handleLogsCleared = () => { - setEntries([]); - }; + const handleLogsCleared = () => { setEntries([]); }; window.addEventListener('gsm:diagnostic-log-added', handleLogAdded); window.addEventListener('gsm:diagnostic-logs-cleared', handleLogsCleared); return () => { @@ -161,19 +349,12 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = // Backend log fetching useEffect(() => { - if (selectedScope === 'frontend') { - setBackendEntries([]); - setBackendLogCount(0); - return; - } + if (selectedScope === 'frontend') { setBackendEntries([]); setBackendLogCount(0); return; } if (!backendAvailable) return; - const fetchBackend = async () => { try { const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); - const res = await fetch('/api/logs?limit=2000', { - headers: { Authorization: `Bearer ${secret}` }, - }); + const res = await fetch('/api/logs?limit=2000', { headers: { Authorization: `Bearer ${secret}` } }); if (res.ok) { const raw = await res.json(); const logs = Array.isArray(raw) ? raw : []; @@ -181,28 +362,24 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = const totalHeader = res.headers.get('X-Log-Count'); setBackendLogCount(totalHeader ? parseInt(totalHeader) || 0 : logs.length); } - } catch { - // Backend unreachable - } + } catch { /* Backend unreachable */ } }; fetchBackend(); const interval = setInterval(fetchBackend, 10000); return () => clearInterval(interval); }, [selectedScope, backendAvailable]); - // Merge entries + // Merge entries — sorted by timestamp DESCENDING (newest first) const allEntries = useMemo(() => { if (selectedScope === 'frontend') return entries; if (selectedScope === 'backend') return backendEntries; - return [...entries, ...backendEntries].sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + return [...entries, ...backendEntries].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [entries, backendEntries, selectedScope]); // Derived: available event types const availableEventTypes = useMemo(() => { const types = new Set(); - for (const entry of allEntries) { - types.add(inferEventType(entry.module, entry.message)); - } + for (const entry of allEntries) types.add(inferEventType(entry.module, entry.message)); return Array.from(types).sort(); }, [allEntries]); @@ -219,6 +396,12 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = }); }, [allEntries, selectedLevels, selectedEventTypes, searchQuery]); + // Visible entries (pagination) + const visibleEntries = useMemo(() => filteredEntries.slice(0, visibleCount), [filteredEntries, visibleCount]); + + // Reset visible count when filters change + useEffect(() => { setVisibleCount(PAGE_SIZE); }, [searchQuery, selectedLevels, selectedEventTypes, selectedScope]); + // Frontend counts const frontendCounts = useMemo(() => logger.getCounts(), [entries]); @@ -240,10 +423,7 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); const res = await fetch('/api/logs/debug', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${secret}`, - }, + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` }, body: JSON.stringify({ enabled: next }), }); if (res.ok) { @@ -251,54 +431,43 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = setBackendDebug(data.debugMode); sessionStorage.setItem('gsm:backend-debug', String(data.debugMode)); } - } catch { - // Backend unreachable, don't update state - } + } catch { /* Backend unreachable */ } }, [backendDebug]); + // Quick disable debug from global indicator + const disableAllDebug = useCallback(() => { + if (frontendDebug) toggleFrontendDebug(); + if (backendDebug) toggleBackendDebug(); + }, [frontendDebug, backendDebug, toggleFrontendDebug, toggleBackendDebug]); + // Clear logs const handleClear = useCallback(async () => { - if (selectedScope === 'frontend' || selectedScope === 'all') { - logger.clear(); - setEntries([]); - } + if (selectedScope === 'frontend' || selectedScope === 'all') { logger.clear(); setEntries([]); } if ((selectedScope === 'backend' || selectedScope === 'all') && backendAvailable) { try { const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); - await fetch('/api/logs', { - method: 'DELETE', - headers: { Authorization: `Bearer ${secret}` }, - }); - setBackendEntries([]); - setBackendLogCount(0); - } catch { - // Backend unreachable - } + await fetch('/api/logs', { method: 'DELETE', headers: { Authorization: `Bearer ${secret}` } }); + setBackendEntries([]); setBackendLogCount(0); + } catch { /* Backend unreachable */ } } }, [selectedScope, backendAvailable]); - // Refresh backend entries + // Refresh const handleRefresh = useCallback(async () => { setIsRefreshing(true); try { const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); - const res = await fetch('/api/logs?limit=2000', { - headers: { Authorization: `Bearer ${secret}` }, - }); + const res = await fetch('/api/logs?limit=2000', { headers: { Authorization: `Bearer ${secret}` } }); if (res.ok) { const raw = await res.json(); setBackendEntries(Array.isArray(raw) ? raw : []); const totalHeader = res.headers.get('X-Log-Count'); setBackendLogCount(totalHeader ? parseInt(totalHeader) || 0 : raw.length); } - } catch { - // Backend unreachable - } finally { - setIsRefreshing(false); - } + } catch { /* Backend unreachable */ } finally { setIsRefreshing(false); } }, []); - // Export logs + // Export const handleExport = useCallback(async () => { setIsExporting(true); try { @@ -307,26 +476,16 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = ? (Object.entries(levelOrder).find(([l]) => selectedLevels.has(l as LogLevel))?.[1] ?? 3) : 3; const minLevelName = (Object.entries(levelOrder).find(([, v]) => v === minLevel)?.[0] as LogLevel) || 'info'; - const frontendLogs = selectedScope !== 'backend' - ? logger.getEntries({ level: minLevelName }).filter(e => selectedLevels.has(e.level)) - : []; + ? logger.getEntries({ level: minLevelName }).filter(e => selectedLevels.has(e.level)) : []; let backendLogs: LogEntry[] = []; if (selectedScope !== 'frontend' && backendAvailable) { try { const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); - const res = await fetch(`/api/logs?limit=2000&level=${minLevelName}`, { - headers: { Authorization: `Bearer ${secret}` }, - }); - if (res.ok) { - const raw = await res.json(); - backendLogs = Array.isArray(raw) ? raw.filter((e: LogEntry) => selectedLevels.has(e.level)) : []; - } - } catch { - // Backend unreachable - } + const res = await fetch(`/api/logs?limit=2000&level=${minLevelName}`, { headers: { Authorization: `Bearer ${secret}` } }); + if (res.ok) { const raw = await res.json(); backendLogs = Array.isArray(raw) ? raw.filter((e: LogEntry) => selectedLevels.has(e.level)) : []; } + } catch { /* Backend unreachable */ } } - const state = useAppStore.getState(); const isElectron = typeof window !== 'undefined' && window.electronAPI; const environment = { @@ -342,365 +501,220 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = backendDebugMode: backendDebug, appVersion, }; - const exportData = { format: 'github-stars-manager-logs-v1', exportDate: new Date().toISOString(), - appVersion, - environment, - sanitizationNote: t( - '所有 Token、API Key、密码、邮箱已脱敏为 ***格式', - 'All tokens, API keys, passwords, and emails have been masked as ***' - ), - frontendLogs, - backendLogs, + appVersion, environment, + sanitizationNote: t('所有 Token、API Key、密码、邮箱已脱敏为 ***格式', 'All tokens, API keys, passwords, and emails have been masked as ***'), + frontendLogs, backendLogs, }; - const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; + const a = document.createElement('a'); a.href = url; a.download = `github-stars-manager-logs-${new Date().toISOString().split('T')[0]}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - } catch { - // Export failed - } finally { - setIsExporting(false); - } + document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); + } catch { /* Export failed */ } finally { setIsExporting(false); } }, [selectedScope, selectedLevels, backendAvailable, frontendDebug, backendDebug, t]); - // Toggle level const toggleLevel = useCallback((level: LogLevel) => { - setSelectedLevels(prev => { - const next = new Set(prev); - if (next.has(level)) next.delete(level); - else next.add(level); - return next; - }); - }, []); - - // Toggle event type - const toggleEventType = useCallback((eventType: LogEventType) => { - setSelectedEventTypes(prev => { - const next = new Set(prev); - if (next.has(eventType)) next.delete(eventType); - else next.add(eventType); - return next; - }); + setSelectedLevels(prev => { const next = new Set(prev); if (next.has(level)) next.delete(level); else next.add(level); return next; }); }, []); - // Toggle data expansion - const toggleDataExpand = useCallback((id: string) => { - setExpandedData(prev => { - const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); - return next; - }); + const toggleEventType = useCallback((et: LogEventType) => { + setSelectedEventTypes(prev => { const next = new Set(prev); if (next.has(et)) next.delete(et); else next.add(et); return next; }); }, []); const totalCount = allEntries.length; return ( -
- {/* Debug Mode Section */} -
-

- - {t('调试模式', 'Debug Mode')} -

-
- {/* Frontend debug toggle */} -
-
-
- {t('前端调试', 'Frontend Debug')} - - {frontendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')} - + <> + {/* Global debug indicator (floats on all pages) */} + + + {/* Detail modal */} + {detailEntry && setDetailEntry(null)} />} + +
+ {/* Debug Mode Section */} +
+

+ + {t('调试模式', 'Debug Mode')} +

+
+
+
+
+ {t('前端调试', 'Frontend Debug')} + + {frontendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')} + +
+

+ {t('开启后将记录所有前端 HTTP 请求详情(方法、路径、状态码、耗时)', 'Records all frontend HTTP request details (method, path, status, duration)')} +

-

- {t('开启后将记录所有前端 HTTP 请求详情(方法、路径、状态码、耗时)', 'Records all frontend HTTP request details (method, path, status, duration)')} -

+
- -
- - {/* Backend debug toggle */} -
-
-
- - {t('后端调试', 'Backend Debug')} - - - {backendAvailable - ? (backendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')) - : t('后端未连接', 'Not connected')} - +
+
+
+ {t('后端调试', 'Backend Debug')} + + {backendAvailable ? (backendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')) : t('后端未连接', 'Not connected')} + +
+

{t('开启后将记录所有后端 HTTP 请求详情', 'Records all backend HTTP request details')}

-

- {t('开启后将记录所有后端 HTTP 请求详情', 'Records all backend HTTP request details')} -

+ +
+
+ + {t('调试模式会产生大量日志,仅用于排障时短暂开启', 'Debug mode produces many logs — enable briefly only for troubleshooting')}
-
+
- {/* Warning */} -
- - {t('调试模式会产生大量日志,仅用于排障时短暂开启', 'Debug mode produces many logs — enable briefly only for troubleshooting')} -
+ {/* Privacy Notice */} +
+ + {t('日志仅记录端点、模型、状态、耗时和错误摘要。所有 Token、API Key、密码、邮箱已自动脱敏为 ***格式', 'Logs store only endpoints, models, status, duration, and error summaries. All sensitive info is automatically masked as ***')}
-
- - {/* Privacy Notice */} -
- - - {t('日志仅记录端点、模型、状态、耗时和错误摘要。所有 Token、API Key、密码、邮箱已自动脱敏为 ***格式', 'Logs store only endpoints, models, status, duration, and error summaries. All sensitive info (Token, API Key, password, email) is automatically masked as ***')} - -
- {/* Toolbar */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - placeholder={t('搜索模块或消息...', 'Search module or message...')} - className="w-full pl-10 pr-4 py-2 rounded-lg border border-black/[0.06] dark:border-white/[0.04] bg-light-surface dark:bg-white/[0.04] text-gray-900 dark:text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-brand-violet" - /> -
+ {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + placeholder={t('搜索模块或消息...', 'Search module or message...')} + className="w-full pl-10 pr-4 py-2 rounded-lg border border-black/[0.06] dark:border-white/[0.04] bg-light-surface dark:bg-white/[0.04] text-gray-900 dark:text-text-primary text-sm focus:outline-none focus:ring-2 focus:ring-brand-violet" /> +
- {/* Level pills */} -
- {t('级别', 'Level')}: - {(['debug', 'info', 'warn', 'error'] as LogLevel[]).map(level => { - const disabled = level === 'debug' && !frontendDebug && !(selectedScope === 'backend' || (selectedScope === 'all' && backendDebug)); - const selected = selectedLevels.has(level); - return ( - - ); - })} -
- - {/* Scope + Event type + Actions row */} -
- {/* Scope toggle */} -
- {(['all', 'frontend', 'backend'] as const).map(scope => ( - ))}
- {/* Event type dropdown */} -
- - {showEventTypeDropdown && ( -
- {availableEventTypes.map(et => ( - - ))} -
- )} -
- - {/* Action buttons */} -
- - - + {/* Scope + Event type + Actions */} +
+
+ {(['all', 'frontend', 'backend'] as const).map(scope => ( + + ))} +
+
+ + {showEventTypeDropdown && ( +
+ {availableEventTypes.map(et => ( + + ))} +
+ )} +
+
+ + + +
-
- {/* Count summary */} -
- {t(`显示 ${filteredEntries.length} / ${totalCount} 条`, `Showing ${filteredEntries.length} / ${totalCount} entries`)} - {(frontendDebug || backendDebug) && ( - - {t('调试模式已开启', 'Debug mode ON')} - - )} - {selectedScope !== 'backend' && ( - - · {t(`前端 ${frontendCounts.total}`, `Frontend ${frontendCounts.total}`)} - - )} - {selectedScope !== 'frontend' && backendAvailable && ( - - · {t(`后端 ${backendLogCount}`, `Backend ${backendLogCount}`)} - - )} -
-
- - {/* Log Entry List */} -
- {filteredEntries.length === 0 ? ( -
- - {totalCount === 0 - ? t('暂无日志', 'No logs yet') - : t('无匹配日志', 'No matching logs')} +
+ {t(`显示 ${filteredEntries.length} / ${totalCount} 条`, `Showing ${filteredEntries.length} / ${totalCount} entries`)} + {(frontendDebug || backendDebug) && {t('调试模式已开启', 'Debug mode ON')}} + {selectedScope !== 'backend' && · {t(`前端 ${frontendCounts.total}`, `Frontend ${frontendCounts.total}`)}} + {selectedScope !== 'frontend' && backendAvailable && · {t(`后端 ${backendLogCount}`, `Backend ${backendLogCount}`)}}
- ) : ( -
- {filteredEntries.map(entry => { - const eventType = inferEventType(entry.module, entry.message); - const httpSummary = getHttpSummary(entry.data); - const entryData = entry.data as Record | undefined; - const hasData = entryData && typeof entryData === 'object' && !Array.isArray(entryData) && Object.keys(entryData).length > 0; - const isExpanded = expandedData.has(entry.id); - const statusColor = entryData?.status ? getStatusColor(entryData.status) : ''; - - return ( -
- {/* Header row */} -
- - {entry.level} - - - {entry.source === 'frontend' ? t('前端', 'FE') : t('后端', 'BE')} - - - {language === 'zh' ? EVENT_TYPE_LABELS[eventType].zh : EVENT_TYPE_LABELS[eventType].en} - - - {formatRelativeTime(entry.timestamp)} - - - {entry.module} - -
- - {/* Message */} -

- {entry.message} -

- - {/* HTTP summary line */} - {httpSummary && ( -
- {String(entryData?.method ?? '')} - {String(entryData?.endpoint ?? entryData?.path ?? '')} - {entryData?.status && ( - → {String(entryData.status)} - )} - {entryData?.durationMs && ( - {String(entryData.durationMs)}ms - )} -
- )} - - {/* Expandable data block */} - {hasData && ( -
- - {isExpanded && ( -
-                          {JSON.stringify(entryData, null, 2)}
-                        
+
+ + {/* Log Entry List */} +
+ {filteredEntries.length === 0 ? ( +
+ + {totalCount === 0 ? t('暂无日志', 'No logs yet') : t('无匹配日志', 'No matching logs')} +
+ ) : ( + <> +
+ {visibleEntries.map(entry => { + const eventType = inferEventType(entry.module, entry.message); + const entryData = entry.data as Record | undefined; + const statusColor = entryData?.status ? getStatusColor(entryData.status) : ''; + const hasHttpDetail = entryData?.method || entryData?.status || entryData?.durationMs; + + return ( +
setDetailEntry(entry) : undefined}> +
+ {entry.level} + + {entry.source === 'frontend' ? t('前端', 'FE') : t('后端', 'BE')} + + + {language === 'zh' ? EVENT_TYPE_LABELS[eventType].zh : EVENT_TYPE_LABELS[eventType].en} + + {formatRelativeTime(entry.timestamp)} + {entry.module} + {hasHttpDetail && } +
+

{entry.message}

+ {hasHttpDetail && ( +
+ {entryData?.method && {String(entryData.method)}} + {(entryData?.endpoint || entryData?.path) && {String(entryData.endpoint ?? entryData.path)}} + {entryData?.status && → {String(entryData.status)}} + {entryData?.durationMs != null && {String(entryData.durationMs)}ms} +
)}
- )} + ); + })} +
+ {/* Load more */} + {visibleCount < filteredEntries.length && ( +
+
- ); - })} -
- )} -
- + )} + + )} + + + ); -}; \ No newline at end of file +}; From 7ec2180da35bf836451298ceb7d2f0399877e8e4 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Fri, 29 May 2026 23:49:09 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20sort=20all=20scope=20branches,=20remove=20unused=20?= =?UTF-8?q?prop,=20move=20indicator=20to=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sort frontend/backend entries descending in allEntries useMemo branches - Remove unused 't' prop from DataBlock component - Move DebugModeIndicator to App.tsx (global, always visible) - Create standalone DebugModeIndicator.tsx reading from sessionStorage - Remove DebugModeIndicator from DiagnosticLogsPanel (now in App.tsx) --- src/App.tsx | 2 + src/components/DebugModeIndicator.tsx | 65 +++++++++++++++++++ .../settings/DiagnosticLogsPanel.tsx | 47 ++------------ 3 files changed, 74 insertions(+), 40 deletions(-) create mode 100644 src/components/DebugModeIndicator.tsx diff --git a/src/App.tsx b/src/App.tsx index fb867e51..8f7d8424 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,7 @@ import { CategorySidebar } from './components/CategorySidebar'; import { ReleaseTimeline } from './components/ReleaseTimeline'; import { ForkTimeline } from './components/ForkTimeline'; import { SettingsPanel } from './components/SettingsPanel'; +import { DebugModeIndicator } from './components/DebugModeIndicator'; import { DiscoveryView } from './components/DiscoveryView'; import { BackToTop } from './components/BackToTop'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -193,6 +194,7 @@ function App() { {currentViewContent} + ); } diff --git a/src/components/DebugModeIndicator.tsx b/src/components/DebugModeIndicator.tsx new file mode 100644 index 00000000..e3df7de3 --- /dev/null +++ b/src/components/DebugModeIndicator.tsx @@ -0,0 +1,65 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { logger } from '../services/logger'; +import { backend } from '../services/backendAdapter'; + +/** + * Global debug mode indicator — fixed bottom-right corner. + * Reads debug state from sessionStorage; visible across all pages. + * Click to quickly disable all debug modes. + */ +export const DebugModeIndicator: React.FC = () => { + const [frontendDebug, setFrontendDebug] = useState(() => sessionStorage.getItem('gsm:frontend-debug') === 'true'); + const [backendDebug, setBackendDebug] = useState(() => sessionStorage.getItem('gsm:backend-debug') === 'true'); + + // Sync with sessionStorage changes (e.g. from DiagnosticLogsPanel) + useEffect(() => { + const check = () => { + setFrontendDebug(sessionStorage.getItem('gsm:frontend-debug') === 'true'); + setBackendDebug(sessionStorage.getItem('gsm:backend-debug') === 'true'); + }; + // Also listen for storage events from other tabs + window.addEventListener('storage', check); + // Poll for changes (sessionStorage doesn't fire storage events in same tab) + const interval = setInterval(check, 2000); + return () => { + window.removeEventListener('storage', check); + clearInterval(interval); + }; + }, []); + + const disableAll = useCallback(async () => { + // Disable frontend debug + logger.setLevel('info'); + sessionStorage.setItem('gsm:frontend-debug', 'false'); + setFrontendDebug(false); + + // Disable backend debug + if (backend.isAvailable) { + try { + const secret = sessionStorage.getItem('github-stars-manager-backend-secret'); + await fetch('/api/logs/debug', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` }, + body: JSON.stringify({ enabled: false }), + }); + } catch { /* Backend unreachable */ } + } + sessionStorage.setItem('gsm:backend-debug', 'false'); + setBackendDebug(false); + }, []); + + if (!frontendDebug && !backendDebug) return null; + + return ( + + ); +}; diff --git a/src/components/settings/DiagnosticLogsPanel.tsx b/src/components/settings/DiagnosticLogsPanel.tsx index 65ac22ea..8f1a8b23 100644 --- a/src/components/settings/DiagnosticLogsPanel.tsx +++ b/src/components/settings/DiagnosticLogsPanel.tsx @@ -156,13 +156,13 @@ const LogDetailModal: React.FC = ({ entry, language, t, onC ); case 'requestHeader': - return ; + return ; case 'requestBody': - return ; + return ; case 'responseHeader': - return ; + return ; case 'responseBody': - return ; + return ; default: return null; } @@ -218,7 +218,7 @@ const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, ch ); -const DataBlock: React.FC<{ data: unknown; t: (zh: string, en: string) => string; emptyText: string }> = ({ data, t, emptyText }) => { +const DataBlock: React.FC<{ data: unknown; emptyText: string }> = ({ data, emptyText }) => { if (data == null) { return

{emptyText}

; } @@ -230,30 +230,6 @@ const DataBlock: React.FC<{ data: unknown; t: (zh: string, en: string) => string ); }; -// ─── Global Debug Indicator ──────────────────────────────────── -interface DebugModeIndicatorProps { - frontendDebug: boolean; - backendDebug: boolean; - onToggleFrontend: () => void; - t: (zh: string, en: string) => string; -} - -const DebugModeIndicator: React.FC = ({ frontendDebug, backendDebug, onToggleFrontend, t }) => { - if (!frontendDebug && !backendDebug) return null; - return ( - - ); -}; - // ─── Main Panel ──────────────────────────────────────────────── export const DiagnosticLogsPanel: React.FC = ({ t }) => { const language = useAppStore.getState().language; @@ -371,8 +347,8 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = // Merge entries — sorted by timestamp DESCENDING (newest first) const allEntries = useMemo(() => { - if (selectedScope === 'frontend') return entries; - if (selectedScope === 'backend') return backendEntries; + if (selectedScope === 'frontend') return [...entries].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + if (selectedScope === 'backend') return [...backendEntries].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); return [...entries, ...backendEntries].sort((a, b) => b.timestamp.localeCompare(a.timestamp)); }, [entries, backendEntries, selectedScope]); @@ -434,12 +410,6 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = } catch { /* Backend unreachable */ } }, [backendDebug]); - // Quick disable debug from global indicator - const disableAllDebug = useCallback(() => { - if (frontendDebug) toggleFrontendDebug(); - if (backendDebug) toggleBackendDebug(); - }, [frontendDebug, backendDebug, toggleFrontendDebug, toggleBackendDebug]); - // Clear logs const handleClear = useCallback(async () => { if (selectedScope === 'frontend' || selectedScope === 'all') { logger.clear(); setEntries([]); } @@ -528,9 +498,6 @@ export const DiagnosticLogsPanel: React.FC = ({ t }) = return ( <> - {/* Global debug indicator (floats on all pages) */} - - {/* Detail modal */} {detailEntry && setDetailEntry(null)} />} From 4d947adc22f7474a0c3def0f8c7001d7c1b53d59 Mon Sep 17 00:00:00 2001 From: AmintaCCCP Date: Sat, 30 May 2026 00:16:53 +0800 Subject: [PATCH 6/6] feat: add fork/workflow event types, capture HTTP details in debug, indicator navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 'fork' (刷新复刻) and 'workflow' (执行 Workflow) event types - Add logging to ForkTimeline for refresh, sync, and workflow trigger - Capture request/response headers and bodies in debug logs (backendAdapter.ts and githubApi.ts) - Debug indicator click: disable all debug + navigate to settings/logs tab - Restore persisted frontend debug level at app startup (CodeRabbit fix) --- src/App.tsx | 9 +++++ src/components/DebugModeIndicator.tsx | 17 +++++--- src/components/ForkTimeline.tsx | 12 +++++- src/components/SettingsPanel.tsx | 10 +++++ src/services/backendAdapter.ts | 56 ++++++++++++++++++++++++++- src/services/githubApi.ts | 25 +++++++++++- src/utils/logEventTypes.ts | 6 +++ 7 files changed, 126 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8f7d8424..0308cf4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { BackToTop } from './components/BackToTop'; import { ErrorBoundary } from './components/ErrorBoundary'; import { useAppStore } from './store/useAppStore'; import { useAutoUpdateCheck } from './components/UpdateChecker'; +import { logger } from './services/logger'; import { UpdateNotificationBanner } from './components/UpdateNotificationBanner'; import { backend } from './services/backendAdapter'; import { syncFromBackend, startAutoSync, stopAutoSync } from './services/autoSync'; @@ -102,6 +103,14 @@ function App() { useAutoUpdateCheck(); + // Restore persisted frontend debug level at startup so capture is active + // app-wide, not only after DiagnosticLogsPanel mounts. + useEffect(() => { + if (sessionStorage.getItem('gsm:frontend-debug') === 'true') { + logger.setLevel('debug'); + } + }, []); + useEffect(() => { if (theme === 'dark') { document.documentElement.classList.add('dark'); diff --git a/src/components/DebugModeIndicator.tsx b/src/components/DebugModeIndicator.tsx index e3df7de3..f0a78b40 100644 --- a/src/components/DebugModeIndicator.tsx +++ b/src/components/DebugModeIndicator.tsx @@ -1,15 +1,17 @@ import React, { useState, useEffect, useCallback } from 'react'; import { logger } from '../services/logger'; import { backend } from '../services/backendAdapter'; +import { useAppStore } from '../store/useAppStore'; /** * Global debug mode indicator — fixed bottom-right corner. * Reads debug state from sessionStorage; visible across all pages. - * Click to quickly disable all debug modes. + * Click to disable all debug modes and navigate to diagnostic logs. */ export const DebugModeIndicator: React.FC = () => { const [frontendDebug, setFrontendDebug] = useState(() => sessionStorage.getItem('gsm:frontend-debug') === 'true'); const [backendDebug, setBackendDebug] = useState(() => sessionStorage.getItem('gsm:backend-debug') === 'true'); + const setCurrentView = useAppStore(s => s.setCurrentView); // Sync with sessionStorage changes (e.g. from DiagnosticLogsPanel) useEffect(() => { @@ -27,7 +29,7 @@ export const DebugModeIndicator: React.FC = () => { }; }, []); - const disableAll = useCallback(async () => { + const handleClick = useCallback(async () => { // Disable frontend debug logger.setLevel('info'); sessionStorage.setItem('gsm:frontend-debug', 'false'); @@ -46,15 +48,20 @@ export const DebugModeIndicator: React.FC = () => { } sessionStorage.setItem('gsm:backend-debug', 'false'); setBackendDebug(false); - }, []); + + // Navigate to settings → logs tab + setCurrentView('settings'); + // Notify SettingsPanel to switch to logs tab + window.dispatchEvent(new CustomEvent('gsm:navigate-to-settings-tab', { detail: { tab: 'logs' } })); + }, [setCurrentView]); if (!frontendDebug && !backendDebug) return null; return (