mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp-registry): redesign MCP tab with official registry as primary source (#3480)
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Top-level MCP Servers tab component.
|
||||
* Two-pane layout: left = InstalledServerList + browse button,
|
||||
* right = selected server detail OR catalog browser OR install dialog.
|
||||
* Polls `status` every 5s while any server is connected.
|
||||
* Top-level MCP Servers tab.
|
||||
*
|
||||
* Unified table view: shows both installed servers and registry catalog
|
||||
* results in a single table. Filter chips at the top let users toggle
|
||||
* between "All", "Installed", and "Registry" views.
|
||||
*/
|
||||
import debug from 'debug';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
@@ -11,74 +12,111 @@ import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import { mcpClientsApi } from '../../../services/api/mcpClientsApi';
|
||||
import InstallDialog from './InstallDialog';
|
||||
import InstalledServerDetail from './InstalledServerDetail';
|
||||
import InstalledServerList from './InstalledServerList';
|
||||
import McpCatalogBrowser from './McpCatalogBrowser';
|
||||
import McpConnectionHealthToolbar from './McpConnectionHealthToolbar';
|
||||
import McpInventoryPanel from './McpInventoryPanel';
|
||||
import McpServerSearch from './McpServerSearch';
|
||||
import type { ConnStatus, InstalledServer } from './types';
|
||||
import type { ConnStatus, InstalledServer, ServerStatus, SmitheryServer } from './types';
|
||||
|
||||
const log = debug('mcp-clients:tab');
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const DEBOUNCE_MS = 300;
|
||||
const PAGE_SIZE = 30;
|
||||
|
||||
type RightPane =
|
||||
| { mode: 'none' }
|
||||
type View =
|
||||
| { mode: 'home' }
|
||||
| { mode: 'detail'; serverId: string }
|
||||
| { mode: 'catalog' }
|
||||
| { mode: 'install'; qualifiedName: string; prefillEnv?: Record<string, string> };
|
||||
|
||||
type FilterChip = 'all' | 'installed' | 'registry';
|
||||
|
||||
const STATUS_DOT: Record<ServerStatus, string> = {
|
||||
connected: 'bg-sage-500',
|
||||
connecting: 'bg-amber-400',
|
||||
disconnected: 'bg-stone-300 dark:bg-neutral-600',
|
||||
error: 'bg-coral-500',
|
||||
disabled: 'bg-stone-200 dark:bg-neutral-700',
|
||||
};
|
||||
|
||||
const McpServersTab = () => {
|
||||
const { t } = useT();
|
||||
const [servers, setServers] = useState<InstalledServer[]>([]);
|
||||
const [statuses, setStatuses] = useState<ConnStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [rightPane, setRightPane] = useState<RightPane>({ mode: 'none' });
|
||||
// Local-only filter for the installed-server list. Not persisted — the
|
||||
// search is a transient scan helper, not a saved view.
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
// Sharable Inventory modal toggle. Local state — the manifest UX is
|
||||
// a one-off interaction, not a saved view.
|
||||
const [view, setView] = useState<View>({ mode: 'home' });
|
||||
const [inventoryOpen, setInventoryOpen] = useState(false);
|
||||
|
||||
// Unified search + filter
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeChip, setActiveChip] = useState<FilterChip>('all');
|
||||
|
||||
// Registry catalog results
|
||||
const [catalogServers, setCatalogServers] = useState<SmitheryServer[]>([]);
|
||||
const [catalogLoading, setCatalogLoading] = useState(false);
|
||||
const [catalogPage, setCatalogPage] = useState(1);
|
||||
const [catalogTotalPages, setCatalogTotalPages] = useState(1);
|
||||
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const requestSeqRef = useRef(0);
|
||||
|
||||
const loadInstalled = useCallback(async () => {
|
||||
log('loading installed servers');
|
||||
try {
|
||||
const installed = await mcpClientsApi.installedList();
|
||||
// Defensive: API contract guarantees an array, but if a future regression
|
||||
// or malformed envelope returns `undefined`, downstream `.find` crashes
|
||||
// the entire tab. Normalise here.
|
||||
setServers(Array.isArray(installed) ? installed : []);
|
||||
// Clear any previous error on successful reload.
|
||||
setLoadError(null);
|
||||
log('loaded %d installed servers', installed.length);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load installed servers';
|
||||
log('load error: %s', msg);
|
||||
setLoadError(msg);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchStatuses = useCallback(async () => {
|
||||
log('polling statuses');
|
||||
try {
|
||||
const sv = await mcpClientsApi.status();
|
||||
// Defensive: same reasoning as `loadInstalled` — `.find` / `.map`
|
||||
// downstream cannot tolerate an undefined array.
|
||||
setStatuses(Array.isArray(sv) ? sv : []);
|
||||
} catch (err) {
|
||||
log('status poll error: %o', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initial load — `loading` starts as `true` so no synchronous setState
|
||||
// before the async work is needed; just kick off the loads and clear on done.
|
||||
const fetchCatalog = useCallback(async (query: string, page: number, append: boolean) => {
|
||||
const seq = ++requestSeqRef.current;
|
||||
setCatalogLoading(true);
|
||||
try {
|
||||
const result = await mcpClientsApi.registrySearch({
|
||||
query: query || undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
});
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
const incoming = result.servers ?? [];
|
||||
setCatalogServers(prev => (append ? [...prev, ...incoming] : incoming));
|
||||
setCatalogPage(result.page);
|
||||
setCatalogTotalPages(result.total_pages);
|
||||
} catch (err) {
|
||||
if (seq !== requestSeqRef.current) return;
|
||||
log('catalog fetch error: %o', err);
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current) setCatalogLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([loadInstalled(), fetchStatuses()]).finally(() => setLoading(false));
|
||||
}, [loadInstalled, fetchStatuses]);
|
||||
|
||||
// Poll status every 5s while at least one server is connected.
|
||||
// Fetch catalog on mount and when search changes
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void fetchCatalog(searchQuery, 1, false);
|
||||
}, DEBOUNCE_MS);
|
||||
return () => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
};
|
||||
}, [searchQuery, fetchCatalog]);
|
||||
|
||||
// Poll status
|
||||
useEffect(() => {
|
||||
const hasConnected = statuses.some(s => s.status === 'connected');
|
||||
if (!hasConnected) {
|
||||
@@ -88,7 +126,6 @@ const McpServersTab = () => {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
pollTimerRef.current = setTimeout(async () => {
|
||||
await fetchStatuses();
|
||||
@@ -96,7 +133,6 @@ const McpServersTab = () => {
|
||||
}, POLL_INTERVAL_MS);
|
||||
};
|
||||
schedule();
|
||||
|
||||
return () => {
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
@@ -106,99 +142,64 @@ const McpServersTab = () => {
|
||||
}, [statuses, fetchStatuses]);
|
||||
|
||||
const handleSelectServer = useCallback((serverId: string) => {
|
||||
log('selected server_id=%s', serverId);
|
||||
setRightPane({ mode: 'detail', serverId });
|
||||
}, []);
|
||||
|
||||
const handleBrowseCatalog = useCallback(() => {
|
||||
log('opening catalog browser');
|
||||
setRightPane({ mode: 'catalog' });
|
||||
setView({ mode: 'detail', serverId });
|
||||
}, []);
|
||||
|
||||
const handleSelectInstall = useCallback((qualifiedName: string) => {
|
||||
log('opening install dialog for %s', qualifiedName);
|
||||
setRightPane({ mode: 'install', qualifiedName });
|
||||
setView({ mode: 'install', qualifiedName });
|
||||
}, []);
|
||||
|
||||
const handleInstallSuccess = useCallback(
|
||||
async (server: InstalledServer) => {
|
||||
log('install success server_id=%s, refreshing list', server.server_id);
|
||||
await loadInstalled();
|
||||
await fetchStatuses();
|
||||
setRightPane({ mode: 'detail', serverId: server.server_id });
|
||||
setView({ mode: 'detail', serverId: server.server_id });
|
||||
},
|
||||
[loadInstalled, fetchStatuses]
|
||||
);
|
||||
|
||||
const handleUninstalled = useCallback(
|
||||
async (serverId: string) => {
|
||||
log('uninstalled server_id=%s', serverId);
|
||||
async (_serverId: string) => {
|
||||
await loadInstalled();
|
||||
await fetchStatuses();
|
||||
setRightPane({ mode: 'none' });
|
||||
setView({ mode: 'home' });
|
||||
},
|
||||
[loadInstalled, fetchStatuses]
|
||||
);
|
||||
|
||||
const handleEnabledChange = useCallback(
|
||||
async (_serverId: string, _enabled: boolean) => {
|
||||
log('enabled_change server_id=%s enabled=%s', _serverId, _enabled);
|
||||
await loadInstalled();
|
||||
await fetchStatuses();
|
||||
},
|
||||
[loadInstalled, fetchStatuses]
|
||||
);
|
||||
|
||||
// Count rejected settlements and, if any, throw a descriptive error so the
|
||||
// toolbar surfaces it through its `role="alert"` region — otherwise a bulk
|
||||
// action that partially (or wholly) fails looks identical to success and
|
||||
// the user is left re-scanning the status dots. The status refresh still
|
||||
// runs first so the dots reconcile regardless of the partial failure.
|
||||
const reportBulkFailures = useCallback(
|
||||
(results: PromiseSettledResult<unknown>[], total: number) => {
|
||||
const failed = results.filter(r => r.status === 'rejected').length;
|
||||
if (failed > 0) {
|
||||
log('bulk op partial failure: %d/%d failed', failed, total);
|
||||
throw new Error(
|
||||
t('mcp.health.bulkPartialFailure')
|
||||
.replace('{failed}', String(failed))
|
||||
.replace('{total}', String(total))
|
||||
);
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
const handleLoadMore = () => {
|
||||
void fetchCatalog(searchQuery, catalogPage + 1, true);
|
||||
};
|
||||
|
||||
// Bulk Retry — iterate through errored servers, collect per-server outcomes
|
||||
// via `Promise.allSettled` so one bad apple doesn't abort the batch, then
|
||||
// refresh statuses once at the end. The toolbar shows its own disabled state
|
||||
// during the await; the next poll tick reconciles any drift. Partial/total
|
||||
// failures are surfaced via `reportBulkFailures`.
|
||||
const handleBulkReconnect = useCallback(
|
||||
async (serverIds: string[]) => {
|
||||
log('bulk reconnect ids=%o', serverIds);
|
||||
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.connect(id)));
|
||||
await fetchStatuses();
|
||||
reportBulkFailures(results, serverIds.length);
|
||||
},
|
||||
[fetchStatuses, reportBulkFailures]
|
||||
);
|
||||
const selectedServer =
|
||||
view.mode === 'detail' ? (servers.find(s => s.server_id === view.serverId) ?? null) : null;
|
||||
const selectedConnStatus =
|
||||
view.mode === 'detail' ? statuses.find(s => s.server_id === view.serverId) : undefined;
|
||||
|
||||
// Bulk Disconnect — same shape as bulk reconnect. The toolbar gates this
|
||||
// behind a confirmation dialog before we get here.
|
||||
const handleBulkDisconnect = useCallback(
|
||||
async (serverIds: string[]) => {
|
||||
log('bulk disconnect ids=%o', serverIds);
|
||||
const results = await Promise.allSettled(serverIds.map(id => mcpClientsApi.disconnect(id)));
|
||||
await fetchStatuses();
|
||||
reportBulkFailures(results, serverIds.length);
|
||||
},
|
||||
[fetchStatuses, reportBulkFailures]
|
||||
);
|
||||
// Filter installed servers by search
|
||||
const filteredInstalled = servers.filter(s => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
s.display_name.toLowerCase().includes(q) ||
|
||||
s.qualified_name.toLowerCase().includes(q) ||
|
||||
(s.description ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const selectedServerId = rightPane.mode === 'detail' ? rightPane.serverId : null;
|
||||
const selectedServer = servers.find(s => s.server_id === selectedServerId) ?? null;
|
||||
const selectedConnStatus = statuses.find(s => s.server_id === selectedServerId);
|
||||
// Filter out catalog servers already installed
|
||||
const installedNames = new Set(servers.map(s => s.qualified_name));
|
||||
const filteredCatalog = catalogServers.filter(s => !installedNames.has(s.qualified_name));
|
||||
|
||||
const statusMap = new Map(statuses.map(s => [s.server_id, s]));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -208,92 +209,279 @@ const McpServersTab = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Detail view
|
||||
if (view.mode === 'detail' && selectedServer) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ mode: 'home' })}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('mcp.install.back')}
|
||||
</button>
|
||||
<InstalledServerDetail
|
||||
server={selectedServer}
|
||||
connStatus={selectedConnStatus}
|
||||
onUninstalled={serverId => void handleUninstalled(serverId)}
|
||||
onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Install view
|
||||
if (view.mode === 'install') {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setView({ mode: 'home' })}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hover:text-stone-700 dark:hover:text-neutral-200 transition-colors">
|
||||
<svg
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('mcp.install.back')}
|
||||
</button>
|
||||
<InstallDialog
|
||||
qualifiedName={view.qualifiedName}
|
||||
prefillEnv={view.prefillEnv}
|
||||
onSuccess={server => void handleInstallSuccess(server)}
|
||||
onCancel={() => setView({ mode: 'home' })}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Home view — unified table
|
||||
return (
|
||||
<div className="flex flex-col gap-3 h-full min-h-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
role="status"
|
||||
className="flex-1 flex items-start gap-2 rounded-lg border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider bg-amber-200/70 dark:bg-amber-500/30 text-amber-900 dark:text-amber-100 shrink-0 mt-0.5">
|
||||
{t('mcp.alphaBadge')}
|
||||
</span>
|
||||
<span className="leading-relaxed">{t('mcp.alphaBannerText')}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* Search + filter chips */}
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="search"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('mcp.catalog.searchPlaceholder')}
|
||||
aria-label={t('mcp.catalog.searchAria')}
|
||||
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-800 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500/40"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setInventoryOpen(true)}
|
||||
aria-label={t('mcp.inventory.openAria')}
|
||||
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-2 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
|
||||
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-2 text-xs font-medium text-stone-600 dark:text-neutral-300 hover:bg-stone-50 dark:hover:bg-neutral-800">
|
||||
{t('mcp.inventory.openButton')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-1 min-h-0">
|
||||
{/* Left pane: health toolbar + search + installed list */}
|
||||
<div className="w-56 shrink-0 flex flex-col">
|
||||
{loadError && (
|
||||
<div className="mb-2 rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={statuses}
|
||||
onReconnect={handleBulkReconnect}
|
||||
onDisconnect={handleBulkDisconnect}
|
||||
/>
|
||||
{servers.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<McpServerSearch value={searchFilter} onChange={setSearchFilter} />
|
||||
</div>
|
||||
)}
|
||||
<InstalledServerList
|
||||
servers={servers}
|
||||
statuses={statuses}
|
||||
selectedId={selectedServerId}
|
||||
onSelect={handleSelectServer}
|
||||
onBrowseCatalog={handleBrowseCatalog}
|
||||
filter={searchFilter}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right pane */}
|
||||
<div className="flex-1 min-w-0 overflow-y-auto">
|
||||
{rightPane.mode === 'none' && (
|
||||
<div className="h-full flex items-center justify-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('mcp.tab.emptyDetail')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'catalog' && (
|
||||
<McpCatalogBrowser onSelectInstall={handleSelectInstall} />
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'install' && (
|
||||
<InstallDialog
|
||||
qualifiedName={rightPane.qualifiedName}
|
||||
prefillEnv={rightPane.prefillEnv}
|
||||
onSuccess={server => void handleInstallSuccess(server)}
|
||||
onCancel={() => setRightPane({ mode: 'catalog' })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rightPane.mode === 'detail' && selectedServer && (
|
||||
<InstalledServerDetail
|
||||
server={selectedServer}
|
||||
connStatus={selectedConnStatus}
|
||||
onUninstalled={serverId => void handleUninstalled(serverId)}
|
||||
onEnabledChange={(serverId, enabled) => void handleEnabledChange(serverId, enabled)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Filter chips */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(['all', 'installed', 'registry'] as FilterChip[]).map(chip => (
|
||||
<button
|
||||
key={chip}
|
||||
type="button"
|
||||
onClick={() => setActiveChip(chip)}
|
||||
className={`rounded-full px-3 py-1 text-xs font-medium transition-colors ${
|
||||
activeChip === chip
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-stone-100 dark:bg-neutral-800 text-stone-600 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700'
|
||||
}`}>
|
||||
{chip === 'all' && t('mcp.tab.filter.all')}
|
||||
{chip === 'installed' &&
|
||||
t('mcp.tab.filter.installed').replace('{count}', String(filteredInstalled.length))}
|
||||
{chip === 'registry' && t('mcp.tab.filter.registry')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loadError && (
|
||||
<div className="rounded-lg border border-coral-200 dark:border-coral-500/30 bg-coral-50 dark:bg-coral-500/10 px-3 py-2 text-xs text-coral-700 dark:text-coral-300">
|
||||
{loadError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-stone-200 dark:border-neutral-800 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-100 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-900">
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400">
|
||||
{t('mcp.tab.column.name')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 hidden sm:table-cell">
|
||||
{t('mcp.tab.column.description')}
|
||||
</th>
|
||||
<th className="text-left px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 w-24">
|
||||
{t('mcp.tab.column.source')}
|
||||
</th>
|
||||
<th className="text-right px-4 py-2.5 text-xs font-medium text-stone-500 dark:text-neutral-400 w-28">
|
||||
{t('mcp.tab.column.action')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-100 dark:divide-neutral-800">
|
||||
{/* Installed servers */}
|
||||
{(activeChip === 'all' || activeChip === 'installed') &&
|
||||
filteredInstalled.map(server => {
|
||||
const status: ServerStatus =
|
||||
statusMap.get(server.server_id)?.status ?? 'disconnected';
|
||||
return (
|
||||
<tr
|
||||
key={`installed-${server.server_id}`}
|
||||
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 cursor-pointer transition-colors"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={t('mcp.tab.aria.viewDetails').replace(
|
||||
'{name}',
|
||||
server.display_name
|
||||
)}
|
||||
onClick={() => handleSelectServer(server.server_id)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelectServer(server.server_id);
|
||||
}
|
||||
}}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${STATUS_DOT[status]}`}
|
||||
title={status}
|
||||
/>
|
||||
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
|
||||
{server.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-stone-500 dark:text-neutral-400 line-clamp-1 text-xs">
|
||||
{server.description ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-sage-100 dark:bg-sage-500/15 text-sage-700 dark:text-sage-300 border border-sage-200 dark:border-sage-500/30">
|
||||
{t('mcp.tab.badge.installed')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<span className="text-xs text-primary-600 dark:text-primary-400 font-medium">
|
||||
{t('mcp.tab.action.manage')}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Registry servers */}
|
||||
{(activeChip === 'all' || activeChip === 'registry') &&
|
||||
filteredCatalog.map(server => (
|
||||
<tr
|
||||
key={`catalog-${server.qualified_name}`}
|
||||
className="hover:bg-stone-50 dark:hover:bg-neutral-800/40 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{server.icon_url ? (
|
||||
<img
|
||||
src={server.icon_url}
|
||||
alt=""
|
||||
className="w-5 h-5 rounded shrink-0 object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="w-5 h-5 rounded shrink-0 bg-primary-100 dark:bg-primary-500/20 flex items-center justify-center text-[10px]">
|
||||
🔌
|
||||
</span>
|
||||
)}
|
||||
<span className="font-medium text-stone-900 dark:text-neutral-100 truncate">
|
||||
{server.display_name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 hidden sm:table-cell">
|
||||
<span className="text-stone-500 dark:text-neutral-400 line-clamp-1 text-xs">
|
||||
{server.description ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold bg-primary-50 dark:bg-primary-500/15 text-primary-700 dark:text-primary-300 border border-primary-200 dark:border-primary-500/30">
|
||||
{t('mcp.tab.badge.registry')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectInstall(server.qualified_name)}
|
||||
className="rounded-lg bg-primary-500 px-3 py-1 text-xs font-medium text-white hover:bg-primary-600 transition-colors">
|
||||
{t('mcp.install.button')}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* Empty states */}
|
||||
{activeChip === 'installed' && filteredInstalled.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{t('mcp.installed.empty')}
|
||||
</div>
|
||||
)}
|
||||
{activeChip === 'registry' && filteredCatalog.length === 0 && !catalogLoading && (
|
||||
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{searchQuery
|
||||
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
|
||||
: t('mcp.catalog.noResults')}
|
||||
</div>
|
||||
)}
|
||||
{activeChip === 'all' &&
|
||||
filteredInstalled.length === 0 &&
|
||||
filteredCatalog.length === 0 &&
|
||||
!catalogLoading && (
|
||||
<div className="py-8 text-center text-sm text-stone-400 dark:text-neutral-500">
|
||||
{searchQuery
|
||||
? t('mcp.catalog.noResultsFor').replace('{query}', searchQuery)
|
||||
: t('mcp.catalog.noResults')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading / load more */}
|
||||
{catalogLoading && (
|
||||
<div className="py-4 text-center text-xs text-stone-400 dark:text-neutral-500">
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
{!catalogLoading &&
|
||||
catalogPage < catalogTotalPages &&
|
||||
(activeChip === 'all' || activeChip === 'registry') && (
|
||||
<div className="py-3 text-center border-t border-stone-100 dark:border-neutral-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
className="text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline">
|
||||
{t('mcp.catalog.loadMore')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inventoryOpen && (
|
||||
<McpInventoryPanel
|
||||
servers={servers}
|
||||
onInstallServer={(qualifiedName, prefillEnv) => {
|
||||
// Hand the entry off to the existing install-dialog flow.
|
||||
// The panel closes itself; here we open the dialog with the
|
||||
// env keys pre-populated so the user only has to fill values.
|
||||
setRightPane({ mode: 'install', qualifiedName, prefillEnv });
|
||||
setInventoryOpen(false);
|
||||
setView({ mode: 'install', qualifiedName, prefillEnv });
|
||||
}}
|
||||
onClose={() => setInventoryOpen(false)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user