feat: add log management features including log viewing, filtering, and streaming (#45)

This commit is contained in:
samanhappy
2025-05-02 21:41:16 +08:00
committed by GitHub
parent 9b1338a356
commit e5aaae466f
17 changed files with 783 additions and 75 deletions

View File

@@ -11,6 +11,7 @@ import ServersPage from './pages/ServersPage';
import GroupsPage from './pages/GroupsPage'; import GroupsPage from './pages/GroupsPage';
import SettingsPage from './pages/SettingsPage'; import SettingsPage from './pages/SettingsPage';
import MarketPage from './pages/MarketPage'; import MarketPage from './pages/MarketPage';
import LogsPage from './pages/LogsPage';
function App() { function App() {
return ( return (
@@ -30,6 +31,7 @@ function App() {
<Route path="/groups" element={<GroupsPage />} /> <Route path="/groups" element={<GroupsPage />} />
<Route path="/market" element={<MarketPage />} /> <Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} /> <Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
</Route> </Route>
</Route> </Route>

View File

@@ -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<LogViewerProps> = ({ logs, isLoading = false, error = null, onClear }) => {
const { t } = useTranslation();
const logContainerRef = useRef<HTMLDivElement>(null);
const [autoScroll, setAutoScroll] = useState(true);
const [filter, setFilter] = useState<string>('');
const [typeFilter, setTypeFilter] = useState<Array<'info' | 'error' | 'warn' | 'debug'>>(['info', 'error', 'warn', 'debug']);
const [sourceFilter, setSourceFilter] = useState<Array<'main' | 'child-process'>>(['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 (
<div className="flex flex-col h-full">
<div className="bg-card p-3 rounded-t-md border-b flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-sm">{t('logs.filters')}:</span>
{/* Text search filter */}
<input
type="text"
placeholder={t('logs.search')}
className="px-2 py-1 text-sm border rounded"
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
{/* Log type filters */}
<div className="flex gap-1 items-center">
{(['info', 'error', 'warn', 'debug'] as const).map(type => (
<Badge
key={type}
variant={typeFilter.includes(type) ? 'default' : 'outline'}
className={`cursor-pointer ${typeFilter.includes(type) ? getLogTypeColor(type) : ''}`}
onClick={() => {
if (typeFilter.includes(type)) {
setTypeFilter(prev => prev.filter(t => t !== type));
} else {
setTypeFilter(prev => [...prev, type]);
}
}}
>
{type}
</Badge>
))}
</div>
{/* Log source filters */}
<div className="flex gap-1 items-center ml-2">
{(['main', 'child-process'] as const).map(source => (
<Badge
key={source}
variant={sourceFilter.includes(source) ? 'default' : 'outline'}
className="cursor-pointer"
onClick={() => {
if (sourceFilter.includes(source)) {
setSourceFilter(prev => prev.filter(s => s !== source));
} else {
setSourceFilter(prev => [...prev, source]);
}
}}
>
{source === 'main' ? t('logs.mainProcess') : t('logs.childProcess')}
</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={autoScroll}
onChange={() => setAutoScroll(!autoScroll)}
className="form-checkbox h-4 w-4"
/>
{t('logs.autoScroll')}
</label>
<Button
variant="outline"
size="sm"
onClick={onClear}
disabled={isLoading || logs.length === 0}
>
{t('logs.clearLogs')}
</Button>
</div>
</div>
<div
ref={logContainerRef}
className="flex-grow p-2 overflow-auto bg-card rounded-b-md font-mono text-sm"
style={{ maxHeight: 'calc(100vh - 300px)' }}
>
{isLoading ? (
<div className="flex justify-center items-center h-full">
<span>{t('logs.loading')}</span>
</div>
) : error ? (
<div className="text-red-500 p-2">
{error.message}
</div>
) : filteredLogs.length === 0 ? (
<div className="text-center text-muted-foreground p-8">
{filter || typeFilter.length < 4 || sourceFilter.length < 2
? t('logs.noMatch')
: t('logs.noLogs')}
</div>
) : (
filteredLogs.map((log, index) => (
<div
key={`${log.timestamp}-${index}`}
className={`py-1 border-b border-gray-100 dark:border-gray-800 ${log.type === 'error' ? 'text-red-500' :
log.type === 'warn' ? 'text-yellow-500' : ''
}`}
>
<span className="text-gray-400">[{formatTimestamp(log.timestamp)}]</span>
<Badge className={`ml-2 mr-1 ${getLogTypeColor(log.type)}`}>
{log.type}
</Badge>
<Badge variant="outline" className="mr-2">
{log.source === 'main' ? t('logs.main') : t('logs.child')}
{log.processId ? ` (${log.processId})` : ''}
</Badge>
<span className="whitespace-pre-wrap">{log.message}</span>
</div>
))
)}
</div>
</div>
);
};
export default LogViewer;

View File

@@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Server } from '@/types' import { Server } from '@/types'
import { ChevronDown, ChevronRight, AlertCircle, Copy, Check } from 'lucide-react' 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 ToolCard from '@/components/ui/ToolCard'
import DeleteDialog from '@/components/ui/DeleteDialog' import DeleteDialog from '@/components/ui/DeleteDialog'
import { useToast } from '@/contexts/ToastContext' import { useToast } from '@/contexts/ToastContext'
@@ -111,7 +111,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2> <h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
<Badge status={server.status} /> <StatusBadge status={server.status} />
{server.error && ( {server.error && (
<div className="relative"> <div className="relative">

View File

@@ -55,6 +55,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</svg> </svg>
), ),
}, },
{
path: '/logs',
label: t('nav.logs'),
icon: (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4zm2 6a1 1 0 011-1h6a1 1 0 110 2H7a1 1 0 01-1-1zm1 3a1 1 0 100 2h6a1 1 0 100-2H7z" clipRule="evenodd" />
</svg>
),
},
{ {
path: '/settings', path: '/settings',
label: t('nav.settings'), label: t('nav.settings'),

View File

@@ -1,25 +1,61 @@
import { useTranslation } from 'react-i18next' import React from 'react';
import { ServerStatus } from '@/types' import { useTranslation } from 'react-i18next';
import { ServerStatus } from '@/types';
import { cn } from '../../utils/cn';
interface BadgeProps { type BadgeVariant = 'default' | 'secondary' | 'outline' | 'destructive';
status: ServerStatus
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 (
<span
className={cn(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
badgeVariants[variant],
onClick ? 'cursor-pointer' : '',
className
)}
onClick={onClick}
>
{children}
</span>
);
} }
const Badge = ({ status }: BadgeProps) => { // For backward compatibility with existing code
const { t } = useTranslation() export const StatusBadge = ({ status }: { status: 'connected' | 'disconnected' | 'connecting' }) => {
const { t } = useTranslation();
const colors = { const colors = {
connecting: 'bg-yellow-100 text-yellow-800', connecting: 'bg-yellow-100 text-yellow-800',
connected: 'bg-green-100 text-green-800', connected: 'bg-green-100 text-green-800',
disconnected: 'bg-red-100 text-red-800', disconnected: 'bg-red-100 text-red-800',
} };
// Map status to translation keys // Map status to translation keys
const statusTranslations = { const statusTranslations = {
connected: 'status.online', connected: 'status.online',
disconnected: 'status.offline', disconnected: 'status.offline',
connecting: 'status.connecting' connecting: 'status.connecting'
} };
return ( return (
<span <span
@@ -27,7 +63,5 @@ const Badge = ({ status }: BadgeProps) => {
> >
{t(statusTranslations[status] || status)} {t(statusTranslations[status] || status)}
</span> </span>
) );
} };
export default Badge

View File

@@ -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<HTMLButtonElement> {
variant?: ButtonVariant;
size?: ButtonSize;
asChild?: boolean;
children: React.ReactNode;
}
const variantStyles: Record<ButtonVariant, string> = {
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<ButtonSize, string> = {
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 (
<button
className={cn(
'rounded-md inline-flex items-center justify-center font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none',
variantStyles[variant],
sizeStyles[size],
className
)}
disabled={disabled}
{...props}
>
{children}
</button>
);
}

View File

@@ -113,7 +113,8 @@
"groups": "Groups", "groups": "Groups",
"settings": "Settings", "settings": "Settings",
"changePassword": "Change Password", "changePassword": "Change Password",
"market": "Market" "market": "Market",
"logs": "Logs"
}, },
"pages": { "pages": {
"dashboard": { "dashboard": {
@@ -140,8 +141,24 @@
}, },
"market": { "market": {
"title": "Server Market - (Data from mcpm.sh)" "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": { "groups": {
"add": "Add", "add": "Add",
"addNew": "Add New Group", "addNew": "Add New Group",

View File

@@ -93,7 +93,8 @@
"initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...", "initialStartup": "服务器可能正在启动中。首次启动可能需要一些时间,请耐心等候...",
"serverInstall": "安装服务器失败", "serverInstall": "安装服务器失败",
"failedToFetchSettings": "获取设置失败", "failedToFetchSettings": "获取设置失败",
"failedToUpdateSystemConfig": "更新系统配置失败" "failedToUpdateSystemConfig": "更新系统配置失败",
"failedToUpdateRouteConfig": "更新路由配置失败"
}, },
"common": { "common": {
"processing": "处理中...", "processing": "处理中...",
@@ -113,7 +114,8 @@
"settings": "设置", "settings": "设置",
"changePassword": "修改密码", "changePassword": "修改密码",
"groups": "分组", "groups": "分组",
"market": "市场" "market": "市场",
"logs": "日志"
}, },
"pages": { "pages": {
"dashboard": { "dashboard": {
@@ -140,8 +142,24 @@
}, },
"market": { "market": {
"title": "服务器市场 - (数据来源于 mcpm.sh" "title": "服务器市场 - (数据来源于 mcpm.sh"
},
"logs": {
"title": "系统日志"
} }
}, },
"logs": {
"filters": "筛选",
"search": "搜索日志...",
"autoScroll": "自动滚动",
"clearLogs": "清除日志",
"loading": "加载日志中...",
"noLogs": "暂无日志。",
"noMatch": "没有匹配当前筛选条件的日志。",
"mainProcess": "主进程",
"childProcess": "子进程",
"main": "主",
"child": "子"
},
"groups": { "groups": {
"add": "添加", "add": "添加",
"addNew": "添加新分组", "addNew": "添加新分组",

View File

@@ -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 (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">{t('pages.logs.title')}</h1>
</div>
<div className="bg-card rounded-md shadow-sm">
<LogViewer
logs={logs}
isLoading={loading}
error={error}
onClear={clearLogs}
/>
</div>
</div>
);
};
export default LogsPage;

View File

@@ -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<LogEntry[]> => {
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<void> => {
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<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(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 };
};

View File

@@ -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' });
}
};

View File

@@ -6,8 +6,10 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-this';
// Middleware to authenticate JWT token // Middleware to authenticate JWT token
export const auth = (req: Request, res: Response, next: NextFunction): void => { export const auth = (req: Request, res: Response, next: NextFunction): void => {
// Get token from header // Get token from header or query parameter
const token = req.header('x-auth-token'); const headerToken = req.header('x-auth-token');
const queryToken = req.query.token as string;
const token = headerToken || queryToken;
// Check if no token // Check if no token
if (!token) { if (!token) {

View File

@@ -35,6 +35,11 @@ import {
getCurrentUser, getCurrentUser,
changePassword changePassword
} from '../controllers/authController.js'; } from '../controllers/authController.js';
import {
getAllLogs,
clearLogs,
streamLogs
} from '../controllers/logController.js';
import { auth } from '../middlewares/auth.js'; import { auth } from '../middlewares/auth.js';
const router = express.Router(); const router = express.Router();
@@ -70,6 +75,11 @@ export const initRoutes = (app: express.Application): void => {
router.get('/market/tags', getAllMarketTags); router.get('/market/tags', getAllMarketTags);
router.get('/market/tags/:tag', getMarketServersByTag); 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) // Auth routes (these will NOT be protected by auth middleware)
app.post('/auth/login', [ app.post('/auth/login', [
check('username', 'Username is required').not().isEmpty(), check('username', 'Username is required').not().isEmpty(),

View File

@@ -12,7 +12,6 @@ import {
handleMcpPostRequest, handleMcpPostRequest,
handleMcpOtherRequest, handleMcpOtherRequest,
} from './services/sseService.js'; } from './services/sseService.js';
import { migrateUserData } from './utils/migration.js';
import { initializeDefaultUser } from './models/User.js'; import { initializeDefaultUser } from './models/User.js';
// Get the directory name in ESM // Get the directory name in ESM
@@ -31,9 +30,6 @@ export class AppServer {
async initialize(): Promise<void> { async initialize(): Promise<void> {
try { try {
// Migrate user data from users.json to mcp_settings.json if needed
migrateUserData();
// Initialize default admin user if no users exist // Initialize default admin user if no users exist
await initializeDefaultUser(); await initializeDefaultUser();

204
src/services/logService.ts Normal file
View File

@@ -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;

View File

@@ -84,6 +84,9 @@ export const initializeClientsFromSettings = (isInit: boolean): ServerInfo[] =>
args: conf.args, args: conf.args,
env: env, env: env,
}); });
transport.stderr?.on('data', (data) => {
console.error(`Error from server ${name}: ${data}`);
});
} else { } else {
console.warn(`Skipping server '${name}': missing required configuration`); console.warn(`Skipping server '${name}': missing required configuration`);
serverInfos.push({ serverInfos.push({

View File

@@ -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');
}
};