diff --git a/assets/frame.svg b/assets/frame.svg new file mode 100644 index 00000000..1dd24963 --- /dev/null +++ b/assets/frame.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/components/clusterTable/clusterTable.stories.tsx b/components/clusterTable/clusterTable.stories.tsx index 69fda6d3..ca8c1576 100644 --- a/components/clusterTable/clusterTable.stories.tsx +++ b/components/clusterTable/clusterTable.stories.tsx @@ -4,6 +4,7 @@ import { Story } from '@storybook/react'; import { ClusterStatus, ClusterType } from '../../types/provision'; import { InstallationType } from '../../types/redux'; import { noop } from '../../utils/noop'; +import { sortClustersByType } from '../../utils/sortClusterByType'; import { ClusterTable, ClusterInfo } from './clusterTable'; @@ -14,7 +15,7 @@ export default { const clusters: ClusterInfo[] = [ { - clusterName: 'kuberfirst-mgmt2', + clusterName: 'kuberfirst-mgmt', type: ClusterType.MANAGEMENT, cloudProvider: InstallationType.AWS, cloudRegion: 'ap-southeast-1', @@ -27,8 +28,8 @@ const clusters: ClusterInfo[] = [ nodes: 2, }, { - clusterName: 'kuberfirst-mgmt2', - type: ClusterType.MANAGEMENT, + clusterName: 'kuberfirst-worker-1', + type: ClusterType.WORKLOAD, cloudProvider: InstallationType.CIVO, cloudRegion: 'ap-southeast-1', nodes: 2, @@ -40,8 +41,8 @@ const clusters: ClusterInfo[] = [ domainName: 'yourdomain.com', }, { - clusterName: 'kuberfirst-mgmt2', - type: ClusterType.MANAGEMENT, + clusterName: 'kuberfirst-worker-2', + type: ClusterType.WORKLOAD, cloudProvider: InstallationType.DIGITAL_OCEAN, cloudRegion: 'ap-southeast-1', nodes: 2, @@ -53,7 +54,7 @@ const clusters: ClusterInfo[] = [ domainName: 'yourdomain.com', }, { - clusterName: 'kuberfirst-mgmt2', + clusterName: 'kuberfirst-worker-3', type: ClusterType.WORKLOAD, cloudProvider: InstallationType.DIGITAL_OCEAN, cloudRegion: 'ap-southeast-1', @@ -66,7 +67,7 @@ const clusters: ClusterInfo[] = [ domainName: 'yourdomain.com', }, { - clusterName: 'kuberfirst-mgmt2', + clusterName: 'kuberfirst-worker-4', type: ClusterType.WORKLOAD, cloudProvider: InstallationType.VULTR, cloudRegion: 'ap-southeast-1', @@ -80,8 +81,19 @@ const clusters: ClusterInfo[] = [ }, ]; -const DefaultTemplate: Story = (args) => ( - -); +const { managementCluster, workloadClusters } = sortClustersByType(clusters); + +const DefaultTemplate: Story = (args) => + managementCluster ? ( + + ) : ( + <> + ); export const Default = DefaultTemplate.bind({}); diff --git a/components/clusterTable/clusterTable.styled.ts b/components/clusterTable/clusterTable.styled.ts index 74484d55..f07b2d22 100644 --- a/components/clusterTable/clusterTable.styled.ts +++ b/components/clusterTable/clusterTable.styled.ts @@ -1,4 +1,4 @@ -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import { styled as muiStyled } from '@mui/material/styles'; import TableCell, { tableCellClasses } from '@mui/material/TableCell'; import { @@ -7,14 +7,21 @@ import { tableRowClasses, tableBodyClasses, IconButton, - iconButtonClasses, typographyClasses, Box, + TableContainer, + Table, } from '@mui/material'; import Typography from '../typography'; import Tag from '../tag'; -import { CHEFS_HAT, PASTEL_LIGHT_BLUE, SALTBOX_BLUE, VOLCANIC_SAND } from '../../constants/colors'; +import { + CHEFS_HAT, + PASTEL_LIGHT_BLUE, + ROCK_BLUE, + SALTBOX_BLUE, + VOLCANIC_SAND, +} from '../../constants/colors'; export const Menu = styled(Box)` position: absolute; @@ -28,12 +35,20 @@ export const Menu = styled(Box)` z-index: 1; `; -export const StyledIconButton = muiStyled(IconButton)(() => ({ - [`&.${iconButtonClasses.root}`]: { - opacity: 0, - pointerEvents: 'none', - }, -})); +export const StyledIconButton = styled(IconButton)<{ expanded?: boolean }>` + svg { + color: ${ROCK_BLUE}; + transition: transform 0.3s ease; + } + + ${({ expanded }) => + expanded && + css` + svg { + transform: rotate(180deg); + } + `} +`; export const StyledTableBody = muiStyled(TableBody)(() => ({ [`&.${tableBodyClasses.root}`]: { @@ -90,3 +105,15 @@ export const StyledCellText = muiStyled(Typography)(() => ({ fontWeight: 400, }, })); + +export const StyledTable = styled(Table)` + border-collapse: collapse; + margin: 5px; + height: fit-content; + margin: 0 28px; +`; + +export const StyledTableContainer = styled(TableContainer)` + display: flex; + height: 100%; +`; diff --git a/components/clusterTable/clusterTable.tsx b/components/clusterTable/clusterTable.tsx index 9b594820..240ad12e 100644 --- a/components/clusterTable/clusterTable.tsx +++ b/components/clusterTable/clusterTable.tsx @@ -5,13 +5,10 @@ import React, { useCallback, useRef, } from 'react'; -import Table from '@mui/material/Table'; -import TableContainer from '@mui/material/TableContainer'; import TableHead from '@mui/material/TableHead'; -import { Box, Collapse, IconButton, List, ListItem, ListItemButton } from '@mui/material'; +import { IconButton, List, ListItem, ListItemButton } from '@mui/material'; import MoreHorizIcon from '@mui/icons-material/MoreHoriz'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import Image from 'next/image'; import moment from 'moment'; @@ -21,22 +18,25 @@ import civoLogo from '../../assets/civo_logo.svg'; import digitalOceanLogo from '../../assets/digital_ocean_logo.svg'; import vultrLogo from '../../assets/vultr_logo.svg'; import { CLUSTER_TAG_CONFIG } from '../../constants'; -import { DODGER_BLUE, FIRE_BRICK, ROCK_BLUE } from '../../constants/colors'; +import { DODGER_BLUE, FIRE_BRICK } from '../../constants/colors'; import { Cluster, ClusterStatus, ClusterType } from '../../types/provision'; import { InstallationType } from '../../types/redux'; import Typography from '../../components/typography'; import { useOnClickOutside } from '../../hooks/useOnClickOutside'; +import { noop } from '../../utils/noop'; import { StyledTableRow, StyledTableCell, StyledTag, StyledTableBody, - StyledIconButton, StyledTableHeading, StyledCellText, Menu, StyledHeaderCell, + StyledIconButton, + StyledTableContainer, + StyledTable, } from './clusterTable.styled'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -67,11 +67,15 @@ export type ClusterInfo = Pick< }; type ClusterRowProps = ClusterInfo & { - onMenuOpenClose: (clusterName?: string) => void; + expanded?: boolean; + onExpanseClick?: () => void; + onMenuOpenClose: (selectedCluster?: ClusterInfo) => void; onDeleteCluster: () => void; }; const ClusterRow: FunctionComponent = ({ + expanded, + onExpanseClick = noop, onDeleteCluster, onMenuOpenClose, ...rest @@ -87,7 +91,6 @@ const ClusterRow: FunctionComponent = ({ nodes, } = rest; - const [open, setOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const cloudLogoSrc = CLOUD_LOGO_OPTIONS[cloudProvider]; @@ -99,8 +102,8 @@ const ClusterRow: FunctionComponent = ({ const handleMenu = useCallback(() => { setMenuOpen(!menuOpen); - onMenuOpenClose(!menuOpen ? clusterName : undefined); - }, [menuOpen, onMenuOpenClose, clusterName]); + onMenuOpenClose(!menuOpen ? rest : undefined); + }, [menuOpen, onMenuOpenClose, rest]); const buttonRef = useRef(null); @@ -108,19 +111,18 @@ const ClusterRow: FunctionComponent = ({ return ( <> - + - setOpen(!open)}> - {open ? ( - - ) : ( - - )} - + {type === ClusterType.MANAGEMENT && ( + + + + )} @@ -141,7 +143,7 @@ const ClusterRow: FunctionComponent = ({ - {moment(new Date(creationDate as string)).format('DD MMM YYYY')} + {moment(new Date(creationDate)).format('DD MMM YYYY')} @@ -174,73 +176,76 @@ const ClusterRow: FunctionComponent = ({ )} - {open && ( - - - - TBD - - - - )} ); }; interface ClusterTableProps extends ComponentPropsWithoutRef<'div'> { - clusters: ClusterInfo[]; + managementCluster: ClusterInfo; + workloadClusters: ClusterInfo[]; onDeleteCluster: () => void; - onMenuOpenClose: (clusterName?: string) => void; + onMenuOpenClose: (selectedCluster?: ClusterInfo) => void; } export const ClusterTable: FunctionComponent = ({ - clusters, + managementCluster, + workloadClusters, onDeleteCluster, onMenuOpenClose, ...rest -}) => ( - - - - - - - Name - - - Cloud - - - Region - - - Nodes - - - Created - - - Created by - - - Status - - - - - - {clusters.map((cluster) => ( +}) => { + const [expanded, setExpanded] = useState(false); + + return ( + + + + + + + Name + + + Cloud + + + Region + + + Nodes + + + Created + + + Created by + + + Status + + + + + setExpanded(!expanded)} /> - ))} - -
-
-); + + {expanded && + workloadClusters.map((cluster) => ( + + ))} + + + + ); +}; diff --git a/containers/clusterManagement/index.tsx b/containers/clusterManagement/index.tsx index 11b88fa8..0394e998 100644 --- a/containers/clusterManagement/index.tsx +++ b/containers/clusterManagement/index.tsx @@ -1,4 +1,4 @@ -import React, { FunctionComponent, useCallback, useEffect, useRef, useState } from 'react'; +import React, { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Box, Snackbar, Tabs } from '@mui/material'; import { useRouter } from 'next/router'; @@ -15,7 +15,8 @@ import DeleteCluster from '../deleteCluster'; import TabPanel, { Tab, a11yProps } from '../../components/tab'; import { BISCAY, SALTBOX_BLUE } from '../../constants/colors'; import { Flow } from '../../components/flow'; -import { ClusterTable } from '../../components/clusterTable/clusterTable'; +import { ClusterInfo, ClusterTable } from '../../components/clusterTable/clusterTable'; +import { sortClustersByType } from '../../utils/sortClusterByType'; import { CreateClusterFlow } from './createClusterFlow'; import { Container, Content, Header } from './clusterManagement.styled'; @@ -27,7 +28,7 @@ enum MANAGEMENT_TABS { const ClusterManagement: FunctionComponent = () => { const [activeTab, setActiveTab] = useState(MANAGEMENT_TABS.LIST_VIEW); - const [selectedClusterName, setSelectedClusterName] = useState(); + const [selectedCluster, setSelectedCluster] = useState(); const isClusterZero = useAppSelector(({ config }) => config.isClusterZero); const { @@ -46,19 +47,21 @@ const ClusterManagement: FunctionComponent = () => { const dispatch = useAppDispatch(); - const { isDeleted, isDeleting, isError, clusters } = useAppSelector(({ api }) => api); + const { isDeleted, isDeleting, isError, managementCluster, workloadClusters } = useAppSelector( + ({ api }) => api, + ); const handleGetClusters = useCallback(async (): Promise => { await dispatch(getClusters()); }, [dispatch]); const handleDeleteCluster = useCallback(async () => { - if (selectedClusterName) { - await dispatch(deleteCluster({ clusterName: selectedClusterName })).unwrap(); + if (selectedCluster) { + await dispatch(deleteCluster({ clusterName: selectedCluster.clusterName })).unwrap(); handleGetClusters(); closeDeleteModal(); } - }, [dispatch, selectedClusterName, handleGetClusters, closeDeleteModal]); + }, [dispatch, selectedCluster, handleGetClusters, closeDeleteModal]); const handleCreateCluster = () => { dispatch(resetInstallState()); @@ -72,9 +75,9 @@ const ClusterManagement: FunctionComponent = () => { }; useEffect(() => { - if (isDeleting && !isDeleted && selectedClusterName) { + if (isDeleting && !isDeleted && selectedCluster) { interval.current = getClusterInterval({ - clusterName: selectedClusterName, + clusterName: selectedCluster.clusterName, }); handleGetClusters(); } @@ -131,11 +134,14 @@ const ClusterManagement: FunctionComponent = () => { - setSelectedClusterName(clusterName)} - /> + {managementCluster && ( + setSelectedCluster(clusterInfo)} + /> + )} @@ -148,7 +154,7 @@ const ClusterManagement: FunctionComponent = () => { }} open={isDeleted} autoHideDuration={3000} - message={`Cluster ${selectedClusterName} has been deleted`} + message={`Cluster ${selectedCluster?.clusterName} has been deleted`} /> { > - {selectedClusterName && ( + {selectedCluster && ( )} diff --git a/containers/deleteCluster/deleteCluster.stories.tsx b/containers/deleteCluster/deleteCluster.stories.tsx index 41a88a5f..0f923c40 100644 --- a/containers/deleteCluster/deleteCluster.stories.tsx +++ b/containers/deleteCluster/deleteCluster.stories.tsx @@ -3,25 +3,51 @@ import { Story } from '@storybook/react'; import Button from '../../components/button'; import { noop } from '../../utils/noop'; +import { ClusterStatus, ClusterType } from '../../types/provision'; +import { InstallationType } from '../../types/redux'; +import { ClusterInfo } from '../../components/clusterTable/clusterTable'; -import DeleteCluster from './'; +import DeleteCluster, { DeleteClusterProps } from './'; export default { title: 'Components/DeleteCluster', component: DeleteCluster, }; -const DefaultTemplate: Story = (args) => { - const [open, setOpen] = useState(false); +const clusters: ClusterInfo[] = [ + { + clusterName: 'kuberfirst-mgmt', + type: ClusterType.MANAGEMENT, + cloudProvider: InstallationType.AWS, + cloudRegion: 'ap-southeast-1', + creationDate: '05 Apr 2023, 12:24:56', + gitUser: 'Eleanor Carroll', + status: ClusterStatus.PROVISIONED, + adminEmail: 'admin@mycompany.com', + gitProvider: 'Github', + domainName: 'yourdomain.com', + nodes: 2, + }, + { + clusterName: 'kuberfirst-worker-1', + type: ClusterType.WORKLOAD, + cloudProvider: InstallationType.CIVO, + cloudRegion: 'ap-southeast-1', + nodes: 2, + creationDate: '05 Apr 2023, 12:24:56', + gitUser: 'Eleanor Carroll', + status: ClusterStatus.ERROR, + adminEmail: 'admin@mycompany.com', + gitProvider: 'Github', + domainName: 'yourdomain.com', + }, +]; + +const DefaultTemplate: Story = (args) => { + const [open, setOpen] = useState(true); return ( <> - setOpen(false)} - onDelete={noop} - {...args} - /> + setOpen(false)} onDelete={noop} /> @@ -29,4 +55,12 @@ const DefaultTemplate: Story = (args) => { ); }; -export const Default = DefaultTemplate.bind({}); +export const Management = DefaultTemplate.bind({}); +Management.args = { + cluster: clusters[0], +}; + +export const Worker = DefaultTemplate.bind({}); +Worker.args = { + cluster: clusters[1], +}; diff --git a/containers/deleteCluster/deleteCluster.styled.ts b/containers/deleteCluster/deleteCluster.styled.ts index 375a939e..904c0259 100644 --- a/containers/deleteCluster/deleteCluster.styled.ts +++ b/containers/deleteCluster/deleteCluster.styled.ts @@ -1,9 +1,10 @@ import styled from 'styled-components'; import Column from '../../components/column'; +import NextLinkComp from '../../components/nextLink'; export const Content = styled(Column)` - gap: 24px; + gap: 8px; margin-left: 38px; `; @@ -19,3 +20,11 @@ export const Header = styled.div` gap: 12px; margin-bottom: 8px; `; + +export const NextLink = styled(NextLinkComp)` + a { + display: flex; + align-items: center; + gap: 8px; + } +`; diff --git a/containers/deleteCluster/index.tsx b/containers/deleteCluster/index.tsx index 7608b9fe..8e062dbb 100644 --- a/containers/deleteCluster/index.tsx +++ b/containers/deleteCluster/index.tsx @@ -1,47 +1,70 @@ import React, { FunctionComponent, useState } from 'react'; import { Box } from '@mui/material'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import LaunchOutlinedIcon from '@mui/icons-material/LaunchOutlined'; import Typography from '../../components/typography'; import TextFieldWithRef from '../../components/textField'; import Modal from '../../components/modal'; import { LAUGHING_ORANGE } from '../../constants/colors'; import Button from '../../components/button'; +import { ClusterInfo } from '../../components/clusterTable/clusterTable'; +import { ClusterType } from '../../types/provision'; -import { Content, Footer, Header } from './deleteCluster.styled'; +import { Content, Footer, Header, NextLink } from './deleteCluster.styled'; export interface DeleteClusterProps { - clusterName: string; + cluster: ClusterInfo; isOpen: boolean; onClose: () => void; onDelete: () => void; } const DeleteCluster: FunctionComponent = ({ - clusterName, + cluster, isOpen, onClose, onDelete, }) => { const [matchingClusterName, setMatchingClusterName] = useState(''); + const isManagementCluster = cluster.type === ClusterType.MANAGEMENT; + return (
- Delete {clusterName} + Delete {cluster.clusterName}
- - Are you sure you want to delete the cluster {clusterName}? This action - cannot be undone. - - setMatchingClusterName(e.target.value)} - /> + {isManagementCluster ? ( + <> + Deleting a management cluster is carried out via the CLI. + + Note: deleting a management cluster will also delete all it's + corresponding worker clusters + + + <> + How to delete a management cluster + + + + + ) : ( + <> + + Are you sure you want to delete the cluster {cluster.clusterName}? + This action cannot be undone. + + setMatchingClusterName(e.target.value)} + /> + + )}
diff --git a/containers/header/index.tsx b/containers/header/index.tsx index 6aa96e63..dca005e5 100644 --- a/containers/header/index.tsx +++ b/containers/header/index.tsx @@ -17,7 +17,9 @@ const Header: FunctionComponent = () => { const { pathname } = useRouter(); const { isEnabled } = useFeatureFlag('cluster-management'); const { clusters, selectedCluster } = useAppSelector(({ api, cluster }) => ({ - clusters: api.clusters, + clusters: api.managementCluster + ? [api.managementCluster, ...api.workloadClusters] + : [...api.workloadClusters], selectedCluster: cluster.selectedCluster, })); diff --git a/pages/services.tsx b/pages/services.tsx index 06979ccd..3cb7b7e9 100644 --- a/pages/services.tsx +++ b/pages/services.tsx @@ -15,7 +15,9 @@ const ServicesPage: FunctionComponent = ({ isClusterZero }) = const { selectedCluster, clusters } = useAppSelector(({ cluster, api }) => ({ selectedCluster: cluster.selectedCluster, - clusters: api.clusters, + clusters: api.managementCluster + ? [api.managementCluster, ...api.workloadClusters] + : [...api.workloadClusters], })); const hasExistingCluster = useMemo( diff --git a/redux/slices/api.slice.ts b/redux/slices/api.slice.ts index 786a53ef..18aeb805 100644 --- a/redux/slices/api.slice.ts +++ b/redux/slices/api.slice.ts @@ -8,6 +8,7 @@ import { getCluster, getClusters, } from '../thunks/api.thunk'; +import { sortClustersByType } from '../../utils/sortClusterByType'; import { Cluster, ClusterCreationStep, @@ -24,7 +25,8 @@ export interface ApiState { status?: ClusterStatus; isError: boolean; lastErrorCondition?: string; - clusters: Array; + managementCluster?: Cluster; + workloadClusters: Cluster[]; selectedCluster?: Cluster; completedSteps: Array<{ label: string; order: number }>; cloudDomains: Array; @@ -43,7 +45,7 @@ export const initialState: ApiState = { isDeleted: false, status: undefined, loading: false, - clusters: [], + workloadClusters: [], selectedCluster: undefined, completedSteps: [], cloudDomains: [], @@ -116,12 +118,17 @@ const apiSlice = createSlice({ .addCase(getClusters.fulfilled, (state, { payload }: PayloadAction>) => { state.loading = false; state.isError = false; - state.clusters = payload; + + const { managementCluster, workloadClusters } = sortClustersByType(payload); + + state.managementCluster = managementCluster; + state.workloadClusters = workloadClusters; }) .addCase(getClusters.rejected, (state) => { state.loading = false; state.isError = true; - state.clusters = []; + state.managementCluster = undefined; + state.workloadClusters = []; }) .addCase(getCloudDomains.fulfilled, (state, { payload }: PayloadAction>) => { state.cloudDomains = payload; diff --git a/utils/sortClusterByType.ts b/utils/sortClusterByType.ts new file mode 100644 index 00000000..d0519924 --- /dev/null +++ b/utils/sortClusterByType.ts @@ -0,0 +1,19 @@ +import { Cluster, ClusterType } from '../types/provision'; + +export function sortClustersByType(clusters: Cluster[]) { + let managementCluster: Cluster | undefined; + const workloadClusters: Cluster[] = []; + + for (const cluster of clusters) { + if (cluster.type === ClusterType.MANAGEMENT) { + managementCluster = cluster; + } else { + workloadClusters.push(cluster); + } + } + + return { + managementCluster, + workloadClusters, + }; +}