Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { APIOptions, isModArchResponse, restGET, restDELETE, handleRestFailures } from 'mod-arch-core';
import { McpDeploymentList } from '~/app/mcpDeploymentTypes';

export const getListMcpDeployments =
(hostPath: string, queryParams: Record<string, unknown> = {}) =>
(opts: APIOptions): Promise<McpDeploymentList> =>
handleRestFailures(restGET(hostPath, `/mcp_deployments`, queryParams, opts)).then(
(response) => {
if (isModArchResponse<McpDeploymentList>(response)) {
return response.data;
}
throw new Error('Invalid response format');
},
);

export const deleteMcpDeployment =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not used anywhere - should be cleaned up/edited

(hostPath: string) =>
(opts: APIOptions, name: string): Promise<void> =>
handleRestFailures(restDELETE(hostPath, `/mcp_deployments/${name}`, {}, opts)).then(
() => undefined,
);
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export type McpDeploymentCondition = {
message?: string;
};

export type McpDeploymentAddress = {
url: string;
};

export type McpDeployment = {
name: string;
namespace: string;
Expand All @@ -20,6 +24,7 @@ export type McpDeployment = {
port: number;
phase: McpDeploymentPhase;
conditions?: McpDeploymentCondition[];
address?: McpDeploymentAddress;
};

export type McpDeploymentList = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<McpDeploymentServicePopoverProps> = ({
deployment,
}) => {
const connectionUrl = getConnectionUrl(deployment);

if (!connectionUrl) {
return <span data-testid="mcp-deployment-service-unavailable">&ndash;</span>;
}

return (
<Popover
headerContent="Connection URL"
bodyContent={
<ClipboardCopy isReadOnly hoverTip="Copy" clickTip="Copied" data-testid="mcp-deployment-connection-url">
{connectionUrl}
</ClipboardCopy>
}
data-testid="mcp-deployment-service-popover"
>
<Button variant="link" isInline data-testid="mcp-deployment-service-view">
View
</Button>
</Popover>
);
};

export default McpDeploymentServicePopover;
Original file line number Diff line number Diff line change
@@ -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, React.ReactNode> = {
[McpDeploymentPhase.RUNNING]: <CheckCircleIcon />,
[McpDeploymentPhase.FAILED]: <ExclamationCircleIcon />,
[McpDeploymentPhase.PENDING]: <InProgressIcon />,
};

const McpDeploymentStatusLabel: React.FC<McpDeploymentStatusLabelProps> = ({ phase }) => {
const { label, status, tooltip } = getStatusInfo(phase);

return (
<Tooltip content={tooltip}>
<Label status={status} icon={statusIconMap[phase]} data-testid="mcp-deployment-status-label">
{label}
</Label>
</Tooltip>
);
};

export default McpDeploymentStatusLabel;
Original file line number Diff line number Diff line change
@@ -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 = () => (
<EmptyState
icon={CubesIcon}
titleText="No MCP server deployments"
variant={EmptyStateVariant.lg}
data-testid="mcp-deployments-empty-state"
>
<EmptyStateBody>
No MCP server deployments have been created yet. Deploy an MCP server from the catalog to get
started.
</EmptyStateBody>
</EmptyState>
);

export default McpDeploymentsEmptyState;
Original file line number Diff line number Diff line change
@@ -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 (
<ApplicationsPage
title="MCP server deployments"
description="Manage and view the health and performance of your deployed MCP servers."
headerContent={
<Flex alignItems={{ default: 'alignItemsCenter' }} spaceItems={{ default: 'spaceItemsSm' }}>
<FlexItem>Project</FlexItem>
<FlexItem>
<SimpleSelect
options={projectOptions}
value={selectedProject}
onChange={handleProjectChange}
isDisabled={isMandatoryNamespace || namespaces.length === 0}
isScrollable
maxMenuHeight="300px"
popperProps={{ maxWidth: '400px' }}
dataTestId="mcp-deployments-project-selector"
/>
</FlexItem>
</Flex>
}
loadError={loadError}
loaded={loaded}
empty={isEmpty}
emptyStatePage={<McpDeploymentsEmptyState />}
provideChildrenPadding
>
<McpDeploymentsTable
deployments={filteredDeployments}
toolbarContent={
<McpDeploymentsToolbar
filterText={filterText}
onFilterChange={setFilterText}
onClearFilters={clearFilters}
/>
}
onClearFilters={clearFilters}
onDeleteClick={handleDeleteClick}
/>
</ApplicationsPage>
);
};

export default McpDeploymentsPage;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Cypress mock tests — this can be a follow-up, but flagging for visibility.

The MCP Catalog (sibling feature under the same mcp-servers nav section) has a full Cypress suite with page object, test utils, and intercepts (mcpCatalog.cy.ts, mcpCatalogTestUtils.ts, mcpCatalog.ts). The Deployments page has comparable interactive surface area but zero Cypress coverage.

Suggested coverage for a follow-up:

  1. Navigation & routing — nav item gated by mcpCatalog flag, route renders page, unknown sub-routes redirect
  2. Project selector — shows namespaces, switching re-fetches, disabled when mandatoryNamespace is set
  3. Table rendering — rows from BFF response with correct server name, status labels, dates
  4. Service popover — "View" link opens popover with connection URL for Running; dash for Failed/Pending
  5. Filtering — filter by name/server name, clear filters restores list, no-match shows DashboardEmptyTableView
  6. Empty state — zero deployments shows McpDeploymentsEmptyState
  7. Error & loading states — BFF 500 shows error, pending request shows loading
  8. Kebab actions — Edit disabled with tooltip, Delete clickable
  9. Accessibilitycy.testA11y() on main view and empty state

Test artifacts needed:

  • Page object (mcpDeployments.ts) with visit(), wait() + cy.testA11y(), finder methods
  • Test utils (mcpDeploymentsTestUtils.ts) with initMcpDeploymentsIntercepts()
  • Test file (mcpDeployments.cy.ts)

Original file line number Diff line number Diff line change
@@ -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 = () => (
<Routes>
<Route path="/" element={<McpDeploymentsPage />} />
<Route path="*" element={<Navigate to="." />} />
</Routes>
);

export default McpDeploymentsRoutes;
Original file line number Diff line number Diff line change
@@ -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<Pick<React.ComponentProps<typeof Table>, 'toolbarContent'>>;

const McpDeploymentsTable: React.FC<McpDeploymentsTableProps> = ({
deployments,
toolbarContent,
onClearFilters,
onDeleteClick,
}) => (
<Table
data-testid="mcp-deployments-table"
data={deployments}
columns={mcpDeploymentColumns}
toolbarContent={toolbarContent}
onClearFilters={onClearFilters}
enablePagination
emptyTableView={<DashboardEmptyTableView onClearFilters={onClearFilters} />}
rowRenderer={(deployment) => (
<McpDeploymentsTableRow
key={deployment.name}
deployment={deployment}
onDeleteClick={onDeleteClick}
/>
)}
/>
);

export default McpDeploymentsTable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { SortableData } from 'mod-arch-shared';
import { McpDeployment, McpDeploymentPhase } from '~/app/mcpDeploymentTypes';
import { getServerDisplayName } from './utils';

const phaseOrder: Record<McpDeploymentPhase, number> = {
[McpDeploymentPhase.RUNNING]: 0,
[McpDeploymentPhase.PENDING]: 1,
[McpDeploymentPhase.FAILED]: 2,
};

export const mcpDeploymentColumns: SortableData<McpDeployment>[] = [
{
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,
},
];
Original file line number Diff line number Diff line change
@@ -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<McpDeploymentsTableRowProps> = ({
deployment,
onDeleteClick,
}) => {
const actions: IAction[] = [
{
title: 'Edit',
isAriaDisabled: true,
tooltipProps: {
content: 'Editing is not yet available.',
},
},
{
title: 'Delete',
onClick: () => onDeleteClick(deployment),
},
];

return (
<Tr data-testid={`mcp-deployment-row-${deployment.name}`}>
<Td dataLabel="Server" data-testid="mcp-deployment-server">
<Truncate content={getServerDisplayName(deployment)} />
</Td>
<Td dataLabel="Name" data-testid="mcp-deployment-name">
<Truncate content={deployment.name} />
</Td>
<Td dataLabel="Created" data-testid="mcp-deployment-created">
<Timestamp
date={new Date(deployment.creationTimestamp)}
tooltip={{ variant: TimestampTooltipVariant.default }}
/>
</Td>
<Td dataLabel="Status" data-testid="mcp-deployment-status">
<McpDeploymentStatusLabel phase={deployment.phase} />
</Td>
<Td dataLabel="Service" data-testid="mcp-deployment-service">
<McpDeploymentServicePopover deployment={deployment} />
</Td>
<Td isActionCell>
<ActionsColumn items={actions} />
</Td>
</Tr>
);
};

export default McpDeploymentsTableRow;
Loading
Loading