mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(mcp): connection health toolbar with bulk Retry All / Disconnect All actions (#2641)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Claude Opus 4.7
Steven Enamakel
parent
5a5cd43ea9
commit
32d03ca6aa
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* Tests for McpConnectionHealthToolbar — aggregate status counts +
|
||||
* Retry All / Disconnect All bulk-action surface with confirmation
|
||||
* dialog.
|
||||
*/
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import McpConnectionHealthToolbar from './McpConnectionHealthToolbar';
|
||||
import type { ConnStatus, ServerStatus } from './types';
|
||||
|
||||
const statusFor = (server_id: string, status: ServerStatus): ConnStatus => ({
|
||||
server_id,
|
||||
qualified_name: `acme/${server_id}`,
|
||||
display_name: server_id,
|
||||
status,
|
||||
tool_count: status === 'connected' ? 3 : 0,
|
||||
});
|
||||
|
||||
describe('McpConnectionHealthToolbar', () => {
|
||||
it('renders nothing when statuses array is empty', () => {
|
||||
const { container } = render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('always shows connected + disconnected counts (even when zero)', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'disconnected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('0 connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 idle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('only shows connecting count when there are connecting servers', () => {
|
||||
const { rerender } = render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected'), statusFor('b', 'error')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(/connecting/)).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected'), statusFor('b', 'connecting')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('1 connecting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('only shows error count when there are errored servers', () => {
|
||||
const { rerender } = render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText(/error/)).not.toBeInTheDocument();
|
||||
rerender(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected'), statusFor('b', 'error')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('1 error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('aggregates counts correctly across a mixed status set', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[
|
||||
statusFor('a', 'connected'),
|
||||
statusFor('b', 'connected'),
|
||||
statusFor('c', 'error'),
|
||||
statusFor('d', 'connecting'),
|
||||
statusFor('e', 'disconnected'),
|
||||
statusFor('f', 'disconnected'),
|
||||
]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText('2 connected')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 connecting')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 error')).toBeInTheDocument();
|
||||
expect(screen.getByText('2 idle')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Retry all" button when there are no errors', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected'), statusFor('b', 'disconnected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Retry all/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides "Disconnect all" button when nothing is connected', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'error'), statusFor('b', 'disconnected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: /Disconnect all/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Retry all (N)" button with the correct error count when errors exist', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'error'), statusFor('b', 'error'), statusFor('c', 'connected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Retry all 2 errored MCP servers' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Retry all (2)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onReconnect with the errored server IDs when "Retry all" is clicked', async () => {
|
||||
const onReconnect = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[
|
||||
statusFor('srv-1', 'error'),
|
||||
statusFor('srv-2', 'connected'),
|
||||
statusFor('srv-3', 'error'),
|
||||
]}
|
||||
onReconnect={onReconnect}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry all/i }));
|
||||
});
|
||||
expect(onReconnect).toHaveBeenCalledTimes(1);
|
||||
expect(onReconnect).toHaveBeenCalledWith(['srv-1', 'srv-3']);
|
||||
});
|
||||
|
||||
it('does NOT call onDisconnect directly — opens confirm dialog first', () => {
|
||||
const onDisconnect = vi.fn();
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Disconnect all/i }));
|
||||
expect(onDisconnect).not.toHaveBeenCalled();
|
||||
// Confirm dialog appears with accessible structure
|
||||
const dialog = screen.getByRole('dialog');
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||
expect(screen.getByText('Disconnect all MCP servers?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('cancel in the dialog closes it without calling onDisconnect', () => {
|
||||
const onDisconnect = vi.fn();
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /Disconnect all \d+ connected MCP servers/i })
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onDisconnect).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Escape closes the confirm dialog without calling onDisconnect', () => {
|
||||
const onDisconnect = vi.fn();
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /Disconnect all \d+ connected MCP servers/i })
|
||||
);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
act(() => {
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
});
|
||||
expect(onDisconnect).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('confirm in the dialog fires onDisconnect with connected IDs and closes the dialog', async () => {
|
||||
const onDisconnect = vi.fn().mockResolvedValue(undefined);
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[
|
||||
statusFor('srv-1', 'connected'),
|
||||
statusFor('srv-2', 'error'),
|
||||
statusFor('srv-3', 'connected'),
|
||||
]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={onDisconnect}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /Disconnect all \d+ connected MCP servers/i })
|
||||
);
|
||||
// Confirm button inside dialog
|
||||
const dialogConfirm = screen.getAllByRole('button', { name: 'Disconnect all' })[0];
|
||||
await act(async () => {
|
||||
fireEvent.click(dialogConfirm);
|
||||
});
|
||||
expect(onDisconnect).toHaveBeenCalledTimes(1);
|
||||
expect(onDisconnect).toHaveBeenCalledWith(['srv-1', 'srv-3']);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables both action buttons while a bulk operation is pending', async () => {
|
||||
let resolveOp: (() => void) | undefined;
|
||||
const onReconnect = vi.fn(
|
||||
() =>
|
||||
new Promise<void>(res => {
|
||||
resolveOp = res;
|
||||
})
|
||||
);
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'error'), statusFor('b', 'connected')]}
|
||||
onReconnect={onReconnect}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry all/i }));
|
||||
// While the promise is pending, both buttons should be disabled.
|
||||
expect(screen.getByRole('button', { name: /Retry all/i })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Disconnect all \d+ connected MCP servers/i })
|
||||
).toBeDisabled();
|
||||
// Resolve and re-render — buttons re-enable.
|
||||
await act(async () => {
|
||||
resolveOp?.();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /Retry all/i })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('surfaces a thrown error from onReconnect via role="alert"', async () => {
|
||||
const onReconnect = vi.fn().mockRejectedValue(new Error('upstream RPC died'));
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'error')]}
|
||||
onReconnect={onReconnect}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry all/i }));
|
||||
});
|
||||
const alert = screen.getByRole('alert');
|
||||
expect(alert).toHaveTextContent('upstream RPC died');
|
||||
});
|
||||
|
||||
it('falls back to a generic error message when the thrown value is not an Error instance', async () => {
|
||||
const onReconnect = vi.fn().mockRejectedValue('not-an-error-object');
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'error')]}
|
||||
onReconnect={onReconnect}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Retry all/i }));
|
||||
});
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Bulk operation failed. See logs.');
|
||||
});
|
||||
|
||||
it('the summary region is a polite live region with an accessible label', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[statusFor('a', 'connected')]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
const status = screen.getByRole('status', { name: 'MCP connection health summary' });
|
||||
expect(status).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('confirm dialog body interpolates the connected count', () => {
|
||||
render(
|
||||
<McpConnectionHealthToolbar
|
||||
statuses={[
|
||||
statusFor('a', 'connected'),
|
||||
statusFor('b', 'connected'),
|
||||
statusFor('c', 'connected'),
|
||||
]}
|
||||
onReconnect={async () => {}}
|
||||
onDisconnect={async () => {}}
|
||||
/>
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /Disconnect all \d+ connected MCP servers/i })
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/This will disconnect 3 currently-connected MCP servers/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Aggregate health summary + bulk actions for the MCP server list.
|
||||
*
|
||||
* Lives in the left pane of `McpServersTab` above the list. Reads the
|
||||
* polled `statuses` array (no extra fetches) and surfaces:
|
||||
*
|
||||
* - Live counts per state (connected / connecting / error / disconnected),
|
||||
* announced through a `role="status" aria-live="polite"` region so
|
||||
* screen readers hear updates as the polling loop refreshes.
|
||||
* - `Retry all` button — visible only when there are servers in error
|
||||
* state; iterates through them and calls `onReconnect` once.
|
||||
* - `Disconnect all` button — visible only when there are connected
|
||||
* servers; opens a confirm dialog (`role="dialog" aria-modal`) before
|
||||
* firing `onDisconnect`.
|
||||
*
|
||||
* Parent (`McpServersTab`) owns the actual `mcpClientsApi` calls and the
|
||||
* subsequent `fetchStatuses()` refresh; this component only orchestrates
|
||||
* the user intent.
|
||||
*/
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { useT } from '../../../lib/i18n/I18nContext';
|
||||
import type { ConnStatus } from './types';
|
||||
|
||||
interface McpConnectionHealthToolbarProps {
|
||||
statuses: ConnStatus[];
|
||||
/** Reconnect every server in error state. Caller resolves after refresh. */
|
||||
onReconnect: (serverIds: string[]) => Promise<void>;
|
||||
/** Disconnect every currently-connected server. Caller resolves after refresh. */
|
||||
onDisconnect: (serverIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
interface HealthCounts {
|
||||
connectedIds: string[];
|
||||
errorIds: string[];
|
||||
connectedCount: number;
|
||||
connectingCount: number;
|
||||
errorCount: number;
|
||||
disconnectedCount: number;
|
||||
}
|
||||
|
||||
const computeHealthCounts = (statuses: ConnStatus[]): HealthCounts => {
|
||||
const connectedIds: string[] = [];
|
||||
const errorIds: string[] = [];
|
||||
let connectingCount = 0;
|
||||
let disconnectedCount = 0;
|
||||
for (const s of statuses) {
|
||||
switch (s.status) {
|
||||
case 'connected':
|
||||
connectedIds.push(s.server_id);
|
||||
break;
|
||||
case 'error':
|
||||
errorIds.push(s.server_id);
|
||||
break;
|
||||
case 'connecting':
|
||||
connectingCount += 1;
|
||||
break;
|
||||
case 'disconnected':
|
||||
disconnectedCount += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {
|
||||
connectedIds,
|
||||
errorIds,
|
||||
connectedCount: connectedIds.length,
|
||||
connectingCount,
|
||||
errorCount: errorIds.length,
|
||||
disconnectedCount,
|
||||
};
|
||||
};
|
||||
|
||||
const McpConnectionHealthToolbar = ({
|
||||
statuses,
|
||||
onReconnect,
|
||||
onDisconnect,
|
||||
}: McpConnectionHealthToolbarProps) => {
|
||||
const { t } = useT();
|
||||
const [isOperating, setIsOperating] = useState(false);
|
||||
const [confirmDisconnect, setConfirmDisconnect] = useState(false);
|
||||
const [opError, setOpError] = useState<string | null>(null);
|
||||
|
||||
const counts = useMemo(() => computeHealthCounts(statuses), [statuses]);
|
||||
|
||||
// Escape closes the "Disconnect all" confirmation WITHOUT firing the bulk
|
||||
// RPC — the standard modal-dismiss affordance, matching the other MCP
|
||||
// dialogs. The listener is only attached while the dialog is open. (Must
|
||||
// be declared before the early return below to satisfy the rules of hooks.)
|
||||
useEffect(() => {
|
||||
if (!confirmDisconnect) return;
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setConfirmDisconnect(false);
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [confirmDisconnect]);
|
||||
|
||||
// Nothing to summarise — match the parent's existing "hide chrome when
|
||||
// there's nothing installed" pattern.
|
||||
if (statuses.length === 0) return null;
|
||||
|
||||
const runRetryAll = async () => {
|
||||
if (counts.errorIds.length === 0 || isOperating) return;
|
||||
setIsOperating(true);
|
||||
setOpError(null);
|
||||
try {
|
||||
await onReconnect(counts.errorIds);
|
||||
} catch (err) {
|
||||
setOpError(err instanceof Error ? err.message : t('mcp.health.opErrorGeneric'));
|
||||
} finally {
|
||||
setIsOperating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runDisconnectAll = async () => {
|
||||
if (counts.connectedIds.length === 0 || isOperating) return;
|
||||
setConfirmDisconnect(false);
|
||||
setIsOperating(true);
|
||||
setOpError(null);
|
||||
try {
|
||||
await onDisconnect(counts.connectedIds);
|
||||
} catch (err) {
|
||||
setOpError(err instanceof Error ? err.message : t('mcp.health.opErrorGeneric'));
|
||||
} finally {
|
||||
setIsOperating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-lg border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-2.5 py-2">
|
||||
<div className="flex items-center justify-between gap-2 mb-1.5">
|
||||
<span className="text-[10px] font-semibold text-stone-500 dark:text-neutral-400 uppercase tracking-wide">
|
||||
{t('mcp.health.title')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{counts.errorCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runRetryAll()}
|
||||
disabled={isOperating}
|
||||
aria-label={t('mcp.health.retryAllAria').replace(
|
||||
'{count}',
|
||||
String(counts.errorCount)
|
||||
)}
|
||||
className="text-[10px] font-medium text-amber-700 dark:text-amber-300 hover:underline disabled:opacity-50 disabled:no-underline">
|
||||
{t('mcp.health.retryAll').replace('{count}', String(counts.errorCount))}
|
||||
</button>
|
||||
)}
|
||||
{counts.connectedCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDisconnect(true)}
|
||||
disabled={isOperating}
|
||||
aria-label={t('mcp.health.disconnectAllAria').replace(
|
||||
'{count}',
|
||||
String(counts.connectedCount)
|
||||
)}
|
||||
className="text-[10px] font-medium text-stone-600 dark:text-neutral-300 hover:underline disabled:opacity-50 disabled:no-underline">
|
||||
{t('mcp.health.disconnectAll').replace('{count}', String(counts.connectedCount))}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={t('mcp.health.summaryAria')}
|
||||
className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[10px] text-stone-600 dark:text-neutral-300">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-sage-500" aria-hidden="true" />
|
||||
<span>
|
||||
{t('mcp.health.connectedCount').replace('{count}', String(counts.connectedCount))}
|
||||
</span>
|
||||
</span>
|
||||
{counts.connectingCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" aria-hidden="true" />
|
||||
<span>
|
||||
{t('mcp.health.connectingCount').replace('{count}', String(counts.connectingCount))}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{counts.errorCount > 0 && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-coral-500" aria-hidden="true" />
|
||||
<span>{t('mcp.health.errorCount').replace('{count}', String(counts.errorCount))}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full bg-stone-300 dark:bg-neutral-600"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span>
|
||||
{t('mcp.health.disconnectedCount').replace('{count}', String(counts.disconnectedCount))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{opError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="mt-1.5 text-[10px] text-coral-700 dark:text-coral-300 break-words">
|
||||
{opError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{confirmDisconnect && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mcp-disconnect-all-title"
|
||||
aria-describedby="mcp-disconnect-all-body"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl shadow-xl max-w-sm w-full p-4">
|
||||
<h2
|
||||
id="mcp-disconnect-all-title"
|
||||
className="text-sm font-semibold text-stone-900 dark:text-neutral-100 mb-2">
|
||||
{t('mcp.health.disconnectConfirm.title')}
|
||||
</h2>
|
||||
<p
|
||||
id="mcp-disconnect-all-body"
|
||||
className="text-xs text-stone-600 dark:text-neutral-300 mb-4">
|
||||
{t('mcp.health.disconnectConfirm.body').replace(
|
||||
'{count}',
|
||||
String(counts.connectedCount)
|
||||
)}
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDisconnect(false)}
|
||||
className="px-3 py-1.5 text-xs rounded-lg border border-stone-200 dark:border-neutral-700 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800">
|
||||
{t('mcp.health.disconnectConfirm.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void runDisconnectAll()}
|
||||
className="px-3 py-1.5 text-xs rounded-lg bg-coral-500 text-white hover:bg-coral-600">
|
||||
{t('mcp.health.disconnectConfirm.confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default McpConnectionHealthToolbar;
|
||||
@@ -66,6 +66,16 @@ const STATUSES_CONNECTED = [
|
||||
},
|
||||
];
|
||||
|
||||
const STATUSES_ERROR = [
|
||||
{
|
||||
server_id: 'srv-1',
|
||||
qualified_name: 'acme/fs-server',
|
||||
display_name: 'File Server',
|
||||
status: 'error' as const,
|
||||
tool_count: 0,
|
||||
},
|
||||
];
|
||||
|
||||
describe('McpServersTab', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
@@ -437,4 +447,51 @@ describe('McpServersTab', () => {
|
||||
expect(mockInstall).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a partial-failure alert when bulk Retry All has rejections', async () => {
|
||||
mockInstalledList.mockResolvedValue(SERVERS);
|
||||
mockStatus.mockResolvedValue(STATUSES_ERROR);
|
||||
// The single errored server fails to reconnect.
|
||||
mockConnect.mockRejectedValue(new Error('connect refused'));
|
||||
|
||||
render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
const retryBtn = await screen.findByRole('button', { name: 'Retry all 1 errored MCP servers' });
|
||||
await act(async () => {
|
||||
fireEvent.click(retryBtn);
|
||||
});
|
||||
|
||||
// allSettled never rejects, so the connect was attempted...
|
||||
await waitFor(() => expect(mockConnect).toHaveBeenCalledWith('srv-1'));
|
||||
// ...and the failure is surfaced through the alert region, not swallowed.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('1 of 1 servers failed. See logs.');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces a partial-failure alert when bulk Disconnect All has rejections', async () => {
|
||||
mockInstalledList.mockResolvedValue(SERVERS);
|
||||
mockStatus.mockResolvedValue(STATUSES_CONNECTED);
|
||||
mockDisconnect.mockRejectedValue(new Error('disconnect failed'));
|
||||
|
||||
render(<McpServersTab />);
|
||||
vi.useRealTimers();
|
||||
|
||||
const disconnectBtn = await screen.findByRole('button', {
|
||||
name: 'Disconnect all 1 connected MCP servers',
|
||||
});
|
||||
fireEvent.click(disconnectBtn);
|
||||
|
||||
// Confirm dialog gates the bulk RPC; confirm it.
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Disconnect all' });
|
||||
await act(async () => {
|
||||
fireEvent.click(confirmBtn);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(mockDisconnect).toHaveBeenCalledWith('srv-1'));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('1 of 1 servers failed. See logs.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ 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';
|
||||
@@ -139,6 +140,53 @@ const McpServersTab = () => {
|
||||
[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]
|
||||
);
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
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);
|
||||
@@ -171,13 +219,18 @@ const McpServersTab = () => {
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-4 flex-1 min-h-0">
|
||||
{/* Left pane: search + installed list */}
|
||||
{/* 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} />
|
||||
|
||||
@@ -1343,6 +1343,23 @@ const ar1: TranslationMap = {
|
||||
'mcp.installed.empty': 'لم يتم تثبيت خوادم MCP حتى الآن.',
|
||||
'mcp.installed.toolSingular': 'أداة {count}',
|
||||
'mcp.installed.toolPlural': 'أدوات {count}',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1352,6 +1352,23 @@ const bn1: TranslationMap = {
|
||||
'mcp.installed.empty': 'এখনো কোনো MCP সার্ভার ইনস্টল করা হয়নি।',
|
||||
'mcp.installed.toolSingular': '{count} টুল',
|
||||
'mcp.installed.toolPlural': '{count} টুল',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1370,6 +1370,23 @@ const de1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Noch keine MCP-Server installiert.',
|
||||
'mcp.installed.toolSingular': '{count}-Tool',
|
||||
'mcp.installed.toolPlural': '{count}-Tools',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1328,6 +1328,23 @@ const en1: TranslationMap = {
|
||||
'mcp.installed.empty': 'No MCP servers installed yet.',
|
||||
'mcp.installed.toolSingular': '{count} tool',
|
||||
'mcp.installed.toolPlural': '{count} tools',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1367,6 +1367,23 @@ const es1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Aún no hay servidores MCP instalados.',
|
||||
'mcp.installed.toolSingular': 'herramienta {count}',
|
||||
'mcp.installed.toolPlural': '{count} herramientas',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1372,6 +1372,23 @@ const fr1: TranslationMap = {
|
||||
'mcp.installed.empty': 'No MCP servers installed yet.',
|
||||
'mcp.installed.toolSingular': 'Outil {count}',
|
||||
'mcp.installed.toolPlural': 'Outils {count}',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1351,6 +1351,23 @@ const hi1: TranslationMap = {
|
||||
'mcp.installed.empty': 'अभी तक कोई MCP सर्वर स्थापित नहीं है।',
|
||||
'mcp.installed.toolSingular': '{count} उपकरण',
|
||||
'mcp.installed.toolPlural': '{count} उपकरण',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1355,6 +1355,23 @@ const id1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Belum ada server MCP yang terinstal.',
|
||||
'mcp.installed.toolSingular': '{count} alat',
|
||||
'mcp.installed.toolPlural': '{count} alat',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1360,6 +1360,23 @@ const it1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Nessun server MCP ancora installato.',
|
||||
'mcp.installed.toolSingular': '{count} strumento',
|
||||
'mcp.installed.toolPlural': '{count} strumenti',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1352,6 +1352,23 @@ const ko1: TranslationMap = {
|
||||
'mcp.installed.empty': '아직 MCP 서버가 설치되지 않았습니다.',
|
||||
'mcp.installed.toolSingular': '{count} 도구',
|
||||
'mcp.installed.toolPlural': '{count} 도구',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1365,6 +1365,23 @@ const pt1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Nenhum servidor MCP instalado ainda.',
|
||||
'mcp.installed.toolSingular': 'Ferramenta {count}',
|
||||
'mcp.installed.toolPlural': 'Ferramentas {count}',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1355,6 +1355,23 @@ const ru1: TranslationMap = {
|
||||
'mcp.installed.empty': 'Серверы MCP пока не установлены.',
|
||||
'mcp.installed.toolSingular': '{count} инструмент',
|
||||
'mcp.installed.toolPlural': '{count} инструменты',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -1336,6 +1336,23 @@ const zhCN1: TranslationMap = {
|
||||
'mcp.installed.empty': '尚未安装 MCP 服务器。',
|
||||
'mcp.installed.toolSingular': '{count} 工具',
|
||||
'mcp.installed.toolPlural': '{count} 工具',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
@@ -952,6 +952,23 @@ const en: TranslationMap = {
|
||||
'mcp.installed.empty': 'No MCP servers installed yet.',
|
||||
'mcp.installed.toolSingular': '{count} tool',
|
||||
'mcp.installed.toolPlural': '{count} tools',
|
||||
'mcp.health.title': 'Health',
|
||||
'mcp.health.summaryAria': 'MCP connection health summary',
|
||||
'mcp.health.connectedCount': '{count} connected',
|
||||
'mcp.health.connectingCount': '{count} connecting',
|
||||
'mcp.health.errorCount': '{count} error',
|
||||
'mcp.health.disconnectedCount': '{count} idle',
|
||||
'mcp.health.retryAll': 'Retry all ({count})',
|
||||
'mcp.health.retryAllAria': 'Retry all {count} errored MCP servers',
|
||||
'mcp.health.disconnectAll': 'Disconnect all ({count})',
|
||||
'mcp.health.disconnectAllAria': 'Disconnect all {count} connected MCP servers',
|
||||
'mcp.health.disconnectConfirm.title': 'Disconnect all MCP servers?',
|
||||
'mcp.health.disconnectConfirm.body':
|
||||
'This will disconnect {count} currently-connected MCP servers. Installed configurations and secrets are kept; you can reconnect any server later.',
|
||||
'mcp.health.disconnectConfirm.cancel': 'Cancel',
|
||||
'mcp.health.disconnectConfirm.confirm': 'Disconnect all',
|
||||
'mcp.health.opErrorGeneric': 'Bulk operation failed. See logs.',
|
||||
'mcp.health.bulkPartialFailure': '{failed} of {total} servers failed. See logs.',
|
||||
'mcp.installed.search.landmarkAria': 'Search installed MCP servers',
|
||||
'mcp.installed.search.inputAria': 'Filter installed MCP servers by name',
|
||||
'mcp.installed.search.placeholder': 'Filter servers…',
|
||||
|
||||
Reference in New Issue
Block a user