Skip to content

Commit 2b706c8

Browse files
authored
feat: MCP: visualize members and MCP status in header area (#386)
1 parent 224fbcf commit 2b706c8

File tree

13 files changed

+241
-39
lines changed

13 files changed

+241
-39
lines changed

public/locales/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,7 @@
369369
},
370370
"common": {
371371
"all": "All",
372+
"status": "Status",
372373
"documentation": "Documentation",
373374
"close": "Close",
374375
"cannotLoadData": "Cannot load data",

src/components/ControlPlane/MCPHealthPopoverButton.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import {
77
Button,
88
PopoverDomRef,
99
ButtonDomRef,
10+
LinkDomRef,
1011
} from '@ui5/webcomponents-react';
1112
import { AnalyticalTableColumnDefinition } from '@ui5/webcomponents-react/wrappers';
1213
import PopoverPlacement from '@ui5/webcomponents/dist/types/PopoverPlacement.js';
1314
import '@ui5/webcomponents-icons/dist/copy';
1415
import { JSX, useRef, useState } from 'react';
1516
import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js';
17+
import type { LinkClickEventDetail } from '@ui5/webcomponents/dist/Link.js';
1618
import {
1719
ControlPlaneStatusType,
1820
ReadyStatus,
@@ -30,17 +32,28 @@ type MCPHealthPopoverButtonProps = {
3032
projectName: string;
3133
workspaceName: string;
3234
mcpName: string;
35+
large?: boolean;
3336
};
3437

35-
const MCPHealthPopoverButton = ({ mcpStatus, projectName, workspaceName, mcpName }: MCPHealthPopoverButtonProps) => {
38+
const MCPHealthPopoverButton = ({
39+
mcpStatus,
40+
projectName,
41+
workspaceName,
42+
mcpName,
43+
large = false,
44+
}: MCPHealthPopoverButtonProps) => {
3645
const popoverRef = useRef<PopoverDomRef>(null);
46+
const buttonRef = useRef<ButtonDomRef>(null);
3747
const [open, setOpen] = useState(false);
3848
const { githubIssuesSupportTicket } = useLink();
3949
const { t } = useTranslation();
4050

41-
const handleOpenerClick = (event: Ui5CustomEvent<ButtonDomRef, ButtonClickEventDetail>) => {
51+
const handleOpenerClick = (
52+
event: Ui5CustomEvent<ButtonDomRef, ButtonClickEventDetail> | Ui5CustomEvent<LinkDomRef, LinkClickEventDetail>,
53+
) => {
4254
if (popoverRef.current) {
43-
(popoverRef.current as unknown as { opener: EventTarget | null }).opener = event.target;
55+
// Prefer explicit button ref as opener (works reliably); fall back to event.target
56+
(popoverRef.current as unknown as { opener: EventTarget | null }).opener = buttonRef.current ?? event.target;
4457
setOpen((prev) => !prev);
4558
}
4659
};
@@ -141,11 +154,13 @@ const MCPHealthPopoverButton = ({ mcpStatus, projectName, workspaceName, mcpName
141154
return (
142155
<div className="component-title-row">
143156
<AnimatedHoverTextButton
157+
ref={buttonRef}
144158
icon={getIconForOverallStatus(mcpStatus?.status)}
145159
text={mcpStatus?.status ?? ''}
160+
large={large}
146161
onClick={handleOpenerClick}
147162
/>
148-
<Popover ref={popoverRef} open={open} placement={PopoverPlacement.Bottom}>
163+
<Popover ref={popoverRef} open={open} placement={PopoverPlacement.Bottom} onClose={() => setOpen(false)}>
149164
<StatusTable
150165
status={mcpStatus}
151166
tableColumns={statusTableColumns}
@@ -184,11 +199,11 @@ const StatusTable = ({ status, tableColumns, githubIssuesLink }: StatusTableProp
184199
const getIconForOverallStatus = (status: ReadyStatus | undefined): JSX.Element => {
185200
switch (status) {
186201
case ReadyStatus.Ready:
187-
return <Icon style={{ color: 'green' }} name="sap-icon://sys-enter" />;
202+
return <Icon style={{ color: 'var(--sapPositiveColor)' }} name="sap-icon://sys-enter" />;
188203
case ReadyStatus.NotReady:
189-
return <Icon style={{ color: 'red' }} name="sap-icon://pending" />;
204+
return <Icon style={{ color: 'var(--sapNegativeColor)' }} name="sap-icon://pending" />;
190205
case ReadyStatus.InDeletion:
191-
return <Icon style={{ color: 'orange' }} name="sap-icon://delete" />;
206+
return <Icon style={{ color: 'var(--sapCriticalColor)' }} name="sap-icon://delete" />;
192207
default:
193208
return <></>;
194209
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.statusLabel {
2+
font-weight: bold;
3+
margin-bottom: 0.75rem;
4+
margin-left: 0.6rem;
5+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { FlexBox, Text } from '@ui5/webcomponents-react';
2+
import { useTranslation } from 'react-i18next';
3+
import MCPHealthPopoverButton from './MCPHealthPopoverButton.tsx';
4+
import { ControlPlaneStatusType } from '../../lib/api/types/crate/controlPlanes.ts';
5+
import styles from './McpStatusSection.module.css';
6+
7+
interface McpStatusSectionProps {
8+
mcpStatus: ControlPlaneStatusType | undefined;
9+
projectName: string;
10+
workspaceName: string;
11+
mcpName: string;
12+
}
13+
14+
export function McpStatusSection({ mcpStatus, projectName, workspaceName, mcpName }: McpStatusSectionProps) {
15+
const { t } = useTranslation();
16+
17+
return (
18+
<FlexBox direction={'Column'}>
19+
<Text className={styles.statusLabel}>{t('common.status')}:</Text>
20+
<MCPHealthPopoverButton
21+
mcpStatus={mcpStatus}
22+
projectName={projectName}
23+
workspaceName={workspaceName}
24+
mcpName={mcpName}
25+
large
26+
/>
27+
</FlexBox>
28+
);
29+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { ReadyStatus } from '../../lib/api/types/crate/controlPlanes';
2+
3+
export const getClassNameForOverallStatus = (status: ReadyStatus | undefined): string => {
4+
switch (status) {
5+
case ReadyStatus.Ready:
6+
return 'ready';
7+
case ReadyStatus.NotReady:
8+
return 'not-ready';
9+
case ReadyStatus.InDeletion:
10+
return 'deleting';
11+
default:
12+
return '';
13+
}
14+
};

src/components/ControlPlanes/List/MembersAvatarView.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,10 @@ interface Props {
1010
project?: string;
1111
workspace?: string;
1212
members: Member[];
13+
hideNamespaceColumn?: boolean;
1314
}
1415

15-
export function MembersAvatarView({ members, project, workspace }: Props) {
16+
export function MembersAvatarView({ members, project, workspace, hideNamespaceColumn = false }: Props) {
1617
const openerId = useId();
1718
const [popoverIsOpen, setPopoverIsOpen] = useState(false);
1819
const avatars = [];
@@ -44,7 +45,7 @@ export function MembersAvatarView({ members, project, workspace }: Props) {
4445
setPopoverIsOpen(false);
4546
}}
4647
>
47-
<MemberTable members={members} requireAtLeastOneMember={false} />
48+
<MemberTable members={members} requireAtLeastOneMember={false} hideNamespaceColumn={hideNamespaceColumn} />
4849
</Popover>
4950
</div>
5051
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.membersTitle {
2+
margin-left: 5px;
3+
font-weight: bold;
4+
margin-bottom: 0.5rem;
5+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { MembersAvatarView } from '../List/MembersAvatarView.tsx';
2+
import { convertRoleBindingsToMembers } from '../../../utils/convertRoleBindingsToMembers.ts';
3+
import { FlexBox, Text } from '@ui5/webcomponents-react';
4+
import { useTranslation } from 'react-i18next';
5+
import styles from './McpMembersAvatarView.module.css';
6+
7+
interface Props {
8+
project?: string;
9+
workspace?: string;
10+
roleBindings?: { role: string; subjects: { kind: string; name: string }[] }[];
11+
}
12+
13+
export function McpMembersAvatarView({ roleBindings, project, workspace }: Props) {
14+
const members = convertRoleBindingsToMembers(roleBindings);
15+
const { t } = useTranslation();
16+
return (
17+
<FlexBox direction="Column">
18+
<Text className={styles.membersTitle}>
19+
{t('common.members')} ({members.length}):
20+
</Text>
21+
<MembersAvatarView members={members} project={project} workspace={workspace} hideNamespaceColumn />
22+
</FlexBox>
23+
);
24+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
.text {
2+
margin-right: 0.5rem;
3+
}
4+
5+
.link:focus-within,
6+
.link:focus-visible {
7+
background-color: transparent;
8+
}
9+
10+
.ready {
11+
color: var(--sapPositiveColor);
12+
}
13+
14+
.not-ready {
15+
color: var(--sapNegativeColor);
16+
}
17+
18+
.deleting {
19+
color: var(--sapCriticalColor);
20+
}
21+
22+
.large {
23+
font-size: 1.25rem;
24+
}
Lines changed: 57 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,72 @@
1-
import { Button, ButtonDomRef, FlexBox, FlexBoxAlignItems, Text } from '@ui5/webcomponents-react';
1+
import { Button, ButtonDomRef, FlexBox, FlexBoxAlignItems } from '@ui5/webcomponents-react';
22
import '@ui5/webcomponents-icons/dist/copy';
3-
import { JSX, useId, useState } from 'react';
4-
import ButtonDesign from '@ui5/webcomponents/dist/types/ButtonDesign.js';
3+
import { JSX, useId, useState, forwardRef } from 'react';
54
import type { Ui5CustomEvent } from '@ui5/webcomponents-react-base';
65
import type { ButtonClickEventDetail } from '@ui5/webcomponents/dist/Button.js';
76

7+
import styles from './AnimatedHoverTextButton.module.css';
8+
import { getClassNameForOverallStatus } from '../ControlPlane/statusUtils';
9+
import { ReadyStatus } from '../../lib/api/types/crate/controlPlanes.ts';
10+
import cx from 'clsx';
811
type HoverTextButtonProps = {
912
id?: string;
1013
text: string;
1114
icon: JSX.Element;
1215
onClick: (event: Ui5CustomEvent<ButtonDomRef, ButtonClickEventDetail>) => void;
16+
large?: boolean;
1317
};
14-
export const AnimatedHoverTextButton = ({ id, text, icon, onClick }: HoverTextButtonProps) => {
15-
const [hover, setHover] = useState(false);
1618

17-
const generatedId = useId();
18-
id ??= generatedId;
19+
export const AnimatedHoverTextButton = forwardRef<ButtonDomRef, HoverTextButtonProps>(
20+
({ id, text, icon, onClick, large = false }: HoverTextButtonProps, ref) => {
21+
const [hover, setHover] = useState(false);
1922

20-
return (
21-
<Button
22-
id={id}
23-
design={ButtonDesign.Transparent}
24-
onClick={onClick}
25-
onMouseOver={() => setHover(true)}
26-
onMouseLeave={() => setHover(false)}
27-
>
23+
const generatedId = useId();
24+
id ??= generatedId;
25+
26+
const content = (
2827
<FlexBox alignItems={FlexBoxAlignItems.Center}>
29-
{hover ? <Text style={{ marginRight: '8px' }}> {text}</Text> : null}
28+
{hover || large ? (
29+
<span
30+
className={cx(styles.text, styles[getClassNameForOverallStatus(text as ReadyStatus)], {
31+
[styles.large]: large,
32+
})}
33+
>
34+
{text}
35+
</span>
36+
) : null}
3037
{icon}
3138
</FlexBox>
32-
</Button>
33-
);
34-
};
39+
);
40+
41+
if (large) {
42+
return (
43+
<Button
44+
ref={ref}
45+
id={id}
46+
design={'Transparent'}
47+
className={cx(styles.link, styles[getClassNameForOverallStatus(text ? (text as ReadyStatus) : undefined)])}
48+
onClick={onClick}
49+
onMouseLeave={() => setHover(false)}
50+
onMouseOver={() => setHover(true)}
51+
>
52+
{content}
53+
</Button>
54+
);
55+
}
56+
57+
return (
58+
<Button
59+
ref={ref}
60+
id={id}
61+
design={'Transparent'}
62+
onClick={onClick}
63+
onMouseLeave={() => setHover(false)}
64+
onMouseOver={() => setHover(true)}
65+
>
66+
{content}
67+
</Button>
68+
);
69+
},
70+
);
71+
72+
AnimatedHoverTextButton.displayName = 'AnimatedHoverTextButton';

0 commit comments

Comments
 (0)