Skip to content

Commit 3a0f1df

Browse files
authored
Merge pull request #3119 from illume/home-multi
frontend: Home: Add multi cluster selection
2 parents 30db9f7 + 4384d4c commit 3a0f1df

20 files changed

+769
-708
lines changed

e2e-tests/tests/multiCluster.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,16 @@ test.describe('multi-cluster setup', () => {
1010
headlampPage = new HeadlampPage(page);
1111

1212
await headlampPage.navigateTopage('/', /Choose a cluster/);
13-
await expect(page.locator('h1:has-text("Home")')).toBeVisible();
14-
await expect(page.locator('h2:has-text("All Clusters")')).toBeVisible();
13+
await expect(page.locator('h1:has-text("All Clusters")')).toBeVisible();
1514
});
1615

1716
test("home page should display two cluster selection buttons labeled 'test' and 'test2'", async ({
1817
page,
1918
}) => {
20-
const buttons = page.locator('button p');
19+
const buttons = page.locator('td a');
2120
await expect(buttons).toHaveCount(2);
22-
await expect(page.locator('button p', { hasText: /^test$/ })).toBeVisible();
23-
await expect(page.locator('button p', { hasText: /^test2$/ })).toBeVisible();
21+
await expect(page.locator('td a', { hasText: /^test$/ })).toBeVisible();
22+
await expect(page.locator('td a', { hasText: /^test2$/ })).toBeVisible();
2423
});
2524

2625
test('home page should display a table containing exactly two rows, each representing a cluster entry', async ({
@@ -48,7 +47,8 @@ test.describe('multi-cluster setup', () => {
4847
const clusterRow = clusterAnchor.locator('../../..');
4948

5049
const clusterStatus = clusterRow.locator('td').nth(2).locator('p');
51-
await expect(clusterStatus).toHaveText('Active');
50+
await expect(clusterStatus).toBeVisible();
51+
await expect(clusterStatus).toHaveText(/Active|Plugin/);
5252
}
5353
});
5454

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { Icon } from '@iconify/react';
2+
import IconButton from '@mui/material/IconButton';
3+
import ListItemText from '@mui/material/ListItemText';
4+
import Menu from '@mui/material/Menu';
5+
import MenuItem from '@mui/material/MenuItem';
6+
import Tooltip from '@mui/material/Tooltip';
7+
import React from 'react';
8+
import { useTranslation } from 'react-i18next';
9+
import { useDispatch } from 'react-redux';
10+
import { useHistory } from 'react-router-dom';
11+
import helpers from '../../../helpers';
12+
import { deleteCluster } from '../../../lib/k8s/apiProxy';
13+
import { Cluster } from '../../../lib/k8s/cluster';
14+
import { createRouteURL } from '../../../lib/router';
15+
import { useId } from '../../../lib/util';
16+
import { setConfig } from '../../../redux/configSlice';
17+
import { useTypedSelector } from '../../../redux/reducers/reducers';
18+
import { ConfirmDialog } from '../../common';
19+
import ErrorBoundary from '../../common/ErrorBoundary/ErrorBoundary';
20+
21+
interface ClusterContextMenuProps {
22+
/** The cluster for the context menu to act on. */
23+
cluster: Cluster;
24+
}
25+
26+
/**
27+
* ClusterContextMenu component displays a context menu for a given cluster.
28+
*/
29+
export default function ClusterContextMenu({ cluster }: ClusterContextMenuProps) {
30+
const { t } = useTranslation(['translation']);
31+
const history = useHistory();
32+
const dispatch = useDispatch();
33+
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
34+
const menuId = useId('context-menu');
35+
const [openConfirmDialog, setOpenConfirmDialog] = React.useState<string | null>(null);
36+
const dialogs = useTypedSelector(state => state.clusterProvider.dialogs);
37+
const menuItems = useTypedSelector(state => state.clusterProvider.menuItems);
38+
39+
function removeCluster(cluster: Cluster) {
40+
deleteCluster(cluster.name || '')
41+
.then(config => {
42+
dispatch(setConfig(config));
43+
})
44+
.catch((err: Error) => {
45+
if (err.message === 'Not Found') {
46+
// TODO: create notification with error message
47+
}
48+
})
49+
.finally(() => {
50+
history.push('/');
51+
});
52+
}
53+
54+
function handleMenuClose() {
55+
setAnchorEl(null);
56+
}
57+
58+
return (
59+
<>
60+
<Tooltip title={t('Actions')}>
61+
<IconButton
62+
size="small"
63+
onClick={event => {
64+
setAnchorEl(event.currentTarget);
65+
}}
66+
aria-haspopup="menu"
67+
aria-controls={menuId}
68+
aria-label={t('Actions')}
69+
>
70+
<Icon icon="mdi:more-vert" />
71+
</IconButton>
72+
</Tooltip>
73+
<Menu
74+
id={menuId}
75+
anchorEl={anchorEl}
76+
open={Boolean(anchorEl)}
77+
onClose={() => {
78+
handleMenuClose();
79+
}}
80+
>
81+
<MenuItem
82+
onClick={() => {
83+
history.push(createRouteURL('cluster', { cluster: cluster.name }));
84+
handleMenuClose();
85+
}}
86+
>
87+
<ListItemText>{t('translation|View')}</ListItemText>
88+
</MenuItem>
89+
<MenuItem
90+
onClick={() => {
91+
history.push(createRouteURL('settingsCluster', { cluster: cluster.name }));
92+
handleMenuClose();
93+
}}
94+
>
95+
<ListItemText>{t('translation|Settings')}</ListItemText>
96+
</MenuItem>
97+
{helpers.isElectron() && cluster.meta_data?.source === 'dynamic_cluster' && (
98+
<MenuItem
99+
onClick={() => {
100+
setOpenConfirmDialog('deleteDynamic');
101+
handleMenuClose();
102+
}}
103+
>
104+
<ListItemText>{t('translation|Delete')}</ListItemText>
105+
</MenuItem>
106+
)}
107+
108+
{menuItems.map((Item, index) => {
109+
return (
110+
<Item
111+
cluster={cluster}
112+
setOpenConfirmDialog={setOpenConfirmDialog}
113+
handleMenuClose={handleMenuClose}
114+
key={index}
115+
/>
116+
);
117+
})}
118+
</Menu>
119+
<ConfirmDialog
120+
open={openConfirmDialog === 'deleteDynamic'}
121+
handleClose={() => setOpenConfirmDialog('')}
122+
onConfirm={() => {
123+
setOpenConfirmDialog('');
124+
removeCluster(cluster);
125+
}}
126+
title={t('translation|Delete Cluster')}
127+
description={t(
128+
'translation|Are you sure you want to remove the cluster "{{ clusterName }}"?',
129+
{
130+
clusterName: cluster.name,
131+
}
132+
)}
133+
/>
134+
{openConfirmDialog !== null &&
135+
dialogs.map((Dialog, index) => {
136+
return (
137+
<ErrorBoundary>
138+
<Dialog
139+
cluster={cluster}
140+
openConfirmDialog={openConfirmDialog}
141+
setOpenConfirmDialog={setOpenConfirmDialog}
142+
key={index}
143+
/>
144+
</ErrorBoundary>
145+
);
146+
})}
147+
</>
148+
);
149+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { Icon } from '@iconify/react';
2+
import { Button, useTheme } from '@mui/material';
3+
import Box from '@mui/material/Box';
4+
import Typography from '@mui/material/Typography';
5+
import { useTranslation } from 'react-i18next';
6+
import { generatePath, useHistory } from 'react-router-dom';
7+
import { useClustersConf, useClustersVersion } from '../../../lib/k8s';
8+
import { ApiError } from '../../../lib/k8s/apiProxy';
9+
import { Cluster } from '../../../lib/k8s/cluster';
10+
import { getClusterPrefixedPath } from '../../../lib/util';
11+
import { Link, Table } from '../../common';
12+
import ClusterContextMenu from './ClusterContextMenu';
13+
import { MULTI_HOME_ENABLED } from './config';
14+
import { getCustomClusterNames } from './customClusterNames';
15+
16+
/**
17+
* ClusterStatus component displays the status of a cluster.
18+
* It shows an icon and a message indicating whether the cluster is active, unknown, or has an error.
19+
*
20+
* @param {Object} props - The component props.
21+
* @param {ApiError|null} [props.error] - The error object if there is an error with the cluster.
22+
*/
23+
function ClusterStatus({ error }: { error?: ApiError | null }) {
24+
const { t } = useTranslation(['translation']);
25+
const theme = useTheme();
26+
27+
const stateUnknown = error === undefined;
28+
const hasReachError = error && error.status !== 401 && error.status !== 403;
29+
30+
return (
31+
<Box width="fit-content">
32+
<Box display="flex" alignItems="center" justifyContent="center">
33+
{hasReachError ? (
34+
<Icon icon="mdi:cloud-off" width={16} color={theme.palette.home.status.error} />
35+
) : stateUnknown ? (
36+
<Icon icon="mdi:cloud-question" width={16} color={theme.palette.home.status.unknown} />
37+
) : (
38+
<Icon
39+
icon="mdi:cloud-check-variant"
40+
width={16}
41+
color={theme.palette.home.status.success}
42+
/>
43+
)}
44+
<Typography
45+
variant="body2"
46+
style={{
47+
marginLeft: theme.spacing(1),
48+
color: hasReachError
49+
? theme.palette.home.status.error
50+
: !stateUnknown
51+
? theme.palette.home.status.success
52+
: undefined,
53+
}}
54+
>
55+
{hasReachError ? error.message : stateUnknown ? '⋯' : t('translation|Active')}
56+
</Typography>
57+
</Box>
58+
</Box>
59+
);
60+
}
61+
62+
export interface ClusterTableProps {
63+
/** Some clusters have custom names. */
64+
customNameClusters: ReturnType<typeof getCustomClusterNames>;
65+
/** Versions for each cluster. */
66+
versions: ReturnType<typeof useClustersVersion>[0];
67+
/** Errors for each cluster. */
68+
errors: ReturnType<typeof useClustersVersion>[1];
69+
/** Clusters configuration. */
70+
clusters: ReturnType<typeof useClustersConf>;
71+
/** Warnings for each cluster. */
72+
warningLabels: { [cluster: string]: string };
73+
}
74+
75+
/**
76+
* ClusterTable component displays a table of clusters with their status, origin, and version.
77+
*/
78+
export default function ClusterTable({
79+
customNameClusters,
80+
versions,
81+
errors,
82+
clusters,
83+
warningLabels,
84+
}: ClusterTableProps) {
85+
const history = useHistory();
86+
const { t } = useTranslation(['translation']);
87+
88+
/**
89+
* Gets the origin of a cluster.
90+
*
91+
* @param cluster
92+
* @returns A description of where the cluster is picked up from: dynamic, in-cluster, or from a kubeconfig file.
93+
*/
94+
function getOrigin(cluster: Cluster): string {
95+
if (cluster.meta_data?.source === 'kubeconfig') {
96+
const kubeconfigPath = process.env.KUBECONFIG ?? '~/.kube/config';
97+
return `Kubeconfig: ${kubeconfigPath}`;
98+
} else if (cluster.meta_data?.source === 'dynamic_cluster') {
99+
return t('translation|Plugin');
100+
} else if (cluster.meta_data?.source === 'in_cluster') {
101+
return t('translation|In-cluster');
102+
}
103+
return 'Unknown';
104+
}
105+
const viewClusters = t('View Clusters');
106+
107+
return (
108+
<Table
109+
columns={[
110+
{
111+
id: 'name',
112+
header: t('Name'),
113+
accessorKey: 'name',
114+
Cell: ({ row: { original } }) => (
115+
<Link routeName="cluster" params={{ cluster: original.name }}>
116+
{original.name}
117+
</Link>
118+
),
119+
},
120+
{
121+
header: t('Origin'),
122+
accessorFn: cluster => getOrigin(cluster),
123+
Cell: ({ row: { original } }) => (
124+
<Typography variant="body2">{getOrigin((clusters || {})[original.name])}</Typography>
125+
),
126+
},
127+
{
128+
header: t('Status'),
129+
accessorFn: cluster =>
130+
errors[cluster.name] === null ? 'Active' : errors[cluster.name]?.message,
131+
Cell: ({ row: { original } }) => <ClusterStatus error={errors[original.name]} />,
132+
},
133+
{ header: t('Warnings'), accessorFn: cluster => warningLabels[cluster.name] },
134+
{
135+
header: t('glossary|Kubernetes Version'),
136+
accessorFn: ({ name }) => versions[name]?.gitVersion || '⋯',
137+
},
138+
{
139+
header: '',
140+
muiTableBodyCellProps: {
141+
align: 'right',
142+
},
143+
accessorFn: cluster =>
144+
errors[cluster.name] === null ? 'Active' : errors[cluster.name]?.message,
145+
Cell: ({ row: { original: cluster } }) => {
146+
return <ClusterContextMenu cluster={cluster} />;
147+
},
148+
},
149+
]}
150+
data={Object.values(customNameClusters)}
151+
enableRowSelection={
152+
MULTI_HOME_ENABLED
153+
? row => {
154+
// Only allow selection if the cluster is working
155+
return !errors[row.original.name];
156+
}
157+
: false
158+
}
159+
initialState={{
160+
sorting: [{ id: 'name', desc: false }],
161+
}}
162+
muiToolbarAlertBannerProps={{
163+
sx: theme => ({
164+
background: theme.palette.background.muted,
165+
}),
166+
}}
167+
renderToolbarAlertBannerContent={({ table }) => (
168+
<Button
169+
variant="contained"
170+
sx={{
171+
marginLeft: 1,
172+
}}
173+
onClick={() => {
174+
history.push({
175+
pathname: generatePath(getClusterPrefixedPath(), {
176+
cluster: table
177+
.getSelectedRowModel()
178+
.rows.map(it => it.original.name)
179+
.join('+'),
180+
}),
181+
});
182+
}}
183+
>
184+
{viewClusters}
185+
</Button>
186+
)}
187+
/>
188+
);
189+
}

0 commit comments

Comments
 (0)