feat(mcp): enhance MCP installation with status tracking and custom configs
- Add /mcp-servers/ to .gitignore for MCP server installations - Extend mcp_installations table with status, logs, and validation fields - Refactor MCP install route to support custom configs, enable logs, and async installation - Improve MCPSyncService with promise-based syncing to prevent concurrent operations This update improves MCP management by adding robust installation tracking, custom configuration support, and better error handling for a more reliable user experience.
This commit is contained in:
1 parent
1042f2136b
commit
f19bf10ef3
35 files changed
+4743
-429
No files matched your search
@@ -1,11 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { X, Search, Filter, Package, Star, Eye } from "lucide-react";
|
||||
import { MCPProvider } from "@/lib/types/mcp";
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
Filter,
|
||||
Package,
|
||||
Star,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { MCPProvider, MCPTestResult } from "@/lib/types/mcp";
|
||||
import { MCPCard } from "./MCPCard";
|
||||
import { MCPCategoryFilter } from "./MCPCategoryFilter";
|
||||
import { MCPSearchBar } from "./MCPSearchBar";
|
||||
import { MCPInstallConfigModal } from "./MCPInstallConfigModal";
|
||||
import { MCPManualInstallModal } from "./MCPManualInstallModal";
|
||||
// import { VirtualizedMCPGrid } from "./VirtualizedMCPGrid";
|
||||
|
||||
interface MCPMarketplaceModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,6 +39,11 @@ export function MCPMarketplaceModal({
|
||||
>("rating");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [selectedMCP, setSelectedMCP] = useState<MCPProvider | null>(null);
|
||||
const [configModalOpen, setConfigModalOpen] = useState(false);
|
||||
const [selectedConfigMCP, setSelectedConfigMCP] =
|
||||
useState<MCPProvider | null>(null);
|
||||
const [mcpConfig, setMcpConfig] = useState<any>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
isSyncing: boolean;
|
||||
lastSync: number | null;
|
||||
@@ -37,6 +55,66 @@ export function MCPMarketplaceModal({
|
||||
others: Array<{ category: string; count: number }>;
|
||||
}>({ primary: [], others: [] });
|
||||
|
||||
// Estados para controle de abas
|
||||
const [activeTab, setActiveTab] = useState<"marketplace" | "installed">(
|
||||
"marketplace"
|
||||
);
|
||||
const [installedMcps, setInstalledMcps] = useState<any[]>([]);
|
||||
const [loadingInstalled, setLoadingInstalled] = useState(false);
|
||||
const [isEditMode, setIsEditMode] = useState(false);
|
||||
|
||||
// Estados para teste de servidor
|
||||
const [testingMCP, setTestingMCP] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<MCPTestResult | null>(null);
|
||||
|
||||
// Estados para tracking de instalação em progresso
|
||||
const [installingMcps, setInstallingMcps] = useState<
|
||||
Map<
|
||||
string,
|
||||
{
|
||||
status: string;
|
||||
message: string;
|
||||
percentage: number;
|
||||
}
|
||||
>
|
||||
>(new Map());
|
||||
|
||||
// Estado para modal de instalação manual
|
||||
const [manualInstallModalOpen, setManualInstallModalOpen] = useState(false);
|
||||
|
||||
// Função helper para calcular porcentagem baseada no status
|
||||
const calculatePercentage = (status: string): number => {
|
||||
const statusMap: Record<string, number> = {
|
||||
pending: 5,
|
||||
checking_dependencies: 10,
|
||||
downloading: 30,
|
||||
installing: 60,
|
||||
testing: 85,
|
||||
ready: 100,
|
||||
failed: 0,
|
||||
};
|
||||
return statusMap[status] || 0;
|
||||
};
|
||||
|
||||
// Memoizar categorias processadas para evitar re-processamento desnecessário
|
||||
const processedCategories = useMemo(
|
||||
() => ({
|
||||
primary: categories.primary.map((cat) => ({
|
||||
id: cat.category.toLowerCase(),
|
||||
name: cat.category,
|
||||
icon: cat.category,
|
||||
count: cat.count,
|
||||
})),
|
||||
others: categories.others.map((cat) => ({
|
||||
id: cat.category.toLowerCase(),
|
||||
name: cat.category,
|
||||
icon: cat.category,
|
||||
count: cat.count,
|
||||
})),
|
||||
}),
|
||||
[categories]
|
||||
);
|
||||
|
||||
const fetchMCPs = useCallback(async () => {
|
||||
console.log("Fetching MCPs with params:", {
|
||||
selectedCategory,
|
||||
@@ -83,25 +161,261 @@ export function MCPMarketplaceModal({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchInstalledMCPs = useCallback(async () => {
|
||||
setLoadingInstalled(true);
|
||||
try {
|
||||
const response = await fetch("/api/mcp/installed");
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setInstalledMcps(data.mcps);
|
||||
console.log("📦 Installed MCPs loaded:", data.mcps.length);
|
||||
} else {
|
||||
console.error("Failed to fetch installed MCPs:", data.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching installed MCPs:", error);
|
||||
} finally {
|
||||
setLoadingInstalled(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleInstall = async (mcpId: string) => {
|
||||
try {
|
||||
const response = await fetch("/api/mcp/install", {
|
||||
// Limpar ID de possíveis barras no início/fim
|
||||
const cleanId = mcpId.replace(/^\/+|\/+$/g, "");
|
||||
|
||||
console.log(
|
||||
`[handleInstall] Received mcpId: "${mcpId}" -> cleaned: "${cleanId}"`
|
||||
);
|
||||
|
||||
// Buscar dados completos do MCP no cache
|
||||
const mcpFromCache = mcps.find((m) => m.id === cleanId || m.id === mcpId);
|
||||
if (!mcpFromCache) {
|
||||
console.error(
|
||||
"MCP not found in cache. Looking for:",
|
||||
cleanId,
|
||||
"Available IDs:",
|
||||
mcps.slice(0, 5).map((m) => m.id)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[handleInstall] Found MCP:", {
|
||||
id: mcpFromCache.id,
|
||||
owner: mcpFromCache.owner,
|
||||
repo: mcpFromCache.repo,
|
||||
name: mcpFromCache.name,
|
||||
});
|
||||
|
||||
// Construir ID no formato owner/repo
|
||||
let finalId = cleanId;
|
||||
if (mcpFromCache.owner && mcpFromCache.owner.trim() !== "") {
|
||||
finalId = `${mcpFromCache.owner}/${mcpFromCache.repo}`;
|
||||
} else if (mcpFromCache.repo) {
|
||||
finalId = mcpFromCache.repo;
|
||||
}
|
||||
|
||||
console.log(`[handleInstall] Using final ID: "${finalId}"`);
|
||||
|
||||
setLoadingConfig(true);
|
||||
|
||||
// Abrir modal de configuração sempre
|
||||
// Se tivermos owner/repo, buscar config; senão, abrir com config vazio
|
||||
if (finalId.includes("/")) {
|
||||
const configResponse = await fetch(
|
||||
`/api/mcp/server-config?mcpId=${encodeURIComponent(finalId)}`
|
||||
);
|
||||
const configData = await configResponse.json();
|
||||
|
||||
if (configData.success) {
|
||||
console.log(
|
||||
"Opening config modal with:",
|
||||
configData.config ? "provided config" : "empty config"
|
||||
);
|
||||
setSelectedConfigMCP(mcpFromCache);
|
||||
setMcpConfig(configData.config); // Pode ser null
|
||||
setConfigModalOpen(true);
|
||||
} else {
|
||||
console.error("Error fetching server config:", configData.error);
|
||||
// Abrir modal mesmo com erro (usuário pode adicionar config)
|
||||
setSelectedConfigMCP(mcpFromCache);
|
||||
setMcpConfig(null);
|
||||
setConfigModalOpen(true);
|
||||
}
|
||||
} else {
|
||||
console.log("No owner/repo format, opening modal with empty config");
|
||||
// Abrir modal com config vazio
|
||||
setSelectedConfigMCP(mcpFromCache);
|
||||
setMcpConfig(null);
|
||||
setConfigModalOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error preparing MCP installation:", error);
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditConfig = async (mcp: any) => {
|
||||
try {
|
||||
setLoadingConfig(true);
|
||||
setIsEditMode(true);
|
||||
|
||||
// Converter dados do MCP instalado para formato MCPProvider
|
||||
const mcpProvider: MCPProvider = {
|
||||
id: mcp.id,
|
||||
name: mcp.name,
|
||||
author: mcp.author || mcp.owner || "Unknown",
|
||||
description: mcp.description || `MCP instalado: ${mcp.name}`,
|
||||
version: "1.0.0",
|
||||
category: mcp.category || "other",
|
||||
tags: mcp.tags || [],
|
||||
rating: mcp.rating || 0,
|
||||
totalRatings: mcp.totalRatings || 0,
|
||||
installed: true,
|
||||
repository: mcp.repository || "",
|
||||
homepage: mcp.homepage || "",
|
||||
config: mcp.config,
|
||||
tools: mcp.tools,
|
||||
};
|
||||
|
||||
setSelectedConfigMCP(mcpProvider);
|
||||
setMcpConfig(mcp.config);
|
||||
setConfigModalOpen(true);
|
||||
} catch (error) {
|
||||
console.error("Error preparing MCP config edit:", error);
|
||||
} finally {
|
||||
setLoadingConfig(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestServer = async (mcpId: string) => {
|
||||
try {
|
||||
setTestingMCP(mcpId);
|
||||
setTestResult(null);
|
||||
|
||||
console.log(`🧪 Testando servidor MCP: ${mcpId}`);
|
||||
|
||||
const response = await fetch("/api/mcp/test-server", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpId }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
// Atualizar estado local
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) =>
|
||||
mcp.id === mcpId ? { ...mcp, installed: true } : mcp
|
||||
)
|
||||
);
|
||||
setTestResult(data.result);
|
||||
console.log("✅ Teste concluído:", data.result);
|
||||
} else {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: data.error || "Erro desconhecido",
|
||||
error: data.error,
|
||||
});
|
||||
console.error("❌ Erro no teste:", data.error);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Erro ao testar servidor:", error);
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: "Erro de conexão",
|
||||
error: error.message,
|
||||
});
|
||||
} finally {
|
||||
setTestingMCP(null);
|
||||
|
||||
// Limpar resultado após 5 segundos
|
||||
setTimeout(() => {
|
||||
setTestResult(null);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmInstall = async (editedConfig: any) => {
|
||||
if (!selectedConfigMCP) return;
|
||||
|
||||
try {
|
||||
if (isEditMode) {
|
||||
// Modo de edição - atualizar configuração existente
|
||||
const response = await fetch("/api/mcp/update-config", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mcpId: selectedConfigMCP.id,
|
||||
newConfig: editedConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Recarregar lista de MCPs instalados
|
||||
await fetchInstalledMCPs();
|
||||
// Fechar modal
|
||||
setConfigModalOpen(false);
|
||||
setSelectedConfigMCP(null);
|
||||
setMcpConfig(null);
|
||||
setIsEditMode(false);
|
||||
} else {
|
||||
console.error("Error updating MCP config:", data.error);
|
||||
}
|
||||
} else {
|
||||
// Modo de instalação - comportamento original
|
||||
let finalId = selectedConfigMCP.id;
|
||||
if (!finalId.includes("/")) {
|
||||
if (
|
||||
selectedConfigMCP.owner &&
|
||||
selectedConfigMCP.owner.trim() !== ""
|
||||
) {
|
||||
finalId = `${selectedConfigMCP.owner}/${selectedConfigMCP.repo}`;
|
||||
} else if (selectedConfigMCP.repo) {
|
||||
finalId = selectedConfigMCP.repo;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch("/api/mcp/install", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
mcpId: finalId,
|
||||
customConfig: editedConfig,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
// Adicionar ao tracking de instalação
|
||||
setInstallingMcps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(finalId, {
|
||||
status: data.status || "pending",
|
||||
message: "Installation started...",
|
||||
percentage: 5,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
// Atualizar estado local
|
||||
setMcps((prev) =>
|
||||
prev.map((mcp) =>
|
||||
mcp.id === selectedConfigMCP.id
|
||||
? { ...mcp, installed: true }
|
||||
: mcp
|
||||
)
|
||||
);
|
||||
// Fechar modal
|
||||
setConfigModalOpen(false);
|
||||
setSelectedConfigMCP(null);
|
||||
setMcpConfig(null);
|
||||
|
||||
console.log(`✅ Installation started for ${finalId}`);
|
||||
} else {
|
||||
console.error("Error installing MCP:", data.error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error installing MCP:", error);
|
||||
console.error("Error processing MCP:", error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -139,6 +453,13 @@ export function MCPMarketplaceModal({
|
||||
}
|
||||
}, [isOpen]); // Removido fetchMCPs das dependências
|
||||
|
||||
// Carregar MCPs instalados quando aba "installed" for ativada
|
||||
useEffect(() => {
|
||||
if (isOpen && activeTab === "installed") {
|
||||
fetchInstalledMCPs();
|
||||
}
|
||||
}, [isOpen, activeTab]);
|
||||
|
||||
// Debounce para searchTerm
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -165,6 +486,57 @@ export function MCPMarketplaceModal({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Poll status de instalações em progresso
|
||||
useEffect(() => {
|
||||
if (installingMcps.size === 0) return;
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
for (const mcpId of installingMcps.keys()) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/mcp/install-status?mcpId=${mcpId}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const status = data.status;
|
||||
const isComplete = status === "ready" || status === "failed";
|
||||
|
||||
if (isComplete) {
|
||||
// Remover do tracking
|
||||
setInstallingMcps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(mcpId);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Atualizar lista de MCPs
|
||||
await fetchMCPs();
|
||||
if (activeTab === "installed") {
|
||||
await fetchInstalledMCPs();
|
||||
}
|
||||
} else {
|
||||
// Atualizar progresso
|
||||
setInstallingMcps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(mcpId, {
|
||||
status: data.status,
|
||||
message: data.message || "",
|
||||
percentage: calculatePercentage(data.status),
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error polling status for ${mcpId}:`, error);
|
||||
}
|
||||
}
|
||||
}, 2000); // Poll a cada 2 segundos
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [installingMcps, activeTab]);
|
||||
|
||||
// Recarregar MCPs quando filtros mudarem (só se modal estiver aberto)
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -185,14 +557,56 @@ export function MCPMarketplaceModal({
|
||||
<Package className="h-6 w-6 text-[var(--primary)]" />
|
||||
<h2 className="text-xl font-semibold">MCP Marketplace</h2>
|
||||
<span className="text-sm text-[var(--muted-foreground)]">
|
||||
{mcps.length} MCPs available
|
||||
{activeTab === "marketplace"
|
||||
? `${mcps.length} MCPs available`
|
||||
: `${installedMcps.length} MCPs installed`}
|
||||
</span>
|
||||
{loadingConfig && (
|
||||
<div className="flex items-center gap-2 text-sm text-blue-600">
|
||||
<div className="animate-spin h-4 w-4 border-2 border-blue-600 border-t-transparent rounded-full"></div>
|
||||
<span>Carregando configuração...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setManualInstallModalOpen(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||
title="Install MCP manually from configuration"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
Install Manual
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-[var(--border)] px-6 flex-shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-[var(--surface)] rounded-md transition-colors"
|
||||
onClick={() => setActiveTab("marketplace")}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "marketplace"
|
||||
? "border-[var(--primary)] text-[var(--primary)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
Marketplace
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("installed")}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "installed"
|
||||
? "border-[var(--primary)] text-[var(--primary)]"
|
||||
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
Instalados ({installedMcps.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -244,101 +658,246 @@ export function MCPMarketplaceModal({
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Sidebar */}
|
||||
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
|
||||
<MCPCategoryFilter
|
||||
primaryCategories={categories.primary.map((cat) => ({
|
||||
id: cat.category.toLowerCase(),
|
||||
name: cat.category,
|
||||
icon: cat.category,
|
||||
count: cat.count,
|
||||
}))}
|
||||
otherCategories={categories.others.map((cat) => ({
|
||||
id: cat.category.toLowerCase(),
|
||||
name: cat.category,
|
||||
icon: cat.category,
|
||||
count: cat.count,
|
||||
}))}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
{/* Sidebar - apenas para marketplace */}
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="w-64 border-r border-[var(--border)] p-4 flex-shrink-0 overflow-y-auto">
|
||||
<MCPCategoryFilter
|
||||
primaryCategories={processedCategories.primary}
|
||||
otherCategories={processedCategories.others}
|
||||
selectedCategory={selectedCategory}
|
||||
onSelectCategory={setSelectedCategory}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Search and Filters */}
|
||||
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
|
||||
<div className="flex gap-4 items-center">
|
||||
<MCPSearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
onSearch={() => {
|
||||
// O useEffect já cuida de recarregar quando searchTerm muda
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
|
||||
>
|
||||
<option value="rating">Por Avaliação</option>
|
||||
<option value="name">Por Nome</option>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
<option value="total_ratings">Por Nº de Reviews</option>
|
||||
<option value="updated_at">Atualizados Recentemente</option>
|
||||
</select>
|
||||
{/* Search and Filters - apenas para marketplace */}
|
||||
{activeTab === "marketplace" && (
|
||||
<div className="p-4 border-b border-[var(--border)] flex-shrink-0">
|
||||
<div className="flex gap-4 items-center">
|
||||
<MCPSearchBar
|
||||
value={searchTerm}
|
||||
onChange={setSearchTerm}
|
||||
onSearch={() => {
|
||||
// O useEffect já cuida de recarregar quando searchTerm muda
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-3 py-2 bg-[var(--surface)] border border-[var(--border)] rounded-md text-sm"
|
||||
>
|
||||
<option value="rating">Por Avaliação</option>
|
||||
<option value="name">Por Nome</option>
|
||||
<option value="recent">Mais Recentes</option>
|
||||
<option value="total_ratings">Por Nº de Reviews</option>
|
||||
<option value="updated_at">
|
||||
Atualizados Recentemente
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"))
|
||||
}
|
||||
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
|
||||
title={
|
||||
sortOrder === "asc"
|
||||
? "Ordem Crescente"
|
||||
: "Ordem Decrescente"
|
||||
}
|
||||
>
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setSortOrder((prev) =>
|
||||
prev === "asc" ? "desc" : "asc"
|
||||
)
|
||||
}
|
||||
className="px-3 py-2 border border-[var(--border)] rounded-md hover:bg-[var(--surface)] transition-colors"
|
||||
title={
|
||||
sortOrder === "asc"
|
||||
? "Ordem Crescente"
|
||||
: "Ordem Decrescente"
|
||||
}
|
||||
>
|
||||
{sortOrder === "asc" ? "↑" : "↓"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MCP Grid */}
|
||||
<div className="flex-1 p-4 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{mcps.map((mcp) => (
|
||||
<MCPCard
|
||||
key={mcp.id}
|
||||
mcp={mcp}
|
||||
onInstall={() => handleInstall(mcp.id)}
|
||||
onUninstall={() => handleUninstall(mcp.id)}
|
||||
onViewDetails={() => handleViewDetails(mcp)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{activeTab === "marketplace" && (
|
||||
<>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{mcps.map((mcp) => (
|
||||
<MCPCard
|
||||
key={mcp.id}
|
||||
mcp={mcp}
|
||||
onInstall={() => handleInstall(mcp.id)}
|
||||
onUninstall={() => handleUninstall(mcp.id)}
|
||||
onViewDetails={() => handleViewDetails(mcp)}
|
||||
installingStatus={installingMcps.get(mcp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && mcps.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>No MCPs found</p>
|
||||
<p className="text-sm">
|
||||
Try adjusting your search or filters
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && mcps.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>No MCPs found</p>
|
||||
<p className="text-sm">
|
||||
Try adjusting your search or filters
|
||||
</p>
|
||||
</div>
|
||||
{activeTab === "installed" && (
|
||||
<>
|
||||
{loadingInstalled ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--primary)]"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{installedMcps.map((mcp) => (
|
||||
<MCPCard
|
||||
key={mcp.id}
|
||||
mcp={{
|
||||
id: mcp.id,
|
||||
name: mcp.name,
|
||||
author: mcp.author || mcp.owner || "Unknown",
|
||||
description:
|
||||
mcp.description || `MCP instalado: ${mcp.name}`,
|
||||
version: "1.0.0",
|
||||
category: mcp.category || "other",
|
||||
tags: mcp.tags || [],
|
||||
rating: mcp.rating || 0,
|
||||
totalRatings: mcp.totalRatings || 0,
|
||||
installed: true,
|
||||
repository: mcp.repository || "",
|
||||
homepage: mcp.homepage || "",
|
||||
config: mcp.config,
|
||||
tools: mcp.tools,
|
||||
}}
|
||||
mode="installed"
|
||||
onInstall={() => {}} // Não usado no modo installed
|
||||
onEditConfig={() => handleEditConfig(mcp)}
|
||||
onTestServer={() => handleTestServer(mcp.id)}
|
||||
isTesting={testingMCP === mcp.id}
|
||||
onUninstall={() => handleUninstall(mcp.id)}
|
||||
onViewDetails={() =>
|
||||
handleViewDetails({
|
||||
id: mcp.id,
|
||||
name: mcp.name,
|
||||
author: mcp.author || mcp.owner || "Unknown",
|
||||
description:
|
||||
mcp.description || `MCP instalado: ${mcp.name}`,
|
||||
version: "1.0.0",
|
||||
category: mcp.category || "other",
|
||||
tags: mcp.tags || [],
|
||||
rating: mcp.rating || 0,
|
||||
totalRatings: mcp.totalRatings || 0,
|
||||
installed: true,
|
||||
repository: mcp.repository || "",
|
||||
homepage: mcp.homepage || "",
|
||||
config: mcp.config,
|
||||
tools: mcp.tools,
|
||||
})
|
||||
}
|
||||
installingStatus={installingMcps.get(mcp.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingInstalled && installedMcps.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-[var(--muted-foreground)]">
|
||||
<Package className="h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum MCP instalado</p>
|
||||
<p className="text-sm">Instale MCPs na aba Marketplace</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual Install Modal */}
|
||||
<MCPManualInstallModal
|
||||
isOpen={manualInstallModalOpen}
|
||||
onClose={() => setManualInstallModalOpen(false)}
|
||||
onSuccess={async (mcpId) => {
|
||||
console.log(`✅ Manual installation started for ${mcpId}`);
|
||||
|
||||
// Adicionar ao tracking de instalação
|
||||
setInstallingMcps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(mcpId, {
|
||||
status: "pending",
|
||||
message: "Installation started...",
|
||||
percentage: 5,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
|
||||
// Atualizar listas
|
||||
await fetchMCPs();
|
||||
if (activeTab === "installed") {
|
||||
await fetchInstalledMCPs();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Config Modal */}
|
||||
{selectedConfigMCP && (
|
||||
<MCPInstallConfigModal
|
||||
mcp={selectedConfigMCP}
|
||||
config={mcpConfig}
|
||||
isOpen={configModalOpen}
|
||||
onClose={() => {
|
||||
setConfigModalOpen(false);
|
||||
setSelectedConfigMCP(null);
|
||||
setMcpConfig(null);
|
||||
setIsEditMode(false);
|
||||
}}
|
||||
onConfirm={handleConfirmInstall}
|
||||
isEditMode={isEditMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Toast de resultado do teste */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`fixed bottom-4 right-4 p-4 rounded-lg shadow-lg z-50 max-w-sm ${
|
||||
testResult.success ? "bg-green-500" : "bg-red-500"
|
||||
} text-white`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-5 w-5 flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold text-sm">{testResult.message}</p>
|
||||
{testResult.responseTime && (
|
||||
<p className="text-xs opacity-90">
|
||||
Tempo de resposta: {testResult.responseTime}ms
|
||||
</p>
|
||||
)}
|
||||
{testResult.error && (
|
||||
<p className="text-xs opacity-90 font-mono">
|
||||
{testResult.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user