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 SettingsPage from './pages/SettingsPage';
import MarketPage from './pages/MarketPage';
import LogsPage from './pages/LogsPage';
function App() {
return (
@@ -30,6 +31,7 @@ function App() {
<Route path="/groups" element={<GroupsPage />} />
<Route path="/market" element={<MarketPage />} />
<Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/settings" element={<SettingsPage />} />
</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 { 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) =>
>
<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>
<Badge status={server.status} />
<StatusBadge status={server.status} />
{server.error && (
<div className="relative">

View File

@@ -55,6 +55,15 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
</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',
label: t('nav.settings'),

View File

@@ -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 (
<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) => {
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 (
<span
@@ -27,7 +63,5 @@ const Badge = ({ status }: BadgeProps) => {
>
{t(statusTranslations[status] || status)}
</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",
"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",

View File

@@ -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": "添加新分组",

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