mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: add log management features including log viewing, filtering, and streaming (#45)
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
179
frontend/src/components/LogViewer.tsx
Normal file
179
frontend/src/components/LogViewer.tsx
Normal 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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "添加新分组",
|
||||||
|
|||||||
28
frontend/src/pages/LogsPage.tsx
Normal file
28
frontend/src/pages/LogsPage.tsx
Normal 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;
|
||||||
152
frontend/src/services/logService.ts
Normal file
152
frontend/src/services/logService.ts
Normal 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 };
|
||||||
|
};
|
||||||
55
src/controllers/logController.ts
Normal file
55
src/controllers/logController.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
204
src/services/logService.ts
Normal 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;
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user