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 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>
|
||||
|
||||
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 { 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">
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
"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",
|
||||
|
||||
@@ -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": "添加新分组",
|
||||
|
||||
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 };
|
||||
};
|
||||
Reference in New Issue
Block a user