diff --git a/ui/cypress/integration/node/nodetabs.spec.js b/ui/cypress/integration/node/nodetabs.spec.js index 5082cb199f..b9d5823ecb 100644 --- a/ui/cypress/integration/node/nodetabs.spec.js +++ b/ui/cypress/integration/node/nodetabs.spec.js @@ -137,9 +137,10 @@ describe('Node page volumes tabs', () => { it('brings me to the loki-vol volume page', () => { cy.stubHistory(); + cy.get('[data-cy="volume_table_name_cell"]') - .contains('td', 'loki-vol') - .click(); + .contains('div', 'loki-vol') + .click({ force: true }); cy.get('@historyPush').should( 'be.calledWith', '/volumes/loki-vol/overview?node=master-0', diff --git a/ui/cypress/integration/volume/volumelist.spec.js b/ui/cypress/integration/volume/volumelist.spec.js index 89ef04aa42..e50f3f7206 100644 --- a/ui/cypress/integration/volume/volumelist.spec.js +++ b/ui/cypress/integration/volume/volumelist.spec.js @@ -20,15 +20,21 @@ describe('Volume list', () => { ); }); - it('brings me to the overview tab of master-0-alertmanager Volume', () => { + it('brings me to the overview tab of worker-0-burry-1 Volume', () => { + // After implementing the virtualized table, not all the volumes are visible at the first render. + // So we should test the first several volumes which are visiable. + cy.visit('/volumes'); cy.stubHistory(); + // The application re-renders, it's possible the element we're interacting with has become "dead" + // cy... failed because the element has been detached from the DOM cy.get('[data-cy="volume_table_name_cell"]') - .contains('master-1-prometheus') - .click(); + .contains('worker-0-burry-1') + .click({ force: true }); + cy.get('@historyPush').should('be.calledWithExactly', { - pathname: '/volumes/master-1-prometheus/overview', + pathname: '/volumes/worker-0-burry-1/overview', search: '', }); }); @@ -38,10 +44,10 @@ describe('Volume list', () => { cy.stubHistory(); cy.get('[data-cy="volume_table_name_cell"]') - .contains('prom-m0-reldev') - .click(); + .contains('master-0-alertmanager') + .click({ force: true }); cy.get('@historyPush').should('be.calledOnce').and('be.calledWithExactly', { - pathname: '/volumes/prom-m0-reldev/metrics', + pathname: '/volumes/master-0-alertmanager/metrics', search: 'from=now-7d', }); }); diff --git a/ui/package-lock.json b/ui/package-lock.json index 0cdeeb1e74..840b64a441 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -15008,9 +15008,9 @@ } }, "react-table": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.5.1.tgz", - "integrity": "sha512-rprrUElCqvj79lyY2XbUoYLzwA5Mm4CGS8ElQ8OyzocvmkvCcmunvvfbpIg9Jm9HnMBjVZcVyPFPZ1BFelIBKw==" + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/react-table/-/react-table-7.6.2.tgz", + "integrity": "sha512-urwNZTieb+xg/+BITUIrqdH5jZfJlw7rKVAAq25iXpBPwbQojLCEKJuGycLbVwn8fzU+Ovly3y8HHNaLNrPCvQ==" }, "react-test-renderer": { "version": "17.0.1", @@ -15074,6 +15074,20 @@ "react-lifecycles-compat": "^3.0.4" } }, + "react-virtualized-auto-sizer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.2.tgz", + "integrity": "sha512-MYXhTY1BZpdJFjUovvYHVBmkq79szK/k7V3MO+36gJkWGkrXKtyr4vCPtpphaTLRAdDNoYEYFZWE8LjN+PIHNg==" + }, + "react-window": { + "version": "1.8.6", + "resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.6.tgz", + "integrity": "sha512-8VwEEYyjz6DCnGBsd+MgkD0KJ2/OXFULyDtorIiTz+QzwoP94tBoA7CnbtyXMm+cCeAUER5KJcPtWl9cpKbOBg==", + "requires": { + "@babel/runtime": "^7.0.0", + "memoize-one": ">=3.1.1 <6" + } + }, "read-only-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 4edb2b2d95..6bee5d6a7e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -22,8 +22,10 @@ "react-router-dom": "^5.1.0", "react-scripts": "^3.4.4", "react-select": "^3.0.8", - "react-table": "^7.2.2", + "react-table": "^7.6.2", "react-virtualized": "^9.21.0", + "react-virtualized-auto-sizer": "^1.0.2", + "react-window": "^1.8.6", "redux": "^4.0.1", "redux-oidc": "^3.1.0", "redux-saga": "^1.0.2", diff --git a/ui/src/components/CommonLayoutStyle.js b/ui/src/components/CommonLayoutStyle.js index d91068ddc9..f77535eac4 100644 --- a/ui/src/components/CommonLayoutStyle.js +++ b/ui/src/components/CommonLayoutStyle.js @@ -87,7 +87,8 @@ export const SortIncentive = styled.span` display: none; `; -export const TableHeader = styled.th` +export const TableHeader = styled.span` + padding: ${padding.base}; &:hover { ${SortIncentive} { display: block; diff --git a/ui/src/components/NodeListTable.js b/ui/src/components/NodeListTable.js index 84182bb96c..b69fa59aa0 100644 --- a/ui/src/components/NodeListTable.js +++ b/ui/src/components/NodeListTable.js @@ -15,11 +15,6 @@ import { fontSize, padding } from '@scality/core-ui/dist/style/theme'; import CircleStatus from './CircleStatus'; import { Button } from '@scality/core-ui'; import { intl } from '../translations/IntlGlobalProvider'; -import { - SortCaretWrapper, - SortIncentive, - TableHeader, -} from './CommonLayoutStyle'; import { compareHealth, useTableSortURLSync } from '../services/utils'; import { API_STATUS_READY, @@ -140,6 +135,24 @@ const StatusText = styled.div` }}; `; +export const SortCaretWrapper = styled.span` + padding-left: ${padding.smaller}; + position: absolute; +`; + +export const SortIncentive = styled.span` + position: absolute; + display: none; +`; + +export const TableHeader = styled.th` + &:hover { + ${SortIncentive} { + display: block; + } + } +`; + function GlobalFilter({ preGlobalFilteredRows, globalFilter, @@ -315,7 +328,7 @@ function Table({ columns, data, rowClicked, theme, selectedNodeName }) { }), ); return ( - + {column.render('Header')} {column.isSorted ? ( diff --git a/ui/src/components/VolumeListTable.js b/ui/src/components/VolumeListTable.js index de81cfb908..012226c5e9 100644 --- a/ui/src/components/VolumeListTable.js +++ b/ui/src/components/VolumeListTable.js @@ -9,7 +9,10 @@ import { useGlobalFilter, useAsyncDebounce, useSortBy, + useBlockLayout, } from 'react-table'; +import { FixedSizeList as List } from 'react-window'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { useQuery } from '../services/utils'; import { fontSize, @@ -40,19 +43,16 @@ const VolumeListContainer = styled.div` color: ${(props) => props.theme.brand.textPrimary}; font-family: 'Lato'; font-size: ${fontSize.base}; - border-color: ${(props) => props.theme.brand.borderLight}; background-color: ${(props) => props.theme.brand.primary}; - .sc-progressbarcontainer { - width: 100%; - } - .sc-progressbarcontainer > div { - background-color: ${(props) => props.theme.brand.secondaryDark1}; - } - .ReactTable .rt-thead { - overflow-y: scroll; - } + table { - border-spacing: 0; + display: block; + padding-bottom: 13px; + + thead { + width: 100%; + display: inline-block; + } .sc-select-container { width: 120px; height: 10px; @@ -63,6 +63,9 @@ const VolumeListContainer = styled.div` } tr { + display: table; + table-layout: fixed; + width: 100%; :last-child { td { border-bottom: 0; @@ -75,34 +78,29 @@ const VolumeListContainer = styled.div` font-weight: bold; height: 35px; text-align: left; - padding: ${padding.smaller}; + padding-top: 3px; cursor: pointer; - vertical-align: baseline; } td { margin: 0; - padding: 0.5rem; text-align: left; - padding: 5px; - border: none; + border-bottom: 1px solid ${(props) => props.theme.brand.border}; :last-child { border-right: 0; } } } -`; -const HeadRow = styled.tr` - width: 100%; - /* To display scroll bar on the table */ - display: table; - table-layout: fixed; + .sc-progressbarcontainer { + width: 100%; + } + .sc-progressbarcontainer > div { + background-color: ${(props) => props.theme.brand.secondaryDark1}; + } `; -const TableRow = styled(HeadRow)` - height: 48px; - border-bottom: 1px solid ${(props) => props.theme.brand.border}; +const TableRow = styled.div` &:hover, &:focus { background-color: ${(props) => props.theme.brand.backgroundBluer}; @@ -121,17 +119,9 @@ const TableRow = styled(HeadRow)` `; // * table body -const Body = styled.tbody` - /* To display scroll bar on the table */ +const Body = styled.div` display: block; height: calc(100vh - 250px); - overflow: auto; - overflow-y: scroll; -`; - -const Cell = styled.td` - overflow-wrap: break-word; - border-top: 1px solid #424242; `; const CreateVolumeButton = styled(Button)` @@ -140,9 +130,9 @@ const CreateVolumeButton = styled(Button)` const ActionContainer = styled.span` display: flex; - justify-content: space-between; - padding: ${padding.base}; flex-direction: row-reverse; + justify-content: space-between; + padding: ${padding.large} ${padding.base} ${padding.base} 20px; `; const TooltipContent = styled.div` @@ -151,6 +141,12 @@ const TooltipContent = styled.div` min-width: 60px; `; +const UnknownIcon = styled.i` + color: ${(props) => props.theme.brand.textSecondary}; + // Increase the height so that the users don't need to hover precisely on the hyphen. + height: 30px; +`; + function GlobalFilter({ preGlobalFilteredRows, globalFilter, @@ -280,6 +276,7 @@ function Table({ useFilters, useGlobalFilter, useSortBy, + useBlockLayout, ); // Synchronizes the params query with the Table sort state @@ -289,17 +286,86 @@ function Table({ ?.isSortedDesc; useTableSortURLSync(sorted, desc, data); + const RenderRow = React.useCallback( + ({ index, style }) => { + const row = rows[index]; + prepareRow(row); + + return ( + rowClicked(row), + // Note: + // We need to pass the style property to the row component. + // Otherwise when we scroll down, the next rows are flashing because they are re-rendered in loop. + style: { ...style, marginLeft: '5px' }, + })} + volumeName={volumeName} + row={row} + > + {row.cells.map((cell) => { + let cellProps = cell.getCellProps({ + style: { + ...cell.column.cellStyle, + // Vertically center the text in cells. + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + }, + }); + + if (cell.column.Header === 'Name') { + return ( +
+ {cell.render('Cell')} +
+ ); + } else if ( + cell.column.Header !== 'Name' && + cell.value === undefined + ) { + return ( +
+ + {intl.translate('unknown')} + + } + > + + +
+ ); + } else { + return ( +
+ {cell.render('Cell')} +
+ ); + } + })} +
+ ); + }, + [prepareRow, rowClicked, rows, volumeName, theme, data], + ); + return ( <> - - +
+
{/* The first row should be the search bar */} - -
- + + {headerGroups.map((headerGroup) => { return ( - +
{headerGroup.headers.map((column) => { const headerStyleProps = column.getHeaderProps( Object.assign(column.getSortByToggleProps(), { @@ -357,68 +429,48 @@ function Table({ ); })} - +
); })} -
+ {data.length === 0 ? ( - - - + + ) : null} - - {rows.map((row, i) => { - prepareRow(row); - return ( - rowClicked(row) })} - volumeName={volumeName} - row={row} + {/* is a
so it breaks the table layout, + we need to use
for all the parts of table(thead, tbody, tr, td...) and retrieve the defaullt styles by className. */} + + {({ height, width }) => ( + - {row.cells.map((cell) => { - let cellProps = cell.getCellProps({ - style: { - ...cell.column.cellStyle, - }, - }); - if (cell.column.Header === 'Name') { - return ( - - {cell.render('Cell')} - - ); - } else if ( - cell.column.Header !== 'Name' && - cell.value === undefined - ) { - return ( - -
{intl.translate('unknown')}
-
- ); - } else { - return {cell.render('Cell')}; - } - })} - - ); - })} + {RenderRow} +
+ )} +
-
+
+
) : null} -
No Volume -
+ ); } @@ -441,7 +493,10 @@ const VolumeListTable = (props) => { { Header: 'Health', accessor: 'health', - cellStyle: { textAlign: 'center', width: '90px' }, + cellStyle: { + textAlign: 'center', + width: '80px', + }, Cell: (cellProps) => { return ( @@ -452,6 +507,7 @@ const VolumeListTable = (props) => { { Header: 'Name', accessor: 'name', + cellStyle: { flex: 1 }, }, { Header: 'Usage', @@ -486,7 +542,7 @@ const VolumeListTable = (props) => { accessor: 'status', cellStyle: { textAlign: 'center', - width: isNodeColumn ? '70px' : '110px', + width: isNodeColumn ? '50px' : '110px', }, Cell: (cellProps) => { const volume = volumeListData?.find( @@ -523,7 +579,19 @@ const VolumeListTable = (props) => { ); default: - return
{intl.translate('unknown')}
; + return ( + {intl.translate('unknown')} + } + > + + + ); } }, sortType: 'status', @@ -533,7 +601,7 @@ const VolumeListTable = (props) => { accessor: 'latency', cellStyle: { textAlign: 'center', - width: isNodeColumn ? '70px' : '110px', + width: isNodeColumn ? '75px' : '110px', }, Cell: (cellProps) => { return cellProps.value !== undefined ? cellProps.value + ' µs' : null; @@ -542,7 +610,13 @@ const VolumeListTable = (props) => { ], [volumeListData, theme, isNodeColumn], ); - const nodeCol = { Header: 'Node', accessor: 'node' }; + const nodeCol = { + Header: 'Node', + accessor: 'node', + cellStyle: { + width: '100px', + }, + }; if (isNodeColumn) { columns.splice(2, 0, nodeCol); } diff --git a/ui/src/containers/VolumePage.js b/ui/src/containers/VolumePage.js index 6811731b22..45cbc13111 100644 --- a/ui/src/containers/VolumePage.js +++ b/ui/src/containers/VolumePage.js @@ -1,5 +1,5 @@ import React, { useEffect } from 'react'; -import { useRouteMatch, useHistory } from 'react-router'; +import { useRouteMatch } from 'react-router'; import { useSelector, useDispatch } from 'react-redux'; import VolumeContent from './VolumePageContent'; import { fetchPodsAction } from '../ducks/app/pods'; @@ -32,7 +32,6 @@ import { getVolumeListData } from '../services/NodeVolumesUtils'; import { Breadcrumb } from '@scality/core-ui'; import { PageContainer } from '../components/CommonLayoutStyle'; import { intl } from '../translations/IntlGlobalProvider'; -import { useQuery } from '../services/utils'; // component fetchs all the data used by volume page from redux store. // the data for : get the default metrics time span `last 24 hours`, and the component itself can change the time span base on the dropdown selection. @@ -41,8 +40,6 @@ const VolumePage = (props) => { const dispatch = useDispatch(); const match = useRouteMatch(); const currentVolumeName = match.params.name; - const query = useQuery(); - const history = useHistory(); useEffect(() => { if (currentVolumeName) @@ -80,7 +77,6 @@ const VolumePage = (props) => { (state) => state.app.volumes.currentVolumeObject, ); const pVList = useSelector((state) => state.app.volumes.pVList); - /* ** The PVCs list is used to check when the alerts will be mapped to the corresponding volumes ** in order to auto select the volume when all the data are there. @@ -96,21 +92,6 @@ const VolumePage = (props) => { getVolumeListData(state, props), ); - // If data has been retrieved and no volume is selected yet we select the first one - useEffect(() => { - if ( - volumeListData[0]?.name && - alerts.list?.length && - pVCList.length && - !currentVolumeName - ) { - history.replace({ - pathname: `/volumes/${volumeListData[0]?.name}/overview`, - search: query.toString(), - }); - } - }, [volumeListData, currentVolumeName, query, history, alerts.list, pVCList]); - return ( @@ -132,6 +113,7 @@ const VolumePage = (props) => { nodes={nodes} node={node} pVList={pVList} + pVCList={pVCList} pods={pods} alerts={alerts} volumeStats={volumeStats} diff --git a/ui/src/containers/VolumePageContent.js b/ui/src/containers/VolumePageContent.js index e84b503226..e2ef418dbf 100644 --- a/ui/src/containers/VolumePageContent.js +++ b/ui/src/containers/VolumePageContent.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect } from 'react'; import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { useHistory, useLocation, useRouteMatch } from 'react-router'; @@ -70,6 +70,7 @@ const VolumePageContent = (props) => { node, volumeListData, pVList, + pVCList, pods, alerts, volumeStats, @@ -82,8 +83,23 @@ const VolumePageContent = (props) => { const query = new URLSearchParams(location.search); const theme = useSelector((state) => state.config.theme); - const currentVolumeName = match.params.name; + + // If data has been retrieved and no volume is selected yet we select the first one + useEffect(() => { + if ( + volumeListData[0]?.name && + alerts.list?.length && + pVCList.length && + !currentVolumeName + ) { + history.replace({ + pathname: `/volumes/${volumeListData[0]?.name}/overview`, + search: query.toString(), + }); + } + }, [volumeListData, currentVolumeName, query, history, alerts.list, pVCList]); + const volume = volumes?.find( (volume) => volume.metadata.name === currentVolumeName, );