diff --git a/packages/model-registry/upstream/frontend/src/app/api/mcpDeploymentService.ts b/packages/model-registry/upstream/frontend/src/app/api/mcpDeploymentService.ts new file mode 100644 index 00000000000..f2871c4b4a1 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/api/mcpDeploymentService.ts @@ -0,0 +1,14 @@ +import { APIOptions, isModArchResponse, restGET, handleRestFailures } from 'mod-arch-core'; +import { McpDeploymentList } from '~/app/mcpDeploymentTypes'; + +export const getListMcpDeployments = + (hostPath: string, queryParams: Record = {}) => + (opts: APIOptions): Promise => + handleRestFailures(restGET(hostPath, `/mcp_deployments`, queryParams, opts)).then( + (response) => { + if (isModArchResponse(response)) { + return response.data; + } + throw new Error('Invalid response format'); + }, + ); diff --git a/packages/model-registry/upstream/frontend/src/app/mcpDeploymentTypes.ts b/packages/model-registry/upstream/frontend/src/app/mcpDeploymentTypes.ts index cb1adc446a2..da45c90d23c 100644 --- a/packages/model-registry/upstream/frontend/src/app/mcpDeploymentTypes.ts +++ b/packages/model-registry/upstream/frontend/src/app/mcpDeploymentTypes.ts @@ -12,6 +12,10 @@ export type McpDeploymentCondition = { message?: string; }; +export type McpDeploymentAddress = { + url: string; +}; + export type McpDeployment = { name: string; namespace: string; @@ -20,6 +24,7 @@ export type McpDeployment = { port: number; phase: McpDeploymentPhase; conditions?: McpDeploymentCondition[]; + address?: McpDeploymentAddress; }; export type McpDeploymentList = { diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentServicePopover.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentServicePopover.tsx new file mode 100644 index 00000000000..ebc557d232d --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentServicePopover.tsx @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { Button, ClipboardCopy, Popover } from '@patternfly/react-core'; +import { McpDeployment } from '~/app/mcpDeploymentTypes'; +import { getConnectionUrl } from './utils'; + +type McpDeploymentServicePopoverProps = { + deployment: McpDeployment; +}; + +const McpDeploymentServicePopover: React.FC = ({ + deployment, +}) => { + const connectionUrl = getConnectionUrl(deployment); + + if (!connectionUrl) { + return ; + } + + return ( + + {connectionUrl} + + } + data-testid="mcp-deployment-service-popover" + > + + + ); +}; + +export default McpDeploymentServicePopover; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentStatusLabel.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentStatusLabel.tsx new file mode 100644 index 00000000000..ce1731cc9a0 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentStatusLabel.tsx @@ -0,0 +1,33 @@ +import * as React from 'react'; +import { Label, Tooltip } from '@patternfly/react-core'; +import { + CheckCircleIcon, + ExclamationCircleIcon, + InProgressIcon, +} from '@patternfly/react-icons'; +import { McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; +import { getStatusInfo } from './utils'; + +type McpDeploymentStatusLabelProps = { + phase: McpDeploymentPhase; +}; + +const statusIconMap: Record = { + [McpDeploymentPhase.RUNNING]: , + [McpDeploymentPhase.FAILED]: , + [McpDeploymentPhase.PENDING]: , +}; + +const McpDeploymentStatusLabel: React.FC = ({ phase }) => { + const { label, status, tooltip } = getStatusInfo(phase); + + return ( + + + + ); +}; + +export default McpDeploymentStatusLabel; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsEmptyState.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsEmptyState.tsx new file mode 100644 index 00000000000..d14cfd77834 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsEmptyState.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import { + EmptyState, + EmptyStateBody, + EmptyStateVariant, +} from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; + +const McpDeploymentsEmptyState: React.FC = () => ( + + + No MCP server deployments have been created yet. Deploy an MCP server from the catalog to get + started. + + +); + +export default McpDeploymentsEmptyState; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsPage.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsPage.tsx new file mode 100644 index 00000000000..aa59d196c2b --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsPage.tsx @@ -0,0 +1,99 @@ +import * as React from 'react'; +import { ApplicationsPage, SimpleSelect } from 'mod-arch-shared'; +import { useNamespaceSelector, useModularArchContext } from 'mod-arch-core'; +import { Flex, FlexItem } from '@patternfly/react-core'; +import { McpDeployment } from '~/app/mcpDeploymentTypes'; +import useMcpDeployments from './useMcpDeployments'; +import McpDeploymentsTable from './McpDeploymentsTable'; +import McpDeploymentsToolbar from './McpDeploymentsToolbar'; +import McpDeploymentsEmptyState from './McpDeploymentsEmptyState'; +import { getServerDisplayName } from './utils'; + +const McpDeploymentsPage: React.FC = () => { + const [deployments, loaded, loadError, refresh] = useMcpDeployments(); + const [filterText, setFilterText] = React.useState(''); + const { namespaces = [], preferredNamespace, updatePreferredNamespace } = useNamespaceSelector(); + const { config } = useModularArchContext(); + + const isMandatoryNamespace = Boolean(config.mandatoryNamespace); + const projectOptions = namespaces.map((ns) => ({ + key: ns.name, + label: ns.name, + })); + const selectedProject = preferredNamespace?.name || namespaces[0]?.name || ''; + + const handleProjectChange = (key: string, isPlaceholder: boolean) => { + if (isPlaceholder || !key || isMandatoryNamespace) { + return; + } + updatePreferredNamespace({ name: key }); + }; + + const handleDeleteClick = React.useCallback( + (_deployment: McpDeployment) => { + // Delete flow handled by RHOAIENG-53380 + refresh(); + }, + [refresh], + ); + + const filteredDeployments = React.useMemo(() => { + if (!filterText) { + return deployments.items; + } + const lower = filterText.toLowerCase(); + return deployments.items.filter( + (d) => + d.name.toLowerCase().includes(lower) || + getServerDisplayName(d).toLowerCase().includes(lower), + ); + }, [deployments.items, filterText]); + + const clearFilters = React.useCallback(() => setFilterText(''), []); + + const isEmpty = loaded && !loadError && deployments.items.length === 0; + + return ( + + Project + + + + + } + loadError={loadError} + loaded={loaded} + empty={isEmpty} + emptyStatePage={} + provideChildrenPadding + > + + } + onClearFilters={clearFilters} + onDeleteClick={handleDeleteClick} + /> + + ); +}; + +export default McpDeploymentsPage; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsRoutes.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsRoutes.tsx new file mode 100644 index 00000000000..fe1a67a85c6 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsRoutes.tsx @@ -0,0 +1,12 @@ +import * as React from 'react'; +import { Navigate, Route, Routes } from 'react-router-dom'; +import McpDeploymentsPage from './McpDeploymentsPage'; + +const McpDeploymentsRoutes: React.FC = () => ( + + } /> + } /> + +); + +export default McpDeploymentsRoutes; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTable.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTable.tsx new file mode 100644 index 00000000000..3fda5d8c62f --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTable.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { Table, DashboardEmptyTableView } from 'mod-arch-shared'; +import { McpDeployment } from '~/app/mcpDeploymentTypes'; +import { mcpDeploymentColumns } from './McpDeploymentsTableColumns'; +import McpDeploymentsTableRow from './McpDeploymentsTableRow'; + +type McpDeploymentsTableProps = { + deployments: McpDeployment[]; + onClearFilters: () => void; + onDeleteClick: (deployment: McpDeployment) => void; +} & Partial, 'toolbarContent'>>; + +const McpDeploymentsTable: React.FC = ({ + deployments, + toolbarContent, + onClearFilters, + onDeleteClick, +}) => ( + } + rowRenderer={(deployment) => ( + + )} + /> +); + +export default McpDeploymentsTable; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTableColumns.ts b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTableColumns.ts new file mode 100644 index 00000000000..36b76a2b6dc --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTableColumns.ts @@ -0,0 +1,48 @@ +import { SortableData } from 'mod-arch-shared'; +import { McpDeployment, McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; +import { getServerDisplayName } from './utils'; + +const phaseOrder: Record = { + [McpDeploymentPhase.RUNNING]: 0, + [McpDeploymentPhase.PENDING]: 1, + [McpDeploymentPhase.FAILED]: 2, +}; + +export const mcpDeploymentColumns: SortableData[] = [ + { + field: 'server', + label: 'Server', + sortable: (a, b) => getServerDisplayName(a).localeCompare(getServerDisplayName(b)), + width: 20, + }, + { + field: 'name', + label: 'Name', + sortable: (a, b) => a.name.localeCompare(b.name), + width: 20, + }, + { + field: 'created', + label: 'Created', + sortable: (a, b) => + new Date(a.creationTimestamp).getTime() - new Date(b.creationTimestamp).getTime(), + width: 20, + }, + { + field: 'status', + label: 'Status', + sortable: (a, b) => (phaseOrder[a.phase] ?? 3) - (phaseOrder[b.phase] ?? 3), + width: 15, + }, + { + field: 'service', + label: 'Service', + sortable: false, + width: 10, + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTableRow.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTableRow.tsx new file mode 100644 index 00000000000..c0dfbb199dc --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsTableRow.tsx @@ -0,0 +1,59 @@ +import * as React from 'react'; +import { ActionsColumn, IAction, Td, Tr } from '@patternfly/react-table'; +import { Timestamp, TimestampTooltipVariant, Truncate } from '@patternfly/react-core'; +import { McpDeployment } from '~/app/mcpDeploymentTypes'; +import { getServerDisplayName } from './utils'; +import McpDeploymentStatusLabel from './McpDeploymentStatusLabel'; +import McpDeploymentServicePopover from './McpDeploymentServicePopover'; + +type McpDeploymentsTableRowProps = { + deployment: McpDeployment; + onDeleteClick: (deployment: McpDeployment) => void; +}; + +const McpDeploymentsTableRow: React.FC = ({ + deployment, + onDeleteClick, +}) => { + const actions: IAction[] = [ + { + title: 'Edit', + isAriaDisabled: true, + tooltipProps: { + content: 'Editing is not yet available.', + }, + }, + { + title: 'Delete', + onClick: () => onDeleteClick(deployment), + }, + ]; + + return ( + + + + + + + + + ); +}; + +export default McpDeploymentsTableRow; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsToolbar.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsToolbar.tsx new file mode 100644 index 00000000000..1d0a386652b --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/McpDeploymentsToolbar.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { + SearchInput, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; + +type McpDeploymentsToolbarProps = { + filterText: string; + onFilterChange: (value: string) => void; + onClearFilters: () => void; +}; + +const McpDeploymentsToolbar: React.FC = ({ + filterText, + onFilterChange, + onClearFilters, +}) => ( + + + + onFilterChange(value)} + onClear={() => onClearFilters()} + data-testid="mcp-deployments-filter-input" + /> + + + +); + +export default McpDeploymentsToolbar; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/McpDeploymentsTable.spec.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/McpDeploymentsTable.spec.tsx new file mode 100644 index 00000000000..04372f99fcb --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/McpDeploymentsTable.spec.tsx @@ -0,0 +1,75 @@ +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { McpDeployment, McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; +import McpDeploymentsTable from '../McpDeploymentsTable'; + +const mockDeployments: McpDeployment[] = [ + { + name: 'kubernetes-mcp', + namespace: 'mcp-servers', + creationTimestamp: '2026-03-10T14:30:00Z', + image: 'quay.io/mcp-servers/kubernetes:1.0.0', + port: 8080, + phase: McpDeploymentPhase.RUNNING, + }, + { + name: 'slack-mcp', + namespace: 'mcp-servers', + creationTimestamp: '2026-03-14T11:00:00Z', + image: 'quay.io/mcp-servers/slack:0.5.0', + port: 9090, + phase: McpDeploymentPhase.PENDING, + }, + { + name: 'jira-mcp', + namespace: 'mcp-servers', + creationTimestamp: '2026-03-08T16:45:00Z', + image: 'quay.io/mcp-servers/jira:1.2.0', + port: 8080, + phase: McpDeploymentPhase.FAILED, + }, +]; + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe('McpDeploymentsTable', () => { + const onDeleteClick = jest.fn(); + const onClearFilters = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render all deployment rows', () => { + render( + , + { wrapper }, + ); + + expect(screen.getByTestId('mcp-deployment-row-kubernetes-mcp')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-deployment-row-slack-mcp')).toBeInTheDocument(); + expect(screen.getByTestId('mcp-deployment-row-jira-mcp')).toBeInTheDocument(); + }); + + it('should show empty table view when deployments list is empty', () => { + render( + , + { wrapper }, + ); + + expect(screen.queryByTestId('mcp-deployment-row-kubernetes-mcp')).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /clear all filters/i })).toBeInTheDocument(); + }); +}); diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/McpDeploymentsTableRow.spec.tsx b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/McpDeploymentsTableRow.spec.tsx new file mode 100644 index 00000000000..79c20c398e8 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/McpDeploymentsTableRow.spec.tsx @@ -0,0 +1,65 @@ +import '@testing-library/jest-dom'; +import * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Table as PfTable, Tbody } from '@patternfly/react-table'; +import { McpDeployment, McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; +import McpDeploymentsTableRow from '../McpDeploymentsTableRow'; +import { createMockDeployment } from './mcpDeploymentTestUtils'; + +const renderRow = (deployment: McpDeployment, onDeleteClick = jest.fn()) => + render( + + + + + , + ); + +describe('McpDeploymentsTableRow', () => { + it('should render server name derived from image', () => { + renderRow(createMockDeployment()); + expect(screen.getByTestId('mcp-deployment-server')).toHaveTextContent('Kubernetes-1.0.0'); + }); + + it('should render deployment name', () => { + renderRow(createMockDeployment()); + expect(screen.getByTestId('mcp-deployment-name')).toHaveTextContent('kubernetes-mcp'); + }); + + it('should render a non-empty formatted creation date', () => { + renderRow(createMockDeployment()); + const dateCell = screen.getByTestId('mcp-deployment-created'); + expect(dateCell.textContent).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}|[A-Z][a-z]{2}\s+\d{1,2},\s+\d{4}/); + }); + + it('should render status label that maps phase to display label', () => { + renderRow(createMockDeployment({ phase: McpDeploymentPhase.RUNNING })); + expect(screen.getByTestId('mcp-deployment-status-label')).toHaveTextContent('Available'); + }); + + it('should render View link for Running deployment and show connection URL in popover', async () => { + const user = userEvent.setup(); + renderRow( + createMockDeployment({ + phase: McpDeploymentPhase.RUNNING, + address: { url: 'kubernetes-test:8080' }, + }), + ); + const viewLink = screen.getByTestId('mcp-deployment-service-view'); + expect(viewLink).toBeInTheDocument(); + + await user.click(viewLink); + const popover = await screen.findByTestId('mcp-deployment-connection-url'); + expect(popover).toBeInTheDocument(); + expect(popover.querySelector('input')).toHaveValue('kubernetes-test:8080'); + }); + + it.each([McpDeploymentPhase.FAILED, McpDeploymentPhase.PENDING])( + 'should render dash for %s deployment without address', + (phase) => { + renderRow(createMockDeployment({ phase })); + expect(screen.getByTestId('mcp-deployment-service-unavailable')).toBeInTheDocument(); + }, + ); +}); diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/mcpDeploymentTestUtils.ts b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/mcpDeploymentTestUtils.ts new file mode 100644 index 00000000000..9cc55fc3e03 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/mcpDeploymentTestUtils.ts @@ -0,0 +1,11 @@ +import { McpDeployment, McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; + +export const createMockDeployment = (overrides: Partial = {}): McpDeployment => ({ + name: 'kubernetes-mcp', + namespace: 'mcp-servers', + creationTimestamp: '2026-03-10T14:30:00Z', + image: 'quay.io/mcp-servers/kubernetes:1.0.0', + port: 8080, + phase: McpDeploymentPhase.RUNNING, + ...overrides, +}); diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/utils.spec.ts b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/utils.spec.ts new file mode 100644 index 00000000000..4b41279c5b9 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/__tests__/utils.spec.ts @@ -0,0 +1,96 @@ +import { McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; +import { getConnectionUrl, getServerDisplayName, getStatusInfo } from '../utils'; +import { createMockDeployment } from './mcpDeploymentTestUtils'; + +describe('getServerDisplayName', () => { + it('should extract and format server name from full image path', () => { + const deployment = createMockDeployment({ + image: 'quay.io/mcp-servers/kubernetes:1.0.0', + }); + expect(getServerDisplayName(deployment)).toBe('Kubernetes-1.0.0'); + }); + + it('should handle image with no tag', () => { + const deployment = createMockDeployment({ + image: 'quay.io/mcp-servers/kubernetes', + }); + expect(getServerDisplayName(deployment)).toBe('Kubernetes'); + }); + + it('should handle image with hyphenated name', () => { + const deployment = createMockDeployment({ + image: 'quay.io/mcp-servers/service-now:1.2.0', + }); + expect(getServerDisplayName(deployment)).toBe('Service-Now-1.2.0'); + }); + + it('should handle simple image name without registry', () => { + const deployment = createMockDeployment({ + image: 'slack:0.5.0', + }); + expect(getServerDisplayName(deployment)).toBe('Slack-0.5.0'); + }); + + it('should handle image name without slash or tag', () => { + const deployment = createMockDeployment({ + image: 'postgres', + }); + expect(getServerDisplayName(deployment)).toBe('Postgres'); + }); +}); + +describe('getConnectionUrl', () => { + it('should return address URL when provided', () => { + const deployment = createMockDeployment({ + phase: McpDeploymentPhase.RUNNING, + address: { url: 'https://kubernetes-mcp.example.com:8080' }, + }); + expect(getConnectionUrl(deployment)).toBe('https://kubernetes-mcp.example.com:8080'); + }); + + it('should return name:port for Running deployment without address', () => { + const deployment = createMockDeployment({ + phase: McpDeploymentPhase.RUNNING, + }); + expect(getConnectionUrl(deployment)).toBe('kubernetes-mcp:8080'); + }); + + it.each([McpDeploymentPhase.PENDING, McpDeploymentPhase.FAILED])( + 'should return undefined for %s deployment without address', + (phase) => { + const deployment = createMockDeployment({ phase }); + expect(getConnectionUrl(deployment)).toBeUndefined(); + }, + ); + + it('should return address URL even for non-Running deployment', () => { + const deployment = createMockDeployment({ + phase: McpDeploymentPhase.FAILED, + address: { url: 'stale-url:8080' }, + }); + expect(getConnectionUrl(deployment)).toBe('stale-url:8080'); + }); +}); + +describe('getStatusInfo', () => { + it('should return available status for Running phase', () => { + const result = getStatusInfo(McpDeploymentPhase.RUNNING); + expect(result.label).toBe('Available'); + expect(result.status).toBe('success'); + expect(result.tooltip).toBe('This MCP server is running and available for connections.'); + }); + + it('should return unavailable status for Failed phase', () => { + const result = getStatusInfo(McpDeploymentPhase.FAILED); + expect(result.label).toBe('Unavailable'); + expect(result.status).toBe('danger'); + expect(result.tooltip).toBe('This MCP server has failed and is not available.'); + }); + + it('should return pending status for Pending phase', () => { + const result = getStatusInfo(McpDeploymentPhase.PENDING); + expect(result.label).toBe('Pending'); + expect(result.status).toBe('info'); + expect(result.tooltip).toBe('This MCP server is starting up and will be available shortly.'); + }); +}); diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/useMcpDeployments.ts b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/useMcpDeployments.ts new file mode 100644 index 00000000000..6d9c8bdfbad --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/useMcpDeployments.ts @@ -0,0 +1,22 @@ +import * as React from 'react'; +import { useFetchState, FetchState, useQueryParamNamespaces } from 'mod-arch-core'; +import { McpDeploymentList } from '~/app/mcpDeploymentTypes'; +import { BFF_API_VERSION, URL_PREFIX } from '~/app/utilities/const'; +import { getListMcpDeployments } from '~/app/api/mcpDeploymentService'; + +const useMcpDeployments = (): FetchState => { + const hostPath = `${URL_PREFIX}/api/${BFF_API_VERSION}`; + const queryParams = useQueryParamNamespaces(); + const callback = React.useMemo( + () => getListMcpDeployments(hostPath, queryParams), + [hostPath, queryParams], + ); + + return useFetchState( + callback, + { items: [], size: 0, pageSize: 0 }, + { initialPromisePurity: true }, + ); +}; + +export default useMcpDeployments; diff --git a/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/utils.ts b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/utils.ts new file mode 100644 index 00000000000..baed971b15e --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/app/pages/mcpDeployments/utils.ts @@ -0,0 +1,60 @@ +import { McpDeployment, McpDeploymentPhase } from '~/app/mcpDeploymentTypes'; + +export const getConnectionUrl = (deployment: McpDeployment): string | undefined => { + if (deployment.address?.url) { + return deployment.address.url; + } + if (deployment.phase === McpDeploymentPhase.RUNNING) { + return `${deployment.name}:${deployment.port}`; + } + return undefined; +}; + +export const getServerDisplayName = (deployment: McpDeployment): string => { + const { image } = deployment; + const lastSlash = image.lastIndexOf('/'); + const imageWithTag = lastSlash >= 0 ? image.substring(lastSlash + 1) : image; + const [imageName, tag] = imageWithTag.split(':'); + + const capitalizedName = imageName + .split('-') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join('-'); + + return tag ? `${capitalizedName}-${tag}` : capitalizedName; +}; + +export type McpDeploymentStatusInfo = { + label: string; + status: 'success' | 'danger' | 'info'; + tooltip: string; +}; + +export const getStatusInfo = (phase: McpDeploymentPhase): McpDeploymentStatusInfo => { + switch (phase) { + case McpDeploymentPhase.RUNNING: + return { + label: 'Available', + status: 'success', + tooltip: 'This MCP server is running and available for connections.', + }; + case McpDeploymentPhase.FAILED: + return { + label: 'Unavailable', + status: 'danger', + tooltip: 'This MCP server has failed and is not available.', + }; + case McpDeploymentPhase.PENDING: + return { + label: 'Pending', + status: 'info', + tooltip: 'This MCP server is starting up and will be available shortly.', + }; + default: + return { + label: 'Unknown', + status: 'info', + tooltip: 'The status of this MCP server is unknown.', + }; + } +}; diff --git a/packages/model-registry/upstream/frontend/src/odh/McpDeploymentsWrapper.tsx b/packages/model-registry/upstream/frontend/src/odh/McpDeploymentsWrapper.tsx new file mode 100644 index 00000000000..d5680b4e9b2 --- /dev/null +++ b/packages/model-registry/upstream/frontend/src/odh/McpDeploymentsWrapper.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { + BrowserStorageContextProvider, + NotificationContextProvider, + ModularArchContextProvider, + ModularArchConfig, + DeploymentMode, + useSettings, +} from 'mod-arch-core'; +import { ThemeProvider, Theme } from 'mod-arch-kubeflow'; +import { BFF_API_VERSION, URL_PREFIX } from '~/app/utilities/const'; +import { AppContext } from '~/app/context/AppContext'; +import McpDeploymentsRoutes from '~/app/pages/mcpDeployments/McpDeploymentsRoutes'; +import { Bullseye } from '@patternfly/react-core'; +import useFetchDscStatus from '@odh-dashboard/internal/concepts/areas/useFetchDscStatus'; +import NotificationListener from '~/odh/components/NotificationListener'; +import OdhDevFeatureFlagOverridesProvider from '~/odh/components/OdhDevFeatureFlagOverridesProvider'; + +const McpDeploymentsWrapperContent: React.FC = () => { + const { configSettings, userSettings, loaded, loadError } = useSettings(); + if (loadError) { + return
Error: {loadError.message}
; + } + if (!loaded) { + return Loading...; + } + return configSettings && userSettings ? ( + + + + + + + + + + + + + + ) : null; +}; + +const McpDeploymentsWrapper: React.FC = () => { + const [dscStatus] = useFetchDscStatus(); + const modularArchConfig: ModularArchConfig = { + deploymentMode: DeploymentMode.Federated, + URL_PREFIX, + BFF_API_VERSION, + mandatoryNamespace: dscStatus?.components?.modelregistry?.registriesNamespace, + }; + return ( + + + + ); +}; +export default McpDeploymentsWrapper; diff --git a/packages/model-registry/upstream/frontend/src/odh/extensions.ts b/packages/model-registry/upstream/frontend/src/odh/extensions.ts index 5950babcc64..62d03e1481a 100644 --- a/packages/model-registry/upstream/frontend/src/odh/extensions.ts +++ b/packages/model-registry/upstream/frontend/src/odh/extensions.ts @@ -134,6 +134,29 @@ const extensions: (NavExtension | RouteExtension | AreaExtension | McpServerDepl component: () => import('./McpCatalogWrapper'), }, }, + { + type: 'app.navigation/href', + flags: { + required: [SupportedArea.MCP_CATALOG], + }, + properties: { + id: 'mcpDeployments', + title: 'Deployments', + href: '/ai-hub/mcp-deployments', + section: 'mcp-servers', + path: '/ai-hub/mcp-deployments/*', + }, + }, + { + type: 'app.route', + flags: { + required: [SupportedArea.MCP_CATALOG], + }, + properties: { + path: '/ai-hub/mcp-deployments/*', + component: () => import('./McpDeploymentsWrapper'), + }, + }, { type: 'app.navigation/href', flags: {
+ + + + + + + + + + + +