diff --git a/apps/mesh/src/web/components/connections/connection-instance-row.tsx b/apps/mesh/src/web/components/connections/connection-instance-row.tsx new file mode 100644 index 0000000000..1b607aa9c3 --- /dev/null +++ b/apps/mesh/src/web/components/connections/connection-instance-row.tsx @@ -0,0 +1,92 @@ +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { ChevronRight } from "@untitledui/icons"; +import { User } from "@/web/components/user/user.tsx"; + +type StatusValue = "active" | "error" | "inactive"; + +function StatusDot({ status }: { status: StatusValue }) { + if (status === "active") { + return ( +
+ + Connected +
+ ); + } + + if (status === "error") { + return ( +
+ + Error +
+ ); + } + + return ( +
+ + Inactive +
+ ); +} + +export interface ConnectionInstanceRowProps { + connection: ConnectionEntity; + onClick: () => void; + /** Show the integration icon — used for solo connections that render without an accordion header */ + showIcon?: boolean; +} + +export function ConnectionInstanceRow({ + connection, + onClick, + showIcon = false, +}: ConnectionInstanceRowProps) { + return ( + + ); +} diff --git a/apps/mesh/src/web/components/connections/connection-service-group.tsx b/apps/mesh/src/web/components/connections/connection-service-group.tsx new file mode 100644 index 0000000000..1a6f97b52c --- /dev/null +++ b/apps/mesh/src/web/components/connections/connection-service-group.tsx @@ -0,0 +1,86 @@ +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@deco/ui/components/collapsible.tsx"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { ChevronDown, ChevronRight } from "@untitledui/icons"; +import { useState } from "react"; +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { ConnectionInstanceRow } from "./connection-instance-row.tsx"; + +export interface ConnectionServiceGroupProps { + serviceName: string; + icon: string | null; + instances: ConnectionEntity[]; + defaultOpen?: boolean; + onInstanceClick: (connectionId: string) => void; +} + +export function ConnectionServiceGroup({ + serviceName, + icon, + instances, + defaultOpen, + onInstanceClick, +}: ConnectionServiceGroupProps) { + const firstInstance = instances[0]; + const isSolo = + instances.length === 1 && firstInstance != null && !firstInstance.app_name; + const resolvedDefaultOpen = defaultOpen ?? instances.length === 1; + const [open, setOpen] = useState(resolvedDefaultOpen); + + if (isSolo && firstInstance != null) { + return ( +
+ onInstanceClick(firstInstance.id)} + showIcon + /> +
+ ); + } + + return ( + + + + + +
+ {instances.map((instance) => ( + onInstanceClick(instance.id)} + /> + ))} +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/connection/connection-activity.tsx b/apps/mesh/src/web/components/details/connection/connection-activity.tsx new file mode 100644 index 0000000000..2fd7ff4344 --- /dev/null +++ b/apps/mesh/src/web/components/details/connection/connection-activity.tsx @@ -0,0 +1,217 @@ +import { + calculateStats, + type MonitoringLogsResponse as BaseMonitoringLogsResponse, +} from "@/web/components/monitoring/monitoring-stats-row.tsx"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@deco/ui/components/chart.tsx"; +import { KEYS } from "@/web/lib/query-keys.ts"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { + SELF_MCP_ALIAS_ID, + useMCPClient, + useProjectContext, +} from "@decocms/mesh-sdk"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { Suspense, useState } from "react"; +import { Bar, BarChart, Cell, XAxis } from "recharts"; + +type Timeframe = "7d" | "14d" | "30d"; + +const TIMEFRAMES: { value: Timeframe; label: string }[] = [ + { value: "7d", label: "7d" }, + { value: "14d", label: "14d" }, + { value: "30d", label: "30d" }, +]; + +function getDateRange(timeframe: Timeframe): { + startDate: Date; + endDate: Date; +} { + const end = new Date(); + const start = new Date(end); + if (timeframe === "7d") start.setDate(start.getDate() - 7); + else if (timeframe === "14d") start.setDate(start.getDate() - 14); + else start.setDate(start.getDate() - 30); + return { startDate: start, endDate: end }; +} + +const CHART_CONFIG = { + calls: { label: "Tool calls" }, + errors: { label: "Errors" }, +}; + +interface ActivityChartProps { + connectionId: string; + orgId: string; + timeframe: Timeframe; +} + +function ActivityChart({ connectionId, orgId, timeframe }: ActivityChartProps) { + const client = useMCPClient({ connectionId: SELF_MCP_ALIAS_ID, orgId }); + const dateRange = getDateRange(timeframe); + + const { data } = useSuspenseQuery({ + queryKey: KEYS.connectionActivity(connectionId, timeframe), + queryFn: async () => { + const result = (await client.callTool({ + name: "MONITORING_LOGS_LIST", + arguments: { + startDate: dateRange.startDate.toISOString(), + endDate: dateRange.endDate.toISOString(), + connectionId, + limit: 2000, + offset: 0, + }, + })) as { structuredContent?: unknown }; + return (result.structuredContent ?? result) as BaseMonitoringLogsResponse; + }, + staleTime: 5 * 60 * 1000, + }); + + const stats = calculateStats(data?.logs ?? [], dateRange); + const chartData = stats.data; + const hasData = stats.totalCalls > 0; + + return ( +
+ {/* Summary numbers */} +
+
+

+ {stats.totalCalls.toLocaleString()} +

+

Tool calls

+
+ {stats.totalErrors > 0 && ( +
+

+ {stats.totalErrors.toLocaleString()} +

+

Errors

+
+ )} + {stats.avgDurationMs > 0 && ( +
+

+ {Math.round(stats.avgDurationMs)}ms +

+

Avg latency

+
+ )} +
+ + {hasData ? ( + + + + { + const first = Array.isArray(payload) + ? payload[0] + : undefined; + return first && + typeof first === "object" && + "payload" in first + ? ((first as { payload?: { label?: string } }).payload + ?.label ?? "") + : ""; + }} + /> + } + cursor={{ fill: "var(--muted)" }} + /> + + {chartData.map((entry, index) => ( + 50 + ? "var(--destructive)" + : "var(--foreground)" + } + fillOpacity={ + entry.calls === 0 ? 0.2 : entry.errorRate > 50 ? 0.7 : 0.85 + } + /> + ))} + + + + ) : ( +
+

+ No activity in this period +

+
+ )} +
+ ); +} + +function ActivitySkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +interface ConnectionActivityProps { + connectionId: string; +} + +export function ConnectionActivity({ connectionId }: ConnectionActivityProps) { + const [timeframe, setTimeframe] = useState("14d"); + const { org } = useProjectContext(); + + return ( +
+
+
+

Activity

+
+
+ {TIMEFRAMES.map((tf) => ( + + ))} +
+
+ }> + + +
+ ); +} diff --git a/apps/mesh/src/web/components/details/connection/connection-agents-panel.tsx b/apps/mesh/src/web/components/details/connection/connection-agents-panel.tsx new file mode 100644 index 0000000000..4757115c2c --- /dev/null +++ b/apps/mesh/src/web/components/details/connection/connection-agents-panel.tsx @@ -0,0 +1,32 @@ +import { useProjectContext } from "@decocms/mesh-sdk"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { ConnectionVirtualMCPsSection } from "./settings-tab/connection-virtual-mcps-section"; + +interface ConnectionAgentsPanelProps { + connection: ConnectionEntity; +} + +export function ConnectionAgentsPanel({ + connection, +}: ConnectionAgentsPanelProps) { + const { org } = useProjectContext(); + + return ( +
+
+

+ Used by agents +

+
+
+ +
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/connection/connection-capabilities.tsx b/apps/mesh/src/web/components/details/connection/connection-capabilities.tsx new file mode 100644 index 0000000000..55d007bfc7 --- /dev/null +++ b/apps/mesh/src/web/components/details/connection/connection-capabilities.tsx @@ -0,0 +1,248 @@ +import { cn } from "@deco/ui/lib/utils.ts"; +import { BookOpen01, Columns01, Tool01 } from "@untitledui/icons"; +import { useState } from "react"; + +/** + * Converts a snake_case or dot.case tool function name to readable English. + * e.g. "repos.list" -> "List Repos", "create_issue" -> "Create Issue" + */ +function humanizeName(name: string): string { + const parts = name.replace(/[._]/g, " ").trim().split(/\s+/); + const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + + const verbs = new Set([ + "list", + "create", + "get", + "update", + "delete", + "fetch", + "search", + "run", + "trigger", + "review", + "send", + "check", + ]); + + const words = parts.map(capitalize); + // If last word is a verb, move it to front for natural reading + const lastWord = parts[parts.length - 1]; + if (words.length >= 2 && lastWord && verbs.has(lastWord)) { + const verb = words.pop()!; + return [verb, ...words].join(" "); + } + return words.join(" "); +} + +interface Tool { + name: string; + description?: string; +} + +interface Prompt { + name: string; + description?: string; +} + +interface Resource { + name: string; + description?: string; + uri?: string; +} + +interface ConnectionCapabilitiesProps { + tools: Tool[]; + prompts?: Prompt[]; + resources?: Resource[]; +} + +type Tab = "tools" | "prompts" | "resources"; + +function EmptyCapabilities({ label }: { label: string }) { + return ( +
+

No {label} available.

+
+ ); +} + +export function ConnectionCapabilities({ + tools, + prompts = [], + resources = [], +}: ConnectionCapabilitiesProps) { + const hasTools = tools.length > 0; + const hasPrompts = prompts.length > 0; + const hasResources = resources.length > 0; + + const tabs = [ + { + id: "tools" as Tab, + label: "Tools", + count: tools.length, + icon: Tool01, + show: true, + }, + { + id: "prompts" as Tab, + label: "Prompts", + count: prompts.length, + icon: BookOpen01, + show: hasPrompts, + }, + { + id: "resources" as Tab, + label: "Resources", + count: resources.length, + icon: Columns01, + show: hasResources, + }, + ].filter((t) => t.show); + + const [activeTab, setActiveTab] = useState("tools"); + + // If the active tab has no content, reset to tools + const resolvedTab = + activeTab === "prompts" && !hasPrompts + ? "tools" + : activeTab === "resources" && !hasResources + ? "tools" + : activeTab; + + const totalItems = tools.length + prompts.length + resources.length; + + if (totalItems === 0) { + return ( +
+

+ Capabilities +

+

+ No capabilities discovered yet. The connection may still be + connecting. +

+
+ ); + } + + return ( +
+
+

Capabilities

+ {tabs.length > 1 && ( +
+ {tabs.map((tab) => ( + + ))} +
+ )} + {tabs.length === 1 && ( +

+ {tools.length} {tools.length === 1 ? "tool" : "tools"} +

+ )} +
+ + {resolvedTab === "tools" && ( +
+ {hasTools ? ( + tools.map((tool) => ( +
+
+ +
+
+
+ {humanizeName(tool.name)} +
+ {tool.description && ( +
+ {tool.description} +
+ )} +
+
+ )) + ) : ( + + )} +
+ )} + + {resolvedTab === "prompts" && ( +
+ {prompts.map((prompt) => ( +
+
+ +
+
+
+ {humanizeName(prompt.name)} +
+ {prompt.description && ( +
+ {prompt.description} +
+ )} +
+
+ ))} +
+ )} + + {resolvedTab === "resources" && ( +
+ {resources.map((resource) => ( +
+
+ +
+
+
+ {resource.name} +
+ {resource.description && ( +
+ {resource.description} +
+ )} + {resource.uri && ( +
+ {resource.uri} +
+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/mesh/src/web/components/details/connection/connection-detail-header.tsx b/apps/mesh/src/web/components/details/connection/connection-detail-header.tsx new file mode 100644 index 0000000000..f0a9a42c56 --- /dev/null +++ b/apps/mesh/src/web/components/details/connection/connection-detail-header.tsx @@ -0,0 +1,82 @@ +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { ConnectionStatus } from "@/web/components/connections/connection-status.tsx"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { Settings01 } from "@untitledui/icons"; + +interface ConnectionDetailHeaderProps { + connection: ConnectionEntity; + onOpenSettings: () => void; + onDisconnect: () => void; +} + +export function ConnectionDetailHeader({ + connection, + onOpenSettings, + onDisconnect, +}: ConnectionDetailHeaderProps) { + return ( +
+ +
+
+

+ {connection.title} +

+ +
+
+ {connection.app_name && ( + + {connection.app_name} + + )} + {connection.app_name && connection.description && ( + · + )} + {connection.description && ( +

+ {connection.description} +

+ )} +
+ {connection.tools && connection.tools.length > 0 && ( +
+ + {connection.tools.length}{" "} + {connection.tools.length === 1 ? "tool" : "tools"} + +
+ )} +
+
+ + +
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/connection/connection-info-card.tsx b/apps/mesh/src/web/components/details/connection/connection-info-card.tsx new file mode 100644 index 0000000000..5027e86b89 --- /dev/null +++ b/apps/mesh/src/web/components/details/connection/connection-info-card.tsx @@ -0,0 +1,48 @@ +import { User } from "@/web/components/user/user.tsx"; +import { formatTimeAgo } from "@/web/lib/format-time.ts"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; + +interface ConnectionInfoCardProps { + connection: ConnectionEntity; + onOpenSettings: () => void; +} + +export function ConnectionInfoCard({ + connection, + onOpenSettings, +}: ConnectionInfoCardProps) { + return ( +
+
+

Connection

+ +
+
+
+ Added by + +
+
+ Updated + + {connection.updated_at + ? formatTimeAgo(new Date(connection.updated_at)) + : "—"} + +
+
+ Protocol + + {connection.connection_type} + +
+
+
+ ); +} diff --git a/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx b/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx index 434d59884e..2e350d4a6c 100644 --- a/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx +++ b/apps/mesh/src/web/components/details/connection/connection-sidebar.tsx @@ -54,7 +54,7 @@ interface ConnectionSidebarProps { onRemoveOAuth?: () => void | Promise; } -function ConnectionFields({ +export function ConnectionFields({ form, connection, hasOAuthToken, @@ -97,7 +97,7 @@ function ConnectionFields({ if (isVirtualConnection) { return ( -
+
Type @@ -129,7 +129,7 @@ function ConnectionFields({ } return ( -
+
; onUpdate: (connection: Partial) => Promise; isUpdating: boolean; - prompts: Array<{ name: string; description?: string }>; - resources: Array<{ - uri: string; - name?: string; - description?: string; - mimeType?: string; - }>; tools: Array<{ name: string; description?: string; @@ -287,12 +279,15 @@ function ConnectionInspectorViewWithConnection({ annotations?: ToolDefinition["annotations"]; _meta?: Record; }>; - isLoadingTools: boolean; + prompts: Array<{ name: string; description?: string }>; + resources: Array<{ name: string; description?: string; uri?: string }>; }) { const navigate = useNavigate({ from: "/$org/$project/mcps/$connectionId" }); const queryClient = useQueryClient(); const connectionActions = useConnectionActions(); + const [settingsOpen, setSettingsOpen] = useState(false); + const authStatus = useMCPAuthStatus({ connectionId: connectionId, }); @@ -308,12 +303,6 @@ function ConnectionInspectorViewWithConnection({ }); const hasMcpBinding = mcpBindingConnections.length > 0; - // Check if connection has repository info for README tab (stored in metadata) - const repository = connection?.metadata?.repository as - | { url?: string; source?: string; subfolder?: string } - | undefined; - const hasRepository = !!repository?.url; - // Form state lifted to parent const form = useForm({ resolver: zodResolver(connectionFormSchema), @@ -441,62 +430,20 @@ function ConnectionInspectorViewWithConnection({ } }; - const toolsCount = tools.length; - const promptsCount = prompts.length; - const resourcesCount = resources.length; - const uiToolsCount = tools.filter((t) => !!getUIResourceUri(t._meta)).length; - - // Show Tools tab if we have tools OR if we're still loading them - // This handles VIRTUAL connections and others that fetch tools dynamically - const showToolsTab = toolsCount > 0 || isLoadingTools; - - const tabs = [ - { id: "settings", label: "Settings" }, - ...(isMCPAuthenticated && showToolsTab - ? [ - { - id: "tools", - label: "Tools", - count: isLoadingTools ? undefined : toolsCount, - }, - ] - : []), - ...(isMCPAuthenticated && promptsCount > 0 - ? [{ id: "prompts", label: "Prompts", count: promptsCount }] - : []), - ...(isMCPAuthenticated && resourcesCount > 0 - ? [{ id: "resources", label: "Resources", count: resourcesCount }] - : []), - ...(isMCPAuthenticated && uiToolsCount > 0 - ? [{ id: "ui", label: "UI", count: uiToolsCount }] - : []), - ...(isMCPAuthenticated - ? (collections || []).map((c) => ({ id: c.name, label: c.displayName })) - : []), - ...(hasRepository ? [{ id: "readme", label: "README" }] : []), - ]; - - // Default to "tools" when authenticated (if tools tab exists), otherwise "settings" - const defaultTab = - isMCPAuthenticated && tabs.some((t) => t.id === "tools") - ? "tools" - : "settings"; - - const activeTabId = tabs.some((t) => t.id === requestedTabId) - ? requestedTabId - : defaultTab; - - const handleTabChange = (tabId: string) => { + const handleDisconnect = async () => { + if ( + !window.confirm( + `Disconnect "${connection.title}"? This cannot be undone.`, + ) + ) + return; + await connectionActions.delete.mutateAsync(connection.id); navigate({ - search: (prev: { tab?: string }) => ({ ...prev, tab: tabId }), - replace: true, + to: "/$org/$project/mcps", + params: { org, project: ORG_ADMIN_PROJECT_SLUG }, }); }; - const activeCollection = (collections || []).find( - (c) => c.name === activeTabId, - ); - const breadcrumb = ( @@ -519,110 +466,119 @@ function ConnectionInspectorViewWithConnection({ ); return ( - - - - -
- {/* Fixed left sidebar */} -
- + {/* Settings Sheet */} + + + + {connection.title} + + Update URL, authentication, and other settings + + +
+
+
+ ( + + Name + + + + + + )} + /> + ( + + Description + + + + + + )} + /> +
+ + {hasMcpBinding && ( + + )} +
+
+ + {hasAnyChanges && ( + + )} +
+
+
+
+ + {/* Main page */} + +
+ setSettingsOpen(true)} + onDisconnect={handleDisconnect} /> -
- - {/* Right side - Tabs + Content */} -
- {/* Tabs header */} -
- -
- - {/* Tab content */}
- - - -
- } - > - {activeTabId === "tools" ? ( - - ) : activeTabId === "ui" ? ( - - ) : activeTabId === "prompts" ? ( - - ) : activeTabId === "resources" ? ( - - ) : activeTabId === "settings" ? ( - handleTabChange("readme") - : undefined - } - /> - ) : activeTabId === "readme" && hasRepository ? ( - - ) : activeCollection && isMCPAuthenticated ? ( - - ) : ( - - )} - - +
+ {/* Left column */} +
+ + +
+ {/* Right column */} +
+ + setSettingsOpen(true)} + /> +
+
-
-
+ + ); } @@ -633,26 +589,15 @@ function ConnectionInspectorViewContent() { }); const { org: projectOrg } = useProjectContext(); - // We can use search params for active tab if we want persistent tabs - const search = useSearch({ from: "/shell/$org/$project/mcps/$connectionId" }); - const requestedTabId = search.tab ?? ""; - const connection = useConnection(connectionId); const actions = useConnectionActions(); - // Detect collection bindings - const collections = useCollectionBindings(connection ?? undefined); - // Get MCP client for this connection (suspense-based) const client = useMCPClient({ connectionId, orgId: projectOrg.id, }); - // Fetch prompts and resources using SDK hooks - const { data: promptsData } = useMCPPromptsListQuery({ client }); - const { data: resourcesData } = useMCPResourcesListQuery({ client }); - // Fetch tools - uses cached if available, otherwise fetches dynamically // VIRTUAL connections always fetch dynamically because: // 1. Their tools column contains virtual tool definitions (code), not cached downstream tools @@ -660,20 +605,11 @@ function ConnectionInspectorViewContent() { const isVirtualConnection = connection?.connection_type === "VIRTUAL"; const hasCachedTools = !isVirtualConnection && connection?.tools && connection.tools.length > 0; - const { data: toolsData, isLoading: isLoadingTools } = useMCPToolsListQuery({ + const { data: toolsData } = useMCPToolsListQuery({ client, enabled: !hasCachedTools, }); - const prompts = (promptsData?.prompts ?? []).map((p) => ({ - name: p.name, - description: p.description, - })); - const resources = (resourcesData?.resources ?? []).map((r) => ({ - uri: r.uri, - name: r.name, - description: r.description, - })); const tools = hasCachedTools ? (connection.tools ?? []) : (toolsData?.tools ?? []).map((t) => ({ @@ -684,6 +620,21 @@ function ConnectionInspectorViewContent() { _meta: t._meta as Record | undefined, })); + // Fetch prompts and resources from the MCP connection + const { data: promptsData } = useMCPPromptsListQuery({ client }); + const { data: resourcesData } = useMCPResourcesListQuery({ client }); + + const prompts = (promptsData?.prompts ?? []).map((p) => ({ + name: p.name, + description: p.description, + })); + + const resources = (resourcesData?.resources ?? []).map((r) => ({ + name: r.name, + description: r.description, + uri: r.uri, + })); + // Update connection handler const handleUpdateConnection = async ( updatedConnection: Partial, @@ -726,14 +677,11 @@ function ConnectionInspectorViewContent() { org={org} connection={connection} connectionId={connectionId} - requestedTabId={requestedTabId} - collections={collections} onUpdate={handleUpdateConnection} isUpdating={actions.update.isPending} + tools={tools} prompts={prompts} resources={resources} - tools={tools} - isLoadingTools={isLoadingTools} /> ); } diff --git a/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx b/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx index a46295c8b5..4f9380d866 100644 --- a/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx +++ b/apps/mesh/src/web/components/details/connection/settings-tab/index.tsx @@ -1,4 +1,3 @@ -import { EmptyState } from "@/web/components/empty-state.tsx"; import { ErrorBoundary } from "@/web/components/error-boundary.tsx"; import { useMCPClient, @@ -165,20 +164,11 @@ function McpConfigurationContent({ if (!hasProperties) { return ( -
-