Skip to content

Move cluster chooser to the sidebar #3504

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -118,26 +118,7 @@
</div>
<div
class="MuiBox-root css-0"
>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-text MuiButton-textSecondary MuiButton-sizeLarge MuiButton-textSizeLarge MuiButton-colorSecondary MuiButton-disableElevation MuiButton-root MuiButton-text MuiButton-textSecondary MuiButton-sizeLarge MuiButton-textSizeLarge MuiButton-colorSecondary MuiButton-disableElevation css-magldz-MuiButtonBase-root-MuiButton-root"
tabindex="0"
type="button"
>
<span
class="MuiButton-icon MuiButton-startIcon MuiButton-iconSizeLarge css-htszrh-MuiButton-startIcon"
/>
<span
class="css-k5l2zq"
title="ak8s-desktop"
>
Cluster: ak8s-desktop
</span>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
</div>
/>
<button
aria-controls="primary-user-menu"
aria-haspopup="true"
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/Sidebar/ClusterChooserAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*
* Copyright 2025 The Kubernetes Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { Icon } from '@iconify/react';
import Button from '@mui/material/Button';
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTypedSelector } from '../../redux/hooks';
import ClusterChooserPopup from '../cluster/ClusterChooserPopup';

/**
* Action component for cluster sidebar items.
* This shows an ellipsis icon that opens the cluster chooser popup.
* Only displays when no plugin has registered a custom cluster chooser button,
* whose default button component used to be displayed at the top bar.
*/
export const ClusterChooserAction: React.FC = () => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const ChooserButton = useTypedSelector(state => state.ui.clusterChooserButtonComponent);
const buttonRef = React.useRef<HTMLButtonElement>(null);

useHotkeys(
'ctrl+shift+l',
() => {
// Only open popup if no plugin has registered a custom cluster chooser button
if (!ChooserButton) {
setAnchorEl(buttonRef.current);
}
},
{ preventDefault: true }
);

// Don't render if a plugin has registered a custom cluster chooser button
if (ChooserButton) {
return null;
}

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
// Prevent both default action and event propagation to avoid sidebar navigation
event.stopPropagation();
event.preventDefault();
setAnchorEl(event.currentTarget);
};

const handleClose = () => {
setAnchorEl(null);
};

return (
<>
<Button
ref={buttonRef}
variant="outlined"
sx={{
minWidth: 'unset',
px: 0.5,
}}
onClick={handleClick}
>
<Icon icon="mdi:dots-horizontal" width={16} height={16} />
</Button>
<ClusterChooserPopup anchor={anchorEl} onClose={handleClose} />
</>
);
};
3 changes: 3 additions & 0 deletions frontend/src/components/Sidebar/ListItemLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface ListItemLinkProps {
hasParent?: boolean;
fullWidth?: boolean;
divider?: boolean;
endAction?: React.ComponentType<any>;
containerProps?: {
[prop: string]: any;
};
Expand All @@ -60,6 +61,7 @@ export default function ListItemLink(props: ListItemLinkProps) {
subtitle,
hasParent,
fullWidth,
endAction: EndAction,
...other
} = props;

Expand Down Expand Up @@ -236,6 +238,7 @@ export default function ListItemLink(props: ListItemLinkProps) {
secondaryTypographyProps={{ sx: { whiteSpace: 'pre' } }}
/>
)}
{!iconOnly && EndAction && <EndAction />}
</ListItemButton>
</StyledLi>
);
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/components/Sidebar/SidebarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ const SidebarItem = memo((props: SidebarItemProps) => {
icon,
fullWidth = true,
hide,
endAction,
...other
} = props;
const clusters = useSelectedClusters();
Expand Down Expand Up @@ -88,6 +89,7 @@ const SidebarItem = memo((props: SidebarItemProps) => {
iconOnly={!fullWidth}
hasParent={hasParent}
fullWidth={fullWidth}
endAction={endAction}
{...other}
/>
{subList.length > 0 && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
Cluster
</span>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation css-adq2br-MuiButtonBase-root-MuiButton-root"
tabindex="0"
type="button"
>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@
Cluster
</span>
</div>
<button
class="MuiButtonBase-root MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation MuiButton-root MuiButton-outlined MuiButton-outlinedPrimary MuiButton-sizeMedium MuiButton-outlinedSizeMedium MuiButton-colorPrimary MuiButton-disableElevation css-adq2br-MuiButtonBase-root-MuiButton-root"
tabindex="0"
type="button"
>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
</button>
<span
class="MuiTouchRipple-root css-8je8zh-MuiTouchRipple-root"
/>
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/Sidebar/sidebarSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

import { IconProps } from '@iconify/react';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import React from 'react';

export enum DefaultSidebars {
HOME = 'HOME',
Expand Down Expand Up @@ -59,6 +60,11 @@ export interface SidebarEntry {
/** The sidebar to display this item in. If not specified, it will be displayed in the default sidebar.
*/
sidebar?: DefaultSidebars | string;
/**
* An optional React component to render at the end of the sidebar item.
* Can be used for actions like favorites, cluster chooser, or navigation indicators.
*/
endAction?: React.ComponentType<any>;
}

export interface SidebarState {
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/Sidebar/useSidebarItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { useSelectedClusters } from '../../lib/k8s';
import { createRouteURL } from '../../lib/router';
import { useTypedSelector } from '../../redux/hooks';
import { DefaultSidebars, SidebarItemProps } from '.';
import { ClusterChooserAction } from './ClusterChooserAction';

/** Iterates over every entry in the list, including children */
const forEachEntry = (items: SidebarItemProps[], cb: (item: SidebarItemProps) => void) => {
Expand Down Expand Up @@ -67,6 +68,8 @@ export const useSidebarItems = (sidebarName: string = DefaultSidebars.IN_CLUSTER
? '/'
: createRouteURL('cluster', { cluster: Object.keys(clusters)[0] }),
divider: !shouldShowHomeItem,
// Add cluster chooser action to cluster items (when not showing home)
endAction: !shouldShowHomeItem ? ClusterChooserAction : undefined,
},
{
name: 'notifications',
Expand Down Expand Up @@ -112,6 +115,7 @@ export const useSidebarItems = (sidebarName: string = DefaultSidebars.IN_CLUSTER
label: selectedClusters.length ? t('Clusters') : t('glossary|Cluster'),
subtitle: selectedClusters.join('\n') || undefined,
icon: 'mdi:hexagon-multiple-outline',
endAction: ClusterChooserAction,
subList: [
{
name: 'namespaces',
Expand Down
55 changes: 13 additions & 42 deletions frontend/src/components/cluster/Chooser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ import Typography from '@mui/material/Typography';
import useMediaQuery from '@mui/material/useMediaQuery';
import _ from 'lodash';
import React, { isValidElement, PropsWithChildren } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { generatePath } from 'react-router';
Expand All @@ -51,32 +50,30 @@ import ActionButton from '../common/ActionButton';
import { DialogTitle } from '../common/Dialog';
import ErrorBoundary from '../common/ErrorBoundary';
import Loader from '../common/Loader';
import ClusterChooser from './ClusterChooser';
import ClusterChooserPopup from './ClusterChooserPopup';

export interface ClusterTitleProps {
/** @deprecated This prop is not used in the current implementation. */
clusters?: {
[clusterName: string]: Cluster;
};
cluster?: string;
/** @deprecated This prop is not used in the current implementation. */
onClick?: (event?: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
}

/**
* This component renders the cluster chooser button if it's registered by
* plugins. Otherwise, it doesn't show anything. This component still exists to
* provide compatibility with the plugins that expect a cluster chooser to exist.
*
* @param props
* @returns
*/
export function ClusterTitle(props: ClusterTitleProps) {
const { cluster, clusters, onClick } = props;
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const buttonRef = React.useRef<HTMLButtonElement>(null);
const { cluster } = props;
const arePluginsLoaded = useTypedSelector(state => state.plugins.loaded);
const ChooserButton = useTypedSelector(state => state.ui.clusterChooserButtonComponent);

useHotkeys(
'ctrl+shift+l',
() => {
setAnchorEl(buttonRef.current);
},
{ preventDefault: true }
);

if (!cluster) {
return null;
}
Expand All @@ -85,37 +82,11 @@ export function ClusterTitle(props: ClusterTitleProps) {
return null;
}

if (!ChooserButton && Object.keys(clusters || {}).length <= 1) {
if (!ChooserButton || !isValidElement(ChooserButton)) {
return null;
}

return (
<ErrorBoundary>
{ChooserButton ? (
isValidElement(ChooserButton) ? (
ChooserButton
) : (
<ChooserButton
clickHandler={e => {
onClick && onClick(e);
e?.currentTarget && setAnchorEl(e.currentTarget);
}}
cluster={cluster}
/>
)
) : (
<ClusterChooser
ref={buttonRef}
clickHandler={e => {
onClick && onClick(e);
e?.currentTarget && setAnchorEl(e.currentTarget);
}}
cluster={cluster}
/>
)}
<ClusterChooserPopup anchor={anchorEl} onClose={() => setAnchorEl(null)} />
</ErrorBoundary>
);
return <ErrorBoundary>{ChooserButton}</ErrorBoundary>;
}

interface ClusterButtonProps extends PropsWithChildren<{}> {
Expand Down
16 changes: 13 additions & 3 deletions frontend/src/components/cluster/ClusterChooserPopup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ import { Cluster } from '../../lib/k8s/cluster';
import { createRouteURL } from '../../lib/router';
import { getCluster, getClusterPrefixedPath } from '../../lib/util';

function ClusterListItem(props: { cluster: Cluster; onClick: () => void; selected?: boolean }) {
function ClusterListItem(props: {
cluster: Cluster;
onClick: (event: React.MouseEvent<HTMLElement>) => void;
selected?: boolean;
}) {
const { cluster, selected, onClick } = props;
const { t } = useTranslation();
const theme = useTheme();
Expand Down Expand Up @@ -209,6 +213,12 @@ function ClusterChooserPopup(props: ChooserPopupPros) {
}
}

function clusterClicked(event: React.MouseEvent<HTMLElement>, cluster: Cluster) {
event.stopPropagation();
event.preventDefault();
selectCluster(cluster);
}

if (!anchor) {
return null;
}
Expand Down Expand Up @@ -290,7 +300,7 @@ function ClusterChooserPopup(props: ChooserPopupPros) {
<ClusterListItem
key={`recent_cluster_${cluster.name}`}
cluster={cluster}
onClick={() => selectCluster(cluster)}
onClick={(event: React.MouseEvent<HTMLElement>) => clusterClicked(event, cluster)}
selected={cluster.name === getActiveDescendantCluster()?.name}
/>
))}
Expand All @@ -301,7 +311,7 @@ function ClusterChooserPopup(props: ChooserPopupPros) {
<ClusterListItem
key={`cluster_button_${cluster.name}`}
cluster={cluster}
onClick={() => selectCluster(cluster)}
onClick={(event: React.MouseEvent<HTMLElement>) => clusterClicked(event, cluster)}
selected={cluster.name === getActiveDescendantCluster()?.name}
/>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
<p
class="MuiTypography-root MuiTypography-body1 MuiTypography-alignCenter css-9ldkza-MuiTypography-root"
>
TypeError: fetch failed
Error: Unreachable
</p>
</div>
</div>
Expand Down
Loading