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/App.tsx b/src/App.tsx index fb867e51..0308cf4e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,11 +7,13 @@ 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'; 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'; @@ -101,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'); @@ -193,6 +203,7 @@ function App() { {currentViewContent} + ); } diff --git a/src/components/DebugModeIndicator.tsx b/src/components/DebugModeIndicator.tsx new file mode 100644 index 00000000..f0a78b40 --- /dev/null +++ b/src/components/DebugModeIndicator.tsx @@ -0,0 +1,72 @@ +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 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(() => { + 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 handleClick = 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); + + // 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 ( + + ); +}; diff --git a/src/components/ForkTimeline.tsx b/src/components/ForkTimeline.tsx index 622f6547..9cc8637b 100644 --- a/src/components/ForkTimeline.tsx +++ b/src/components/ForkTimeline.tsx @@ -3,6 +3,7 @@ import { Package, Search, X, RefreshCw, ChevronLeft, ChevronRight, ChevronsLeft, import { ForkRepo, WorkflowDefinition } from '../types'; import { useAppStore } from '../store/useAppStore'; import { GitHubApiService } from '../services/githubApi'; +import { logger } from '../services/logger'; import { formatDistanceToNow } from 'date-fns'; import ForkCard from './ForkCard'; import { useDialog } from '../hooks/useDialog'; @@ -158,10 +159,12 @@ export const ForkTimeline: React.FC = () => { return; } + const startTime = Date.now(); setForkIsRefreshing(true); try { const githubApi = new GitHubApiService(githubToken); const newForks = await githubApi.getUserForks(); + logger.info('githubApi', 'Refresh forks completed', { forkCount: newForks.length, durationMs: Date.now() - startTime }); // Merge with existing forks, preserving read status const existingForkMap = new Map(forks.map(f => [f.id, f])); @@ -266,6 +269,7 @@ export const ForkTimeline: React.FC = () => { } } catch (error) { console.error('Fork refresh failed:', error); + logger.error('githubApi', 'Refresh forks failed', { error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - startTime }); toast(language === 'zh' ? 'Fork刷新失败,请检查网络连接。' : 'Fork refresh failed. Please check your network connection.', @@ -352,13 +356,15 @@ export const ForkTimeline: React.FC = () => { if (!fork) return; const { owner, repo, branch } = syncModal; + const syncStartTime = Date.now(); setSyncModal(prev => ({ ...prev, isOpen: false })); setSyncingForks(prev => new Set(prev).add(fork.id)); - + try { const githubApi = new GitHubApiService(githubToken); const result = await githubApi.syncFork(owner, repo, branch); + logger.info('githubApi', 'Sync fork completed', { repo: fork.full_name, mergeType: result.mergeType, durationMs: Date.now() - syncStartTime }); // Mark fork as up-to-date in UI setNeedsSyncMap(prev => ({ ...prev, [fork.id]: false })); @@ -378,6 +384,7 @@ export const ForkTimeline: React.FC = () => { } } catch (error) { console.error('Sync failed:', error); + logger.error('githubApi', 'Sync fork failed', { repo: fork.full_name, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - syncStartTime }); const errorMsg = error instanceof Error ? error.message : String(error); if (errorMsg === 'NOT_A_FORK') { toast(language === 'zh' @@ -417,11 +424,13 @@ export const ForkTimeline: React.FC = () => { if (!fork) return; const branch = fork.default_branch || 'main'; + const workflowStartTime = Date.now(); setRunningWorkflows(prev => new Set(prev).add(forkId)); try { const [owner, repo] = fork.full_name.split('/'); const githubApi = new GitHubApiService(githubToken); await githubApi.triggerWorkflowRun(owner, repo, workflowPath, branch); + logger.info('githubApi', 'Trigger workflow completed', { repo: fork.full_name, workflow: workflowName, branch, durationMs: Date.now() - workflowStartTime }); toast(language === 'zh' ? `已触发工作流 "${workflowName}" 在 ${branch} 分支。` @@ -433,6 +442,7 @@ export const ForkTimeline: React.FC = () => { await loadWorkflows(forkId); } catch (error) { console.error('Failed to run workflow:', error); + logger.error('githubApi', 'Trigger workflow failed', { repo: fork.full_name, workflow: workflowName, error: error instanceof Error ? error.message : String(error), durationMs: Date.now() - workflowStartTime }); toast(language === 'zh' ? `运行工作流失败。` : `Failed to run workflow.`, diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx index 3747c446..d9bc34b8 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; @@ -250,6 +252,16 @@ export const SettingsPanel: React.FC = ({ }; }, []); + // Listen for external tab navigation requests (e.g. from DebugModeIndicator) + useEffect(() => { + const onNavigate = (e: Event) => { + const tab = (e as CustomEvent<{ tab: SettingsTab }>).detail?.tab; + if (tab) handleTabChange(tab); + }; + window.addEventListener('gsm:navigate-to-settings-tab', onNavigate); + return () => window.removeEventListener('gsm:navigate-to-settings-tab', onNavigate); + }, [handleTabChange]); + const tabs: SettingsTabItem[] = [ { id: 'general', @@ -286,6 +298,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 +327,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..8f1a8b23 --- /dev/null +++ b/src/components/settings/DiagnosticLogsPanel.tsx @@ -0,0 +1,687 @@ +import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; +import { + ScrollText, + Search, + Download, + Trash2, + Loader2, + ShieldCheck, + RefreshCw, + ToggleLeft, + ToggleRight, + AlertTriangle, + ChevronDown, + ChevronRight, + X, +} 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', +}; + +type ModalTab = 'general' | 'timing' | 'requestHeader' | 'requestBody' | 'responseHeader' | 'responseBody'; + +const MODAL_TABS: { id: ModalTab; zh: string; en: string }[] = [ + { id: 'general', zh: '概览', en: 'General' }, + { id: 'timing', zh: '耗时', en: 'Timing & Notes' }, + { id: 'requestHeader', zh: '请求头', en: 'Request Header' }, + { id: 'requestBody', zh: '请求体', en: 'Request Body' }, + { id: 'responseHeader', zh: '返回头', en: 'Response Header' }, + { id: 'responseBody', zh: '返回体', en: 'Response Body' }, +]; + +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 getStatusColor(status: unknown): string { + if (!status) return ''; + const s = String(status); + return STATUS_COLORS[s.charAt(0)] || ''; +} + +const PAGE_SIZE = 100; + +// ─── Detail Modal ────────────────────────────────────────────── +interface LogDetailModalProps { + entry: LogEntry; + language: string; + t: (zh: string, en: string) => string; + onClose: () => void; +} + +const LogDetailModal: React.FC = ({ entry, language, t, onClose }) => { + const [activeTab, setActiveTab] = useState('general'); + const eventType = inferEventType(entry.module, entry.message); + const entryData = entry.data as Record | undefined; + + // Escape to close + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }, [onClose]); + + const renderTabContent = () => { + switch (activeTab) { + case 'general': + return ( +
+ + {entry.level} + + + + {entry.source === 'frontend' ? t('前端', 'Frontend') : t('后端', 'Backend')} + + + + {language === 'zh' ? EVENT_TYPE_LABELS[eventType].zh : EVENT_TYPE_LABELS[eventType].en} + + + {entry.module} + + + {entry.message} + + + {entry.timestamp} + + {entryData?.status && ( + + {String(entryData.status)} + + )} +
+ ); + case 'timing': + return ( +
+ {entryData?.durationMs != null && ( + + {String(entryData.durationMs)}ms + + )} + {entryData?.method && ( + + {String(entryData.method)} + + )} + {(entryData?.endpoint || entryData?.path) && ( + + {String(entryData.endpoint ?? entryData.path)} + + )} + {entryData?.apiType && ( + + {String(entryData.apiType)} + + )} + {entryData?.model && ( + + {String(entryData.model)} + + )} + {entryData?.responseLength != null && ( + + {String(entryData.responseLength)} chars + + )} + {!entryData?.durationMs && !entryData?.method && ( +

{t('无耗时信息(需开启调试模式)', 'No timing info (enable debug mode)')}

+ )} +
+ ); + case 'requestHeader': + return ; + case 'requestBody': + return ; + case 'responseHeader': + return ; + case 'responseBody': + return ; + default: + return null; + } + }; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+ {entry.level} + {entry.message} +
+ +
+ + {/* 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; emptyText: string }> = ({ data, emptyText }) => { + if (data == null) { + return

{emptyText}

; + } + const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2); + return ( +
+      {text}
+    
+ ); +}; + +// ─── Main Panel ──────────────────────────────────────────────── +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(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 */ } + }; + fetchDebugState(); + }, [backendAvailable]); + + // 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 [detailEntry, setDetailEntry] = useState(null); + + // Pagination + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + // 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); + const eventTypeRef = useRef(null); + + // Close event type dropdown on outside click or Escape + useEffect(() => { + if (!showEventTypeDropdown) return; + const onDocClick = (e: MouseEvent) => { + if (eventTypeRef.current && !eventTypeRef.current.contains(e.target as Node)) { + setShowEventTypeDropdown(false); + } + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') setShowEventTypeDropdown(false); + }; + document.addEventListener('mousedown', onDocClick); + document.addEventListener('keydown', onKey); + return () => { + document.removeEventListener('mousedown', onDocClick); + document.removeEventListener('keydown', onKey); + }; + }, [showEventTypeDropdown]); + + // 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 — sorted by timestamp DESCENDING (newest first) + const allEntries = useMemo(() => { + 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]); + + // 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]); + + // 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]); + + // 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]); + + // 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; + try { + 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}` }, + 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 */ } + }, [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 + 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 + 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'; + const 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]); + + 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; }); + }, []); + + 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 ( + <> + {/* 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('后端调试', 'Backend Debug')} + + {backendAvailable ? (backendDebug ? t('已开启', 'ON') : t('已关闭', 'OFF')) : t('后端未连接', 'Not connected')} + +
+

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

+
+ +
+
+ + {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 ***')} +
+ + {/* 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 — debug pill always clickable */} +
+ {t('级别', 'Level')}: + {(['debug', 'info', 'warn', 'error'] as LogLevel[]).map(level => ( + + ))} +
+ + {/* Scope + Event type + Actions */} +
+
+ {(['all', 'frontend', 'backend'] as const).map(scope => ( + + ))} +
+
+ + {showEventTypeDropdown && ( +
+ {availableEventTypes.map(et => ( + + ))} +
+ )} +
+
+ + + +
+
+ +
+ {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')} +
+ ) : ( + <> +
+ {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 && ( +
+ +
+ )} + + )} +
+
+ + ); +}; 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..52dc3e60 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 }); + } catch { + 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..af47d5d7 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); @@ -82,8 +85,69 @@ class BackendAdapter { } } + // Capture request details for debug logging + let requestHeaders: Record | undefined; + let requestBody: string | undefined; + if (logger.isDebugMode()) { + if (options?.headers) { + if (options.headers instanceof Headers) { + requestHeaders = {}; + options.headers.forEach((v, k) => { requestHeaders![k] = k.toLowerCase() === 'authorization' ? '***' : v; }); + } else if (Array.isArray(options.headers)) { + requestHeaders = {}; + for (const [k, v] of options.headers) { + requestHeaders[k] = k.toLowerCase() === 'authorization' ? '***' : v; + } + } else { + requestHeaders = {}; + for (const [k, v] of Object.entries(options.headers as Record)) { + requestHeaders[k] = k.toLowerCase() === 'authorization' ? '***' : v; + } + } + } + if (typeof options?.body === 'string') { + try { + const parsed = JSON.parse(options.body); + // Mask any apiKey/password fields recursively + requestBody = JSON.stringify(parsed, (key, val) => { + if (/api[_-]?key|password|secret|token|authorization/i.test(key)) return '***'; + return val; + }, 2); + } catch { + requestBody = options.body.slice(0, 2000); + } + } + } + try { - return await fetch(url, { ...options, signal: controller.signal }); + const response = await fetch(url, { ...options, signal: controller.signal }); + if (logger.isDebugMode()) { + // Capture response headers + const responseHeaders: Record = {}; + response.headers.forEach((v, k) => { responseHeaders[k] = v; }); + // Capture response body preview (clone to avoid consuming) + let responseBody: string | undefined; + try { + const cloned = response.clone(); + const text = await cloned.text(); + if (text.length > 0) { + responseBody = text.length > 4000 ? text.slice(0, 4000) + '...[truncated]' : text; + } + } catch { /* body not readable */ } + logger.debug('backendAdapter', 'Backend request', { + method, path, status: response.status, durationMs: Date.now() - startTime, + requestHeaders, requestBody, responseHeaders, responseBody, + }); + } + return response; + } catch (err) { + if (logger.isDebugMode()) { + logger.debug('backendAdapter', 'Backend request', { + method, path, error: 'timeout/network error', durationMs: Date.now() - startTime, + requestHeaders, requestBody, + }); + } + throw err; } finally { clearTimeout(timeoutId); } @@ -94,6 +158,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 +183,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..f6b9e46a 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,49 @@ 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()) { + // Capture request headers (mask auth) + const requestHeaders: Record = { + 'Authorization': 'Bearer ***', + 'Accept': 'application/vnd.github.v3+json', + 'X-GitHub-Api-Version': '2022-11-28', + ...(options.headers as Record || {}), + }; + // Capture response headers + const responseHeaders: Record = {}; + response.headers.forEach((v, k) => { responseHeaders[k] = v; }); + // Capture response body preview (clone to avoid consuming) + let responseBody: string | undefined; + try { + const cloned = response.clone(); + const text = await cloned.text(); + if (text.length > 0) { + responseBody = text.length > 4000 ? text.slice(0, 4000) + '...[truncated]' : text; + } + } catch { /* body not readable */ } + logger.debug('githubApi', 'API request', { + method, endpoint, status: response.status, durationMs: Date.now() - startTime, + rateLimitRemaining: response.headers.get('x-ratelimit-remaining'), + requestHeaders, responseHeaders, responseBody, + }); + } + const data = response.status === 204 ? null : await response.json(); // 如果是starred repositories的响应,需要处理特殊格式 @@ -275,6 +316,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 +402,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 +512,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 +603,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 +712,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 +829,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..94f1ace2 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..7967ec97 --- /dev/null +++ b/src/utils/logEventTypes.ts @@ -0,0 +1,52 @@ +export type LogEventType = + | 'sync' + | 'aiAnalysis' + | 'aiSearch' + | 'githubApi' + | 'trending' + | 'release' + | 'fork' + | 'workflow' + | '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' }, + fork: { zh: '刷新复刻', en: 'Refresh Forks' }, + workflow: { zh: '执行 Workflow', en: 'Run Workflow' }, + 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 === 'githubApi' && /fork/i.test(message)) return 'fork'; + if (module === 'githubApi' && /workflow/i.test(message)) return 'workflow'; + 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