From e5aaae466f5b4efe49b9bcb689a6a38d69f95680 Mon Sep 17 00:00:00 2001 From: samanhappy Date: Fri, 2 May 2025 21:41:16 +0800 Subject: [PATCH] feat: add log management features including log viewing, filtering, and streaming (#45) --- frontend/src/App.tsx | 2 + frontend/src/components/LogViewer.tsx | 179 ++++++++++++++++++ frontend/src/components/ServerCard.tsx | 4 +- frontend/src/components/layout/Sidebar.tsx | 9 + frontend/src/components/ui/Badge.tsx | 58 ++++-- frontend/src/components/ui/Button.tsx | 51 ++++++ frontend/src/locales/en.json | 19 +- frontend/src/locales/zh.json | 22 ++- frontend/src/pages/LogsPage.tsx | 28 +++ frontend/src/services/logService.ts | 152 +++++++++++++++ src/controllers/logController.ts | 55 ++++++ src/middlewares/auth.ts | 6 +- src/routes/index.ts | 10 + src/server.ts | 4 - src/services/logService.ts | 204 +++++++++++++++++++++ src/services/mcpService.ts | 3 + src/utils/migration.ts | 52 ------ 17 files changed, 783 insertions(+), 75 deletions(-) create mode 100644 frontend/src/components/LogViewer.tsx create mode 100644 frontend/src/pages/LogsPage.tsx create mode 100644 frontend/src/services/logService.ts create mode 100644 src/controllers/logController.ts create mode 100644 src/services/logService.ts delete mode 100644 src/utils/migration.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9cc9940..22ea3e5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import ServersPage from './pages/ServersPage'; import GroupsPage from './pages/GroupsPage'; import SettingsPage from './pages/SettingsPage'; import MarketPage from './pages/MarketPage'; +import LogsPage from './pages/LogsPage'; function App() { return ( @@ -30,6 +31,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx new file mode 100644 index 0000000..f475303 --- /dev/null +++ b/frontend/src/components/LogViewer.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { LogEntry } from '../services/logService'; +import { Button } from './ui/Button'; +import { Badge } from './ui/Badge'; +import { useTranslation } from 'react-i18next'; + +interface LogViewerProps { + logs: LogEntry[]; + isLoading?: boolean; + error?: Error | null; + onClear?: () => void; +} + +const LogViewer: React.FC = ({ logs, isLoading = false, error = null, onClear }) => { + const { t } = useTranslation(); + const logContainerRef = useRef(null); + const [autoScroll, setAutoScroll] = useState(true); + const [filter, setFilter] = useState(''); + const [typeFilter, setTypeFilter] = useState>(['info', 'error', 'warn', 'debug']); + const [sourceFilter, setSourceFilter] = useState>(['main', 'child-process']); + + // Auto scroll to bottom when new logs come in if autoScroll is enabled + useEffect(() => { + if (autoScroll && logContainerRef.current) { + logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight; + } + }, [logs, autoScroll]); + + // Filter logs based on current filter settings + const filteredLogs = logs.filter(log => { + const matchesText = filter ? log.message.toLowerCase().includes(filter.toLowerCase()) : true; + const matchesType = typeFilter.includes(log.type); + const matchesSource = sourceFilter.includes(log.source as 'main' | 'child-process'); + return matchesText && matchesType && matchesSource; + }); + + // Format timestamp to readable format + const formatTimestamp = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }); + }; + + // Get badge color based on log type + const getLogTypeColor = (type: string) => { + switch (type) { + case 'error': return 'bg-red-500'; + case 'warn': return 'bg-yellow-500'; + case 'debug': return 'bg-purple-500'; + default: return 'bg-blue-500'; + } + }; + + return ( +
+
+
+ {t('logs.filters')}: + + {/* Text search filter */} + setFilter(e.target.value)} + /> + + {/* Log type filters */} +
+ {(['info', 'error', 'warn', 'debug'] as const).map(type => ( + { + if (typeFilter.includes(type)) { + setTypeFilter(prev => prev.filter(t => t !== type)); + } else { + setTypeFilter(prev => [...prev, type]); + } + }} + > + {type} + + ))} +
+ + {/* Log source filters */} +
+ {(['main', 'child-process'] as const).map(source => ( + { + if (sourceFilter.includes(source)) { + setSourceFilter(prev => prev.filter(s => s !== source)); + } else { + setSourceFilter(prev => [...prev, source]); + } + }} + > + {source === 'main' ? t('logs.mainProcess') : t('logs.childProcess')} + + ))} +
+
+ +
+ + +
+
+ +
+ {isLoading ? ( +
+ {t('logs.loading')} +
+ ) : error ? ( +
+ {error.message} +
+ ) : filteredLogs.length === 0 ? ( +
+ {filter || typeFilter.length < 4 || sourceFilter.length < 2 + ? t('logs.noMatch') + : t('logs.noLogs')} +
+ ) : ( + filteredLogs.map((log, index) => ( +
+ [{formatTimestamp(log.timestamp)}] + + {log.type} + + + {log.source === 'main' ? t('logs.main') : t('logs.child')} + {log.processId ? ` (${log.processId})` : ''} + + {log.message} +
+ )) + )} +
+
+ ); +}; + +export default LogViewer; \ No newline at end of file diff --git a/frontend/src/components/ServerCard.tsx b/frontend/src/components/ServerCard.tsx index 2016f98..24939e8 100644 --- a/frontend/src/components/ServerCard.tsx +++ b/frontend/src/components/ServerCard.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { Server } from '@/types' import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react' -import Badge from '@/components/ui/Badge' +import { StatusBadge } from '@/components/ui/Badge' import ToolCard from '@/components/ui/ToolCard' import DeleteDialog from '@/components/ui/DeleteDialog' import { useToast } from '@/contexts/ToastContext' @@ -111,7 +111,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) => >

{server.name}

- + {server.error && (
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 96111d8..51aaa7d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -55,6 +55,15 @@ const Sidebar: React.FC = ({ collapsed }) => { ), }, + { + path: '/logs', + label: t('nav.logs'), + icon: ( + + + + ), + }, { path: '/settings', label: t('nav.settings'), diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/Badge.tsx index af95582..f5c29f6 100644 --- a/frontend/src/components/ui/Badge.tsx +++ b/frontend/src/components/ui/Badge.tsx @@ -1,25 +1,61 @@ -import { useTranslation } from 'react-i18next' -import { ServerStatus } from '@/types' +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { ServerStatus } from '@/types'; +import { cn } from '../../utils/cn'; -interface BadgeProps { - status: ServerStatus +type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive'; + +type BadgeProps = { + children: React.ReactNode; + variant?: BadgeVariant; + className?: string; + onClick?: () => void; +}; + +const badgeVariants = { + default: 'bg-blue-500 text-white hover:bg-blue-600', + secondary: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600', + outline: 'bg-transparent border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800', + destructive: 'bg-red-500 text-white hover:bg-red-600', +}; + +export function Badge({ + children, + variant = 'default', + className, + onClick +}: BadgeProps) { + return ( + + {children} + + ); } -const Badge = ({ status }: BadgeProps) => { - const { t } = useTranslation() +// For backward compatibility with existing code +export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => { + const { t } = useTranslation(); const colors = { connecting: 'bg-yellow-100 text-yellow-800', connected: 'bg-green-100 text-green-800', disconnected: 'bg-red-100 text-red-800', - } + }; // Map status to translation keys const statusTranslations = { connected: 'status.online', disconnected: 'status.offline', connecting: 'status.connecting' - } + }; return ( { > {t(statusTranslations[status] || status)} - ) -} - -export default Badge \ No newline at end of file + ); +}; \ No newline at end of file diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index e69de29..a60d9f8 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { cn } from '../../utils/cn'; + +type ButtonVariant = 'default' | 'outline' | 'ghost' | 'link' | 'destructive'; +type ButtonSize = 'default' | 'sm' | 'lg' | 'icon'; + +interface ButtonProps extends React.ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + asChild?: boolean; + children: React.ReactNode; +} + +const variantStyles: Record = { + default: 'bg-blue-500 text-white hover:bg-blue-600 focus:ring-blue-500', + outline: 'border border-gray-300 dark:border-gray-700 bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300', + ghost: 'bg-transparent hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300', + link: 'bg-transparent underline-offset-4 hover:underline text-blue-500 hover:text-blue-600', + destructive: 'bg-red-500 text-white hover:bg-red-600 focus:ring-red-500', +}; + +const sizeStyles: Record = { + default: 'h-10 py-2 px-4', + sm: 'h-8 px-3 text-sm', + lg: 'h-12 px-6', + icon: 'h-10 w-10 p-0', +}; + +export function Button({ + variant = 'default', + size = 'default', + className, + disabled, + children, + ...props +}: ButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 99aeb0e..2566332 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -113,7 +113,8 @@ "groups": "Groups", "settings": "Settings", "changePassword": "Change Password", - "market": "Market" + "market": "Market", + "logs": "Logs" }, "pages": { "dashboard": { @@ -140,8 +141,24 @@ }, "market": { "title": "Server Market - (Data from mcpm.sh)" + }, + "logs": { + "title": "System Logs" } }, + "logs": { + "filters": "Filters", + "search": "Search logs...", + "autoScroll": "Auto-scroll", + "clearLogs": "Clear logs", + "loading": "Loading logs...", + "noLogs": "No logs available.", + "noMatch": "No logs match the current filters.", + "mainProcess": "Main Process", + "childProcess": "Child Process", + "main": "Main", + "child": "Child" + }, "groups": { "add": "Add", "addNew": "Add New Group", diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json index 62fee52..a3e0f47 100644 --- a/frontend/src/locales/zh.json +++ b/frontend/src/locales/zh.json @@ -93,7 +93,8 @@ "initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...", "serverInstall": "安装服务器失败", "failedToFetchSettings": "获取设置失败", - "failedToUpdateSystemConfig": "更新系统配置失败" + "failedToUpdateSystemConfig": "更新系统配置失败", + "failedToUpdateRouteConfig": "更新路由配置失败" }, "common": { "processing": "处理中...", @@ -113,7 +114,8 @@ "settings": "设置", "changePassword": "修改密码", "groups": "分组", - "market": "市场" + "market": "市场", + "logs": "日志" }, "pages": { "dashboard": { @@ -140,8 +142,24 @@ }, "market": { "title": "服务器市场 - (数据来源于 mcpm.sh)" + }, + "logs": { + "title": "系统日志" } }, + "logs": { + "filters": "筛选", + "search": "搜索日志...", + "autoScroll": "自动滚动", + "clearLogs": "清除日志", + "loading": "加载日志中...", + "noLogs": "暂无日志。", + "noMatch": "没有匹配当前筛选条件的日志。", + "mainProcess": "主进程", + "childProcess": "子进程", + "main": "主", + "child": "子" + }, "groups": { "add": "添加", "addNew": "添加新分组", diff --git a/frontend/src/pages/LogsPage.tsx b/frontend/src/pages/LogsPage.tsx new file mode 100644 index 0000000..d52e7b5 --- /dev/null +++ b/frontend/src/pages/LogsPage.tsx @@ -0,0 +1,28 @@ +// filepath: /Users/sunmeng/code/github/mcphub/frontend/src/pages/LogsPage.tsx +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import LogViewer from '../components/LogViewer'; +import { useLogs } from '../services/logService'; + +const LogsPage: React.FC = () => { + const { t } = useTranslation(); + const { logs, loading, error, clearLogs } = useLogs(); + + return ( +
+
+

{t('pages.logs.title')}

+
+
+ +
+
+ ); +}; + +export default LogsPage; \ No newline at end of file diff --git a/frontend/src/services/logService.ts b/frontend/src/services/logService.ts new file mode 100644 index 0000000..bf60515 --- /dev/null +++ b/frontend/src/services/logService.ts @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { getToken } from './authService'; // Import getToken function + +export interface LogEntry { + timestamp: number; + type: 'info' | 'error' | 'warn' | 'debug'; + source: string; + message: string; + processId?: string; +} + +// Fetch all logs +export const fetchLogs = async (): Promise => { + try { + // Get authentication token + const token = getToken(); + if (!token) { + throw new Error('Authentication token not found. Please log in.'); + } + + const response = await fetch('/api/logs', { + headers: { + 'x-auth-token': token + } + }); + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Failed to fetch logs'); + } + + return result.data; + } catch (error) { + console.error('Error fetching logs:', error); + throw error; + } +}; + +// Clear all logs +export const clearLogs = async (): Promise => { + try { + // Get authentication token + const token = getToken(); + if (!token) { + throw new Error('Authentication token not found. Please log in.'); + } + + const response = await fetch('/api/logs', { + method: 'DELETE', + headers: { + 'x-auth-token': token + } + }); + + const result = await response.json(); + + if (!result.success) { + throw new Error(result.error || 'Failed to clear logs'); + } + } catch (error) { + console.error('Error clearing logs:', error); + throw error; + } +}; + +// Hook to use logs with SSE streaming +export const useLogs = () => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let eventSource: EventSource | null = null; + let isMounted = true; + + const connectToLogStream = () => { + try { + // Close existing connection if any + if (eventSource) { + eventSource.close(); + } + + // Get the authentication token + const token = getToken(); + if (!token) { + setError(new Error('Authentication token not found. Please log in.')); + setLoading(false); + return; + } + + // Connect to SSE endpoint with auth token in URL + eventSource = new EventSource(`/api/logs/stream?token=${token}`); + + eventSource.onmessage = (event) => { + if (!isMounted) return; + + try { + const data = JSON.parse(event.data); + + if (data.type === 'initial') { + setLogs(data.logs); + setLoading(false); + } else if (data.type === 'log') { + setLogs(prevLogs => [...prevLogs, data.log]); + } + } catch (err) { + console.error('Error parsing SSE message:', err); + } + }; + + eventSource.onerror = () => { + if (!isMounted) return; + + if (eventSource) { + eventSource.close(); + // Attempt to reconnect after a delay + setTimeout(connectToLogStream, 5000); + } + + setError(new Error('Connection to log stream lost, attempting to reconnect...')); + }; + } catch (err) { + if (!isMounted) return; + setError(err instanceof Error ? err : new Error('Failed to connect to log stream')); + setLoading(false); + } + }; + + // Initial connection + connectToLogStream(); + + // Cleanup on unmount + return () => { + isMounted = false; + if (eventSource) { + eventSource.close(); + } + }; + }, []); + + const clearAllLogs = async () => { + try { + await clearLogs(); + setLogs([]); + } catch (err) { + setError(err instanceof Error ? err : new Error('Failed to clear logs')); + } + }; + + return { logs, loading, error, clearLogs: clearAllLogs }; +}; \ No newline at end of file diff --git a/src/controllers/logController.ts b/src/controllers/logController.ts new file mode 100644 index 0000000..0b85761 --- /dev/null +++ b/src/controllers/logController.ts @@ -0,0 +1,55 @@ +// filepath: /Users/sunmeng/code/github/mcphub/src/controllers/logController.ts +import { Request, Response } from 'express'; +import logService from '../services/logService.js'; + +// Get all logs +export const getAllLogs = (req: Request, res: Response): void => { + try { + const logs = logService.getLogs(); + res.json({ success: true, data: logs }); + } catch (error) { + console.error('Error getting logs:', error); + res.status(500).json({ success: false, error: 'Error getting logs' }); + } +}; + +// Clear all logs +export const clearLogs = (req: Request, res: Response): void => { + try { + logService.clearLogs(); + res.json({ success: true, message: 'Logs cleared successfully' }); + } catch (error) { + console.error('Error clearing logs:', error); + res.status(500).json({ success: false, error: 'Error clearing logs' }); + } +}; + +// Stream logs via SSE +export const streamLogs = (req: Request, res: Response): void => { + try { + // Set headers for SSE + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + + // Send initial data + const logs = logService.getLogs(); + res.write(`data: ${JSON.stringify({ type: 'initial', logs })}\n\n`); + + // Subscribe to log events + const unsubscribe = logService.subscribe((log) => { + res.write(`data: ${JSON.stringify({ type: 'log', log })}\n\n`); + }); + + // Handle client disconnect + req.on('close', () => { + unsubscribe(); + console.log('Client disconnected from log stream'); + }); + } catch (error) { + console.error('Error streaming logs:', error); + res.status(500).json({ success: false, error: 'Error streaming logs' }); + } +}; \ No newline at end of file diff --git a/src/middlewares/auth.ts b/src/middlewares/auth.ts index d7b9a15..a9b5416 100644 --- a/src/middlewares/auth.ts +++ b/src/middlewares/auth.ts @@ -6,8 +6,10 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this'; // Middleware to authenticate JWT token export const auth = (req: Request, res: Response, next: NextFunction): void => { - // Get token from header - const token = req.header('x-auth-token'); + // Get token from header or query parameter + const headerToken = req.header('x-auth-token'); + const queryToken = req.query.token as string; + const token = headerToken || queryToken; // Check if no token if (!token) { diff --git a/src/routes/index.ts b/src/routes/index.ts index b7b06e4..94e3a90 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -35,6 +35,11 @@ import { getCurrentUser, changePassword } from '../controllers/authController.js'; +import { + getAllLogs, + clearLogs, + streamLogs +} from '../controllers/logController.js'; import { auth } from '../middlewares/auth.js'; const router = express.Router(); @@ -70,6 +75,11 @@ export const initRoutes = (app: express.Application): void => { router.get('/market/tags', getAllMarketTags); router.get('/market/tags/:tag', getMarketServersByTag); + // Log routes + router.get('/logs', getAllLogs); + router.delete('/logs', clearLogs); + router.get('/logs/stream', streamLogs); + // Auth routes (these will NOT be protected by auth middleware) app.post('/auth/login', [ check('username', 'Username is required').not().isEmpty(), diff --git a/src/server.ts b/src/server.ts index b7e3008..ac6c337 100644 --- a/src/server.ts +++ b/src/server.ts @@ -12,7 +12,6 @@ import { handleMcpPostRequest, handleMcpOtherRequest, } from './services/sseService.js'; -import { migrateUserData } from './utils/migration.js'; import { initializeDefaultUser } from './models/User.js'; // Get the directory name in ESM @@ -31,9 +30,6 @@ export class AppServer { async initialize(): Promise { try { - // Migrate user data from users.json to mcp_settings.json if needed - migrateUserData(); - // Initialize default admin user if no users exist await initializeDefaultUser(); diff --git a/src/services/logService.ts b/src/services/logService.ts new file mode 100644 index 0000000..3aef229 --- /dev/null +++ b/src/services/logService.ts @@ -0,0 +1,204 @@ +// filepath: /Users/sunmeng/code/github/mcphub/src/services/logService.ts +import { spawn, ChildProcess } from 'child_process'; +import { EventEmitter } from 'events'; +import * as os from 'os'; +import * as process from 'process'; + +interface LogEntry { + timestamp: number; + type: 'info' | 'error' | 'warn' | 'debug'; + source: string; + message: string; + processId?: string; +} + +// ANSI color codes for console output +const colors = { + reset: '\x1b[0m', + bright: '\x1b[1m', + dim: '\x1b[2m', + underscore: '\x1b[4m', + blink: '\x1b[5m', + reverse: '\x1b[7m', + hidden: '\x1b[8m', + + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + + bgBlack: '\x1b[40m', + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', + bgMagenta: '\x1b[45m', + bgCyan: '\x1b[46m', + bgWhite: '\x1b[47m' +}; + +// Level colors for different log types +const levelColors = { + info: colors.green, + error: colors.red, + warn: colors.yellow, + debug: colors.cyan +}; + +// Maximum number of logs to keep in memory +const MAX_LOGS = 1000; + +class LogService { + private logs: LogEntry[] = []; + private logEmitter = new EventEmitter(); + private childProcesses: { [id: string]: ChildProcess } = {}; + private mainProcessId: string; + private hostname: string; + + constructor() { + this.mainProcessId = process.pid.toString(); + this.hostname = os.hostname(); + this.overrideConsole(); + } + + // Format a timestamp for display + private formatTimestamp(timestamp: number): string { + const date = new Date(timestamp); + return date.toISOString(); + } + + // Format a log message for console output + private formatLogMessage(type: 'info' | 'error' | 'warn' | 'debug', source: string, message: string, processId?: string): string { + const timestamp = this.formatTimestamp(Date.now()); + const pid = processId || this.mainProcessId; + const level = type.toUpperCase(); + const levelColor = levelColors[type]; + + return `${colors.dim}[${timestamp}]${colors.reset} ${levelColor}${colors.bright}[${level}]${colors.reset} ${colors.blue}[${pid}]${colors.reset} ${colors.magenta}[${source}]${colors.reset} ${message}`; + } + + // Override console methods to capture logs + private overrideConsole() { + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + const originalConsoleDebug = console.debug; + + console.log = (...args: any[]) => { + const message = args.map(arg => this.formatArgument(arg)).join(' '); + this.addLog('info', 'main', message); + originalConsoleLog.apply(console, [this.formatLogMessage('info', 'main', message)]); + }; + + console.error = (...args: any[]) => { + const message = args.map(arg => this.formatArgument(arg)).join(' '); + this.addLog('error', 'main', message); + originalConsoleError.apply(console, [this.formatLogMessage('error', 'main', message)]); + }; + + console.warn = (...args: any[]) => { + const message = args.map(arg => this.formatArgument(arg)).join(' '); + this.addLog('warn', 'main', message); + originalConsoleWarn.apply(console, [this.formatLogMessage('warn', 'main', message)]); + }; + + console.debug = (...args: any[]) => { + const message = args.map(arg => this.formatArgument(arg)).join(' '); + this.addLog('debug', 'main', message); + originalConsoleDebug.apply(console, [this.formatLogMessage('debug', 'main', message)]); + }; + } + + // Format an argument for logging + private formatArgument(arg: any): string { + if (arg === null) return 'null'; + if (arg === undefined) return 'undefined'; + if (typeof arg === 'object') { + try { + return JSON.stringify(arg, null, 2); + } catch (e) { + return String(arg); + } + } + return String(arg); + } + + // Add a log entry to the logs array + private addLog(type: 'info' | 'error' | 'warn' | 'debug', source: string, message: string, processId?: string) { + const log: LogEntry = { + timestamp: Date.now(), + type, + source, + message, + processId: processId || this.mainProcessId + }; + + this.logs.push(log); + + // Limit the number of logs kept in memory + if (this.logs.length > MAX_LOGS) { + this.logs.shift(); + } + + // Emit the log event for SSE subscribers + this.logEmitter.emit('log', log); + } + + // Capture output from a child process + public captureChildProcess(command: string, args: string[], processId: string): ChildProcess { + const childProcess = spawn(command, args); + this.childProcesses[processId] = childProcess; + + childProcess.stdout.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + this.addLog('info', 'child-process', output, processId); + console.log(this.formatLogMessage('info', 'child-process', output, processId)); + } + }); + + childProcess.stderr.on('data', (data) => { + const output = data.toString().trim(); + if (output) { + this.addLog('error', 'child-process', output, processId); + console.error(this.formatLogMessage('error', 'child-process', output, processId)); + } + }); + + childProcess.on('close', (code) => { + const message = `Process exited with code ${code}`; + this.addLog('info', 'child-process', message, processId); + console.log(this.formatLogMessage('info', 'child-process', message, processId)); + delete this.childProcesses[processId]; + }); + + return childProcess; + } + + // Get all logs + public getLogs(): LogEntry[] { + return this.logs; + } + + // Subscribe to log events + public subscribe(callback: (log: LogEntry) => void): () => void { + this.logEmitter.on('log', callback); + return () => { + this.logEmitter.off('log', callback); + }; + } + + // Clear all logs + public clearLogs(): void { + this.logs = []; + this.logEmitter.emit('clear'); + } +} + +// Export a singleton instance +const logService = new LogService(); +export default logService; \ No newline at end of file diff --git a/src/services/mcpService.ts b/src/services/mcpService.ts index 78b0e32..078c4c8 100644 --- a/src/services/mcpService.ts +++ b/src/services/mcpService.ts @@ -84,6 +84,9 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] => args: conf.args, env: env, }); + transport.stderr?.on('data', (data) => { + console.error(`Error from server ${name}: ${data}`); + }); } else { console.warn(`Skipping server '${name}': missing required configuration`); serverInfos.push({ diff --git a/src/utils/migration.ts b/src/utils/migration.ts deleted file mode 100644 index 493a64a..0000000 --- a/src/utils/migration.ts +++ /dev/null @@ -1,52 +0,0 @@ -// filepath: /Users/sunmeng/code/github/mcphub/src/utils/migration.ts -import fs from 'fs'; -import path from 'path'; -import { loadSettings, saveSettings } from '../config/index.js'; -import { IUser } from '../types/index.js'; - -/** - * Migrates user data from the old users.json file to mcp_settings.json - * This is a one-time migration to support the refactoring from separate - * users.json to integrated user data in mcp_settings.json - */ -export const migrateUserData = (): void => { - const oldUsersFilePath = path.join(process.cwd(), 'data', 'users.json'); - - // Check if the old users file exists - if (fs.existsSync(oldUsersFilePath)) { - try { - // Read users from the old file - const usersData = fs.readFileSync(oldUsersFilePath, 'utf8'); - const users = JSON.parse(usersData) as IUser[]; - - if (users && Array.isArray(users) && users.length > 0) { - console.log(`Migrating ${users.length} users from users.json to mcp_settings.json`); - - // Load current settings - const settings = loadSettings(); - - // Merge users, giving priority to existing settings users - const existingUsernames = new Set((settings.users || []).map(u => u.username)); - const newUsers = users.filter(u => !existingUsernames.has(u.username)); - - settings.users = [...(settings.users || []), ...newUsers]; - - // Save updated settings - if (saveSettings(settings)) { - console.log('User data migration completed successfully'); - - // Rename the old file as backup - const backupPath = `${oldUsersFilePath}.bak.${Date.now()}`; - fs.renameSync(oldUsersFilePath, backupPath); - console.log(`Renamed old users file to ${backupPath}`); - } - } else { - console.log('No users found in users.json, skipping migration'); - } - } catch (error) { - console.error('Error during user data migration:', error); - } - } else { - console.log('users.json not found, no migration needed'); - } -}; \ No newline at end of file