feat: add MCP health check

This commit is contained in:
qixinbo
2026-03-29 23:30:32 +08:00
parent aea97b6342
commit 72d25826a1
4 changed files with 181 additions and 15 deletions
+14 -1
View File
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
import { useAuthStore } from "@/store/authStore";
import { useProjectStore } from "@/store/projectStore";
import { useDashboardStore } from "@/store/dashboardStore";
import { useMcpHealthStore } from "@/store/mcpHealthStore";
import { api } from "@/lib/api";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
@@ -406,12 +407,21 @@ function SidebarBody() {
const queryParams = new URLSearchParams(location.search);
const activeSessionKey = queryParams.get("session") || "api:default";
const { hasMcpError, startPolling, stopPolling } = useMcpHealthStore();
useEffect(() => {
if (currentProject) {
loadDashboards(currentProject.id);
}
}, [currentProject, loadDashboards]);
useEffect(() => {
startPolling(currentProject?.id ?? null);
return () => {
stopPolling();
};
}, [currentProject?.id, startPolling, stopPolling]);
const fetchSessions = async () => {
try {
const url = currentProject
@@ -898,11 +908,14 @@ function SidebarBody() {
</button>
<button
className="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors px-2 py-1.5 rounded-md hover:bg-muted"
className="flex items-center gap-1.5 text-sm hover:text-foreground transition-colors px-2 py-1.5 rounded-md hover:bg-muted relative"
onClick={() => navigate("/skills")}
>
<Wand2 className="h-4 w-4" />
{t('skillCenter')}
{hasMcpError && (
<span className="absolute top-1 right-1 w-2 h-2 rounded-full bg-red-500 ring-2 ring-background animate-pulse" title="MCP Server Error" />
)}
</button>
</div>
+53 -13
View File
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload, Plus } from "lucide-react";
import { Trash2, Terminal, Loader2, FolderOpen, Eye, ShieldCheck, AlertCircle, Wand2, Upload, Plus, RefreshCw } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore";
import { useMcpHealthStore } from "@/store/mcpHealthStore";
import { useRef } from 'react';
interface Skill {
@@ -69,8 +70,10 @@ export function Skills() {
const [mcpArgsStr, setMcpArgsStr] = useState('');
const [mcpEnvStr, setMcpEnvStr] = useState('');
const [mcpHeadersStr, setMcpHeadersStr] = useState('');
const [isRefreshingMcpHealth, setIsRefreshingMcpHealth] = useState(false);
const { currentProject } = useProjectStore();
const { hasMcpError, refresh: refreshMcpHealth } = useMcpHealthStore();
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@@ -101,13 +104,14 @@ export function Skills() {
};
if (currentProject) {
void refreshMcpHealth(currentProject.id);
if (activeTab === 'skills') {
fetchSkills();
void fetchSkills();
} else {
fetchMcpServers();
void fetchMcpServers();
}
}
}, [currentProject, activeTab]);
}, [currentProject?.id, activeTab, refreshMcpHealth]);
const fetchSkills = async () => {
if (!currentProject) return;
@@ -128,6 +132,7 @@ export function Skills() {
try {
const data = await api.get<MCPServer[]>(`/api/v1/mcp?project_id=${currentProject.id}`);
setMcpServers(data);
void refreshMcpHealth(currentProject.id);
} catch (error) {
console.error("Failed to fetch MCP servers", error);
} finally {
@@ -135,6 +140,19 @@ export function Skills() {
}
};
const handleRefreshMcpHealth = async () => {
if (!currentProject) return;
setIsRefreshingMcpHealth(true);
try {
await refreshMcpHealth(currentProject.id);
if (activeTab === 'mcp') {
await fetchMcpServers();
}
} finally {
setIsRefreshingMcpHealth(false);
}
};
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file || !currentProject) return;
@@ -300,10 +318,13 @@ export function Skills() {
{t('skills')}
</button>
<button
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${activeTab === 'mcp' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground/80'}`}
className={`relative px-3 py-1 text-sm font-medium rounded-md transition-colors ${activeTab === 'mcp' ? 'bg-background shadow-sm text-foreground' : 'text-muted-foreground hover:text-foreground/80'}`}
onClick={() => setActiveTab('mcp')}
>
{t('mcpConfig')}
{hasMcpError && (
<span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" title="MCP Server Error" />
)}
</button>
</div>
{activeTab === 'skills' ? (
@@ -323,12 +344,27 @@ export function Skills() {
</Button>
</>
) : (
<Button
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => setIsMcpDialogOpen(true)}
>
<Plus className="h-4 w-4" />{t('addMcpServer')}
</Button>
<>
<Button
variant="outline"
className="h-9 gap-2 rounded-md px-3"
onClick={handleRefreshMcpHealth}
disabled={isRefreshingMcpHealth}
>
{isRefreshingMcpHealth ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
{t('refresh')}
</Button>
<Button
className="h-9 bg-[#ff4d29] hover:bg-[#ff4d29]/90 text-white gap-2 rounded-md px-3"
onClick={() => setIsMcpDialogOpen(true)}
>
<Plus className="h-4 w-4" />{t('addMcpServer')}
</Button>
</>
)}
</div>
</div>
@@ -483,14 +519,18 @@ export function Skills() {
<div className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] md:text-xs font-medium whitespace-nowrap ${
mcp.status === 'connected'
? 'bg-green-50 text-green-700 border border-green-100'
: mcp.status.startsWith('error')
? 'bg-red-50 text-red-700 border border-red-100'
: 'bg-muted/50 text-foreground/80 border border-border'
}`}>
}`}
title={mcp.status}
>
{mcp.status === 'connected' ? (
<ShieldCheck className="h-3 w-3" />
) : (
<AlertCircle className="h-3 w-3" />
)}
{mcp.status}
<span className="truncate max-w-[150px]">{mcp.status}</span>
</div>
</TableCell>
<TableCell className="py-4 px-4 text-right">
+60
View File
@@ -0,0 +1,60 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
interface MCPServerStatus {
status?: string;
}
interface MCPHealthState {
hasMcpError: boolean;
currentProjectId: number | null;
startPolling: (projectId: number | null) => void;
stopPolling: () => void;
refresh: (projectId?: number | null) => Promise<void>;
}
let pollingTimer: ReturnType<typeof setInterval> | null = null;
export const useMcpHealthStore = create<MCPHealthState>((set, get) => ({
hasMcpError: false,
currentProjectId: null,
refresh: async (projectIdArg?: number | null) => {
const projectId = projectIdArg ?? get().currentProjectId;
if (!projectId) {
set({ hasMcpError: false, currentProjectId: null });
return;
}
try {
const data = await api.get<MCPServerStatus[]>(`/api/v1/mcp?project_id=${projectId}`);
const hasError = data.some((mcp) => Boolean(mcp.status && mcp.status.startsWith('error')));
set({ hasMcpError: hasError, currentProjectId: projectId });
} catch (error) {
console.error('Failed to check MCP health', error);
set({ hasMcpError: true, currentProjectId: projectId });
}
},
startPolling: (projectId: number | null) => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
if (!projectId) {
set({ hasMcpError: false, currentProjectId: null });
return;
}
set({ currentProjectId: projectId });
void get().refresh(projectId);
pollingTimer = setInterval(() => {
void get().refresh(projectId);
}, 60000);
},
stopPolling: () => {
if (pollingTimer) {
clearInterval(pollingTimer);
pollingTimer = null;
}
},
}));