diff options
Diffstat (limited to 'opendc-web/opendc-web-ui/src/components')
34 files changed, 692 insertions, 633 deletions
diff --git a/opendc-web/opendc-web-ui/src/components/AppHeader.js b/opendc-web/opendc-web-ui/src/components/AppHeader.js index fd54b3ad..f9ef00aa 100644 --- a/opendc-web/opendc-web-ui/src/components/AppHeader.js +++ b/opendc-web/opendc-web-ui/src/components/AppHeader.js @@ -20,25 +20,57 @@ * SOFTWARE. */ -import { PageHeader } from '@patternfly/react-core' +import Image from 'next/image' +import PropTypes from 'prop-types' import React from 'react' +import { + Masthead, + MastheadMain, + MastheadBrand, + MastheadContent, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core' +import Link from 'next/link' import AppHeaderTools from './AppHeaderTools' -import { AppNavigation } from './AppNavigation' -import AppLogo from './AppLogo' +import AppHeaderUser from './AppHeaderUser' +import ProjectSelector from './context/ProjectSelector' -export function AppHeader() { - // eslint-disable-next-line @next/next/no-img-element - const logo = <img src="/img/logo.png" width={30} height={30} alt="OpenDC" /> +import styles from './AppHeader.module.scss' +export default function AppHeader({ nav }) { return ( - <PageHeader - logo={logo} - logoProps={{ href: '/' }} - logoComponent={AppLogo} - headerTools={<AppHeaderTools />} - topNav={<AppNavigation />} - /> + <Masthead id="app-header" className={styles.header}> + <MastheadMain> + <MastheadBrand + className={styles.logo} + component={(props) => ( + <Link href="/projects"> + <a {...props} /> + </Link> + )} + > + <Image src="/img/logo.svg" alt="OpenDC logo" width={25} height={25} /> + <span>OpenDC</span> + </MastheadBrand> + </MastheadMain> + <MastheadContent> + <Toolbar id="toolbar" isFullHeight isStatic> + <ToolbarContent> + <ToolbarItem> + <ProjectSelector /> + </ToolbarItem> + {nav && <ToolbarItem>{nav}</ToolbarItem>} + <AppHeaderTools /> + <AppHeaderUser /> + </ToolbarContent> + </Toolbar> + </MastheadContent> + </Masthead> ) } -AppHeader.propTypes = {} +AppHeader.propTypes = { + nav: PropTypes.node, +} diff --git a/opendc-web/opendc-web-ui/src/components/AppLogo.module.scss b/opendc-web/opendc-web-ui/src/components/AppHeader.module.scss index 3d228cb6..73ef553c 100644 --- a/opendc-web/opendc-web-ui/src/components/AppLogo.module.scss +++ b/opendc-web/opendc-web-ui/src/components/AppHeader.module.scss @@ -20,10 +20,21 @@ * SOFTWARE. */ -.appLogo { +.header.header { + /* Increase precedence */ + --pf-c-masthead--m-display-inline__content--MinHeight: 3rem; + --pf-c-masthead--m-display-inline__main--MinHeight: 3rem; + + --pf-c-masthead--c-context-selector--Width: 200px; +} + +.logo { span { - margin-left: 4px; + margin-left: 8px; color: #fff; + align-self: center; + font-weight: 600; + font-size: 0.9rem; } &:hover, diff --git a/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js b/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js index 3e58b209..499bceef 100644 --- a/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js +++ b/opendc-web/opendc-web-ui/src/components/AppHeaderTools.js @@ -21,29 +21,19 @@ */ import { - Avatar, Button, ButtonVariant, Dropdown, - DropdownGroup, DropdownItem, - DropdownToggle, KebabToggle, - PageHeaderTools, - PageHeaderToolsGroup, - PageHeaderToolsItem, - Skeleton, + ToolbarGroup, + ToolbarItem, } from '@patternfly/react-core' -import { useState } from 'react' -import { useAuth } from '../auth' +import { useReducer } from 'react' import { GithubIcon, HelpIcon } from '@patternfly/react-icons' function AppHeaderTools() { - const { logout, user, isAuthenticated, isLoading } = useAuth() - const username = isAuthenticated || isLoading ? user?.name : 'Anonymous' - const avatar = isAuthenticated || isLoading ? user?.picture : '/img/avatar.svg' - - const [isKebabDropdownOpen, setKebabDropdownOpen] = useState(false) + const [isKebabDropdownOpen, toggleKebabDropdown] = useReducer((t) => !t, false) const kebabDropdownItems = [ <DropdownItem key={0} @@ -55,23 +45,14 @@ function AppHeaderTools() { />, ] - const [isDropdownOpen, setDropdownOpen] = useState(false) - const userDropdownItems = [ - <DropdownGroup key="group 2"> - <DropdownItem - key="group 2 logout" - isDisabled={!isAuthenticated} - onClick={() => logout({ returnTo: window.location.origin })} - > - Logout - </DropdownItem> - </DropdownGroup>, - ] - return ( - <PageHeaderTools> - <PageHeaderToolsGroup visibility={{ default: 'hidden', lg: 'visible' }}> - <PageHeaderToolsItem> + <ToolbarGroup + variant="icon-button-group" + alignment={{ default: 'alignRight' }} + spacer={{ default: 'spacerNone', md: 'spacerMd' }} + > + <ToolbarGroup variant="icon-button-group" visibility={{ default: 'hidden', lg: 'visible' }}> + <ToolbarItem> <Button component="a" href="https://github.com/atlarge-research/opendc" @@ -81,8 +62,8 @@ function AppHeaderTools() { > <GithubIcon /> </Button> - </PageHeaderToolsItem> - <PageHeaderToolsItem> + </ToolbarItem> + <ToolbarItem> <Button component="a" href="https://opendc.org/" @@ -92,45 +73,18 @@ function AppHeaderTools() { > <HelpIcon /> </Button> - </PageHeaderToolsItem> - </PageHeaderToolsGroup> - <PageHeaderToolsGroup> - <PageHeaderToolsItem visibility={{ lg: 'hidden' }}> - <Dropdown - isPlain - position="right" - toggle={<KebabToggle onToggle={() => setKebabDropdownOpen(!isKebabDropdownOpen)} />} - isOpen={isKebabDropdownOpen} - dropdownItems={kebabDropdownItems} - /> - </PageHeaderToolsItem> - <PageHeaderToolsItem visibility={{ default: 'hidden', md: 'visible' }}> - <Dropdown - isPlain - position="right" - isOpen={isDropdownOpen} - toggle={ - <DropdownToggle onToggle={() => setDropdownOpen(!isDropdownOpen)}> - {username ?? ( - <Skeleton - fontSize="xs" - width="150px" - className="pf-u-display-inline-flex" - screenreaderText="Loading username" - /> - )} - </DropdownToggle> - } - dropdownItems={userDropdownItems} - /> - </PageHeaderToolsItem> - </PageHeaderToolsGroup> - {avatar ? ( - <Avatar src={avatar} alt="Avatar image" /> - ) : ( - <Skeleton className="pf-c-avatar" shape="circle" width="2.25rem" screenreaderText="Loading avatar" /> - )} - </PageHeaderTools> + </ToolbarItem> + </ToolbarGroup> + <ToolbarItem visibility={{ lg: 'hidden' }}> + <Dropdown + isPlain + position="right" + toggle={<KebabToggle onToggle={toggleKebabDropdown} />} + isOpen={isKebabDropdownOpen} + dropdownItems={kebabDropdownItems} + /> + </ToolbarItem> + </ToolbarGroup> ) } diff --git a/opendc-web/opendc-web-ui/src/components/AppHeaderUser.js b/opendc-web/opendc-web-ui/src/components/AppHeaderUser.js new file mode 100644 index 00000000..e271accb --- /dev/null +++ b/opendc-web/opendc-web-ui/src/components/AppHeaderUser.js @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2022 AtLarge Research + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + Dropdown, + DropdownToggle, + Skeleton, + ToolbarItem, + DropdownItem, + DropdownGroup, + Avatar, +} from '@patternfly/react-core' +import { useReducer } from 'react' +import { useAuth } from '../auth' + +export default function AppHeaderUser() { + const { logout, user, isAuthenticated, isLoading } = useAuth() + const username = isAuthenticated || isLoading ? user?.name : 'Anonymous' + const avatar = isAuthenticated || isLoading ? user?.picture : '/img/avatar.svg' + + const [isDropdownOpen, toggleDropdown] = useReducer((t) => !t, false) + const userDropdownItems = [ + <DropdownGroup key="group 2"> + <DropdownItem + key="group 2 logout" + isDisabled={!isAuthenticated} + onClick={() => logout({ returnTo: window.location.origin })} + > + Logout + </DropdownItem> + </DropdownGroup>, + ] + + const avatarComponent = avatar ? ( + <Avatar src={avatar} alt="Avatar image" size="sm" /> + ) : ( + <Skeleton className="pf-c-avatar" shape="circle" width="24px" screenreaderText="Loading avatar" /> + ) + + return ( + <ToolbarItem visibility={{ default: 'hidden', md: 'visible' }}> + <Dropdown + isFullHeight + position="right" + isOpen={isDropdownOpen} + toggle={ + <DropdownToggle onToggle={toggleDropdown} icon={avatarComponent}> + {username ?? ( + <Skeleton + fontSize="xs" + width="150px" + className="pf-u-display-inline-flex" + screenreaderText="Loading username" + /> + )} + </DropdownToggle> + } + dropdownItems={userDropdownItems} + /> + </ToolbarItem> + ) +} diff --git a/opendc-web/opendc-web-ui/src/components/AppLogo.js b/opendc-web/opendc-web-ui/src/components/AppLogo.js deleted file mode 100644 index 92663295..00000000 --- a/opendc-web/opendc-web-ui/src/components/AppLogo.js +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2021 AtLarge Research - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import PropTypes from 'prop-types' -import Link from 'next/link' -import { appLogo } from './AppLogo.module.scss' - -function AppLogo({ href, children, className, ...props }) { - return ( - <> - <Link href={href}> - <a {...props} className={`${className ?? ''} ${appLogo}`}> - {children} - <span>OpenDC</span> - </a> - </Link> - </> - ) -} - -AppLogo.propTypes = { - href: PropTypes.string.isRequired, - children: PropTypes.node, - className: PropTypes.string, -} - -export default AppLogo diff --git a/opendc-web/opendc-web-ui/src/components/AppNavigation.js b/opendc-web/opendc-web-ui/src/components/AppNavigation.js deleted file mode 100644 index 77c683a2..00000000 --- a/opendc-web/opendc-web-ui/src/components/AppNavigation.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2021 AtLarge Research - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -import { Nav, NavItem, NavList } from '@patternfly/react-core' -import { useRouter } from 'next/router' -import NavItemLink from './util/NavItemLink' - -export function AppNavigation() { - const { pathname } = useRouter() - - return ( - <Nav variant="horizontal"> - <NavList> - <NavItem - id="projects" - to="/projects" - itemId={0} - component={NavItemLink} - isActive={pathname === '/projects' || pathname === '/projects/[project]'} - > - Projects - </NavItem> - </NavList> - </Nav> - ) -} - -AppNavigation.propTypes = {} diff --git a/opendc-web/opendc-web-ui/src/components/AppPage.js b/opendc-web/opendc-web-ui/src/components/AppPage.js index 25afaf9a..2893146e 100644 --- a/opendc-web/opendc-web-ui/src/components/AppPage.js +++ b/opendc-web/opendc-web-ui/src/components/AppPage.js @@ -21,7 +21,7 @@ */ import PropTypes from 'prop-types' -import { AppHeader } from './AppHeader' +import AppHeader from './AppHeader' import React from 'react' import { Page, PageGroup, PageBreadcrumb } from '@patternfly/react-core' diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js index a99b60c0..059cfea8 100644 --- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.js @@ -23,9 +23,10 @@ import PropTypes from 'prop-types' import { ContextSelector as PFContextSelector, ContextSelectorItem } from '@patternfly/react-core' import { useMemo, useState } from 'react' -import { contextSelector } from './ContextSelector.module.scss' -function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label }) { +import styles from './ContextSelector.module.scss' + +function ContextSelector({ id, type = 'page', toggleText, items, onSelect, onToggle, isOpen, isFullHeight }) { const [searchValue, setSearchValue] = useState('') const filteredItems = useMemo( () => items.filter(({ name }) => name.toLowerCase().indexOf(searchValue.toLowerCase()) !== -1) || items, @@ -34,11 +35,11 @@ function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label return ( <PFContextSelector - className={contextSelector} - toggleText={activeItem ? `${label}: ${activeItem.name}` : label} + id={id} + className={type === 'page' && styles.pageSelector} + toggleText={toggleText} onSearchInputChange={(value) => setSearchValue(value)} searchInputValue={searchValue} - isOpen={isOpen} onToggle={(_, isOpen) => onToggle(isOpen)} onSelect={(event) => { const targetId = +event.target.value @@ -47,6 +48,8 @@ function ContextSelector({ activeItem, items, onSelect, onToggle, isOpen, label onSelect(target) onToggle(!isOpen) }} + isOpen={isOpen} + isFullHeight={isFullHeight} > {filteredItems.map((item) => ( <ContextSelectorItem key={item.id} value={item.id}> @@ -63,12 +66,14 @@ const Item = PropTypes.shape({ }) ContextSelector.propTypes = { - activeItem: Item, + id: PropTypes.string, + type: PropTypes.oneOf(['app', 'page']), items: PropTypes.arrayOf(Item).isRequired, + toggleText: PropTypes.string, onSelect: PropTypes.func.isRequired, onToggle: PropTypes.func.isRequired, isOpen: PropTypes.bool, - label: PropTypes.string, + isFullHeight: PropTypes.bool, } export default ContextSelector diff --git a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss index 07b7b1d0..4f86ac64 100644 --- a/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss +++ b/opendc-web/opendc-web-ui/src/components/context/ContextSelector.module.scss @@ -20,10 +20,11 @@ * SOFTWARE. */ -.contextSelector.contextSelector { +.pageSelector.pageSelector { // Ensure this selector has precedence over the default one margin-right: 20px; + --pf-c-context-selector__menu--ZIndex: var(--pf-global--ZIndex--lg); --pf-c-context-selector__toggle--PaddingTop: var(--pf-global--spacer--sm); --pf-c-context-selector__toggle--PaddingRight: 0; --pf-c-context-selector__toggle--PaddingBottom: var(--pf-global--spacer--sm); diff --git a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js index c4f2d50e..e401e6fc 100644 --- a/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/PortfolioSelector.js @@ -34,7 +34,8 @@ function PortfolioSelector({ activePortfolio }) { return ( <ContextSelector - label="Portfolio" + id="portfolio" + toggleText={activePortfolio ? `Portfolio: ${activePortfolio.name}` : 'Select portfolio'} activeItem={activePortfolio} items={portfolios} onSelect={(portfolio) => router.push(`/projects/${portfolio.project.id}/portfolios/${portfolio.number}`)} diff --git a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js index 7721e04c..f2791b38 100644 --- a/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/ProjectSelector.js @@ -22,24 +22,28 @@ import { useRouter } from 'next/router' import { useState } from 'react' -import { useProjects } from '../../data/project' +import { useProjects, useProject } from '../../data/project' import { Project } from '../../shapes' import ContextSelector from './ContextSelector' -function ProjectSelector({ activeProject }) { +function ProjectSelector() { const router = useRouter() + const projectId = +router.query['project'] const [isOpen, setOpen] = useState(false) + const { data: activeProject } = useProject(+projectId) const { data: projects = [] } = useProjects({ enabled: isOpen }) return ( <ContextSelector - label="Project" - activeItem={activeProject} + id="project" + type="app" + toggleText={activeProject ? activeProject.name : 'Select project'} items={projects} onSelect={(project) => router.push(`/projects/${project.id}`)} onToggle={setOpen} isOpen={isOpen} + isFullHeight /> ) } diff --git a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js index 9cae4cbf..355d9f4b 100644 --- a/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js +++ b/opendc-web/opendc-web-ui/src/components/context/TopologySelector.js @@ -34,7 +34,8 @@ function TopologySelector({ activeTopology }) { return ( <ContextSelector - label="Topology" + id="topology" + toggleText={activeTopology ? `Topology: ${activeTopology.name}` : 'Select topology'} activeItem={activeTopology} items={topologies} onSelect={(topology) => router.push(`/projects/${topology.project.id}/topologies/${topology.number}`)} diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js index f63f0c7f..f50105ed 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/PortfolioResults.js @@ -20,12 +20,11 @@ * SOFTWARE. */ -import React from 'react' -import PropTypes from 'prop-types' -import { Bar, CartesianGrid, ComposedChart, ErrorBar, ResponsiveContainer, Scatter, XAxis, YAxis } from 'recharts' -import { AVAILABLE_METRICS, METRIC_NAMES, METRIC_UNITS } from '../../util/available-metrics' import { mean, std } from 'mathjs' -import approx from 'approximate-number' +import React, { useMemo } from 'react' +import PropTypes from 'prop-types' +import { VictoryErrorBar } from 'victory-errorbar' +import { METRIC_NAMES, METRIC_UNITS, AVAILABLE_METRICS } from '../../util/available-metrics' import { Bullseye, Card, @@ -41,15 +40,36 @@ import { Spinner, Title, } from '@patternfly/react-core' +import { Chart, ChartAxis, ChartBar, ChartTooltip } from '@patternfly/react-charts' import { ErrorCircleOIcon, CubesIcon } from '@patternfly/react-icons' import { usePortfolio } from '../../data/project' import PortfolioResultInfo from './PortfolioResultInfo' import NewScenario from './NewScenario' -const PortfolioResults = ({ projectId, portfolioId }) => { - const { status, data: scenarios = [] } = usePortfolio(projectId, portfolioId, { - select: (portfolio) => portfolio.scenarios, - }) +function PortfolioResults({ projectId, portfolioId }) { + const { status, data: portfolio } = usePortfolio(projectId, portfolioId) + const scenarios = useMemo(() => portfolio?.scenarios ?? [], [portfolio]) + + const label = ({ datum }) => + `${datum.x}: ${datum.y.toLocaleString()} ± ${datum.errorY.toLocaleString()} ${METRIC_UNITS[datum.metric]}` + const selectedMetrics = new Set(portfolio?.targets?.metrics ?? []) + const dataPerMetric = useMemo(() => { + const dataPerMetric = {} + AVAILABLE_METRICS.forEach((metric) => { + dataPerMetric[metric] = scenarios + .filter((scenario) => scenario.job?.results) + .map((scenario) => ({ + metric, + x: scenario.name, + y: mean(scenario.job.results[metric]), + errorY: std(scenario.job.results[metric]), + label, + })) + }) + return dataPerMetric + }, [scenarios]) + + const categories = useMemo(() => ({ x: scenarios.map((s) => s.name).reverse() }), [scenarios]) if (status === 'loading') { return ( @@ -94,59 +114,57 @@ const PortfolioResults = ({ projectId, portfolioId }) => { ) } - const dataPerMetric = {} - - AVAILABLE_METRICS.forEach((metric) => { - dataPerMetric[metric] = scenarios - .filter((scenario) => scenario.job?.results) - .map((scenario) => ({ - name: scenario.name, - value: mean(scenario.job.results[metric]), - errorX: std(scenario.job.results[metric]), - })) - }) - return ( <Grid hasGutter> - {AVAILABLE_METRICS.map((metric) => ( - <GridItem xl={6} lg={12} key={metric}> - <Card> - <CardHeader> - <CardActions> - <PortfolioResultInfo metric={metric} /> - </CardActions> - <CardTitle>{METRIC_NAMES[metric]}</CardTitle> - </CardHeader> - <CardBody> - <ResponsiveContainer aspect={16 / 9} width="100%"> - <ComposedChart - data={dataPerMetric[metric]} - margin={{ left: 35, bottom: 15 }} - layout="vertical" - > - <CartesianGrid strokeDasharray="3 3" /> - <XAxis - tickFormatter={(tick) => approx(tick)} - label={{ value: METRIC_UNITS[metric], position: 'bottom', offset: 0 }} - type="number" - /> - <YAxis dataKey="name" type="category" /> - <Bar dataKey="value" fill="#3399FF" isAnimationActive={false} /> - <Scatter dataKey="value" opacity={0} isAnimationActive={false}> - <ErrorBar - dataKey="errorX" - width={10} - strokeWidth={3} - stroke="#FF6600" - direction="x" + {AVAILABLE_METRICS.map( + (metric) => + selectedMetrics.has(metric) && ( + <GridItem xl={6} lg={12} key={metric}> + <Card> + <CardHeader> + <CardActions> + <PortfolioResultInfo metric={metric} /> + </CardActions> + <CardTitle>{METRIC_NAMES[metric]}</CardTitle> + </CardHeader> + <CardBody> + <Chart + width={650} + height={250} + padding={{ + top: 10, + bottom: 60, + left: 130, + }} + domainPadding={25} + > + <ChartAxis /> + <ChartAxis + dependentAxis + showGrid + label={METRIC_UNITS[metric]} + fixLabelOverlap + /> + <ChartBar + categories={categories} + data={dataPerMetric[metric]} + labelComponent={<ChartTooltip constrainToVisibleArea />} + barWidth={25} + horizontal + /> + <VictoryErrorBar + categories={categories} + data={dataPerMetric[metric]} + errorY={(d) => d.errorY} + labelComponent={<></>} + horizontal /> - </Scatter> - </ComposedChart> - </ResponsiveContainer> - </CardBody> - </Card> - </GridItem> - ))} + </Chart> + </CardBody> + </Card> + </GridItem> + ) + )} </Grid> ) } diff --git a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js index 68647957..8dc52f7a 100644 --- a/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js +++ b/opendc-web/opendc-web-ui/src/components/portfolios/ScenarioTable.js @@ -20,8 +20,9 @@ * SOFTWARE. */ +import { Bullseye } from '@patternfly/react-core' import Link from 'next/link' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' +import { TableComposable, Thead, Tr, Th, Tbody, Td, ActionsColumn } from '@patternfly/react-table' import React from 'react' import { Portfolio, Status } from '../../shapes' import TableEmptyState from '../util/TableEmptyState' @@ -33,65 +34,65 @@ function ScenarioTable({ portfolio, status }) { const projectId = portfolio?.project?.id const scenarios = portfolio?.scenarios ?? [] - const columns = ['Name', 'Topology', 'Trace', 'State'] - const rows = - scenarios.length > 0 - ? scenarios.map((scenario) => { - const topology = scenario.topology - - return [ - scenario.name, - { - title: topology ? ( - <Link href={`/projects/${projectId}/topologies/${topology.number}`}> - <a>{topology.name}</a> - </Link> - ) : ( - 'Unknown Topology' - ), - }, - `${scenario.workload.trace.name} (${scenario.workload.samplingFraction * 100}%)`, - { title: <ScenarioState state={scenario.job.state} /> }, - ] - }) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 4 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading Scenarios" - emptyTitle="No scenarios" - emptyText="You have not created any scenario for this portfolio yet. Click the New Scenario button to create one." - /> - ), - }, - ], - }, - ] - - const actionResolver = (_, { rowIndex }) => [ + const actions = ({ number }) => [ { title: 'Delete Scenario', - onClick: (_, rowId) => deleteScenario({ projectId: projectId, number: scenarios[rowId].number }), - isDisabled: rowIndex === 0, + onClick: () => deleteScenario({ projectId: projectId, number }), + isDisabled: number === 0, }, ] return ( - <Table - aria-label="Scenario List" - variant="compact" - cells={columns} - rows={rows} - actionResolver={scenarios.length > 0 ? actionResolver : undefined} - > - <TableHeader /> - <TableBody /> - </Table> + <TableComposable aria-label="Scenario List" variant="compact"> + <Thead> + <Tr> + <Th>Name</Th> + <Th>Topology</Th> + <Th>Trace</Th> + <Th>State</Th> + </Tr> + </Thead> + <Tbody> + {scenarios.map((scenario) => ( + <Tr key={scenario.id}> + <Td dataLabel="Name">{scenario.name}</Td> + <Td dataLabel="Topology"> + {scenario.topology ? ( + <Link href={`/projects/${projectId}/topologies/${scenario.topology.number}`}> + <a>{scenario.topology.name}</a> + </Link> + ) : ( + 'Unknown Topology' + )} + , + </Td> + <Td dataLabel="Workload">{`${scenario.workload.trace.name} (${ + scenario.workload.samplingFraction * 100 + }%)`}</Td> + <Td dataLabel="State"> + <ScenarioState state={scenario.job.state} /> + </Td> + <Td isActionCell> + <ActionsColumn items={actions(scenario)} /> + </Td> + </Tr> + ))} + {scenarios.length === 0 && ( + <Tr> + <Td colSpan={4}> + <Bullseye> + <TableEmptyState + status={status} + loadingTitle="Loading Scenarios" + emptyTitle="No scenarios" + emptyText="You have not created any scenario for this portfolio yet. Click the New Scenario button to create one." + /> + </Bullseye> + </Td> + </Tr> + )} + </Tbody> + </TableComposable> ) } diff --git a/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js index 285217e9..7c6d129c 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js +++ b/opendc-web/opendc-web-ui/src/components/projects/FilterPanel.js @@ -6,7 +6,7 @@ import { filterPanel } from './FilterPanel.module.scss' export const FILTERS = { SHOW_ALL: 'All Projects', SHOW_OWN: 'My Projects', SHOW_SHARED: 'Shared with me' } const FilterPanel = ({ onSelect, activeFilter = 'SHOW_ALL' }) => ( - <ToggleGroup className={`${filterPanel} mb-2`}> + <ToggleGroup className={`${filterPanel} pf-u-mb-sm`}> {Object.keys(FILTERS).map((filter) => ( <ToggleGroupItem key={filter} diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js b/opendc-web/opendc-web-ui/src/components/projects/NewProject.js deleted file mode 100644 index bfa7c01a..00000000 --- a/opendc-web/opendc-web-ui/src/components/projects/NewProject.js +++ /dev/null @@ -1,39 +0,0 @@ -import React, { useState } from 'react' -import { Button } from '@patternfly/react-core' -import { PlusIcon } from '@patternfly/react-icons' -import { useNewProject } from '../../data/project' -import { buttonContainer } from './NewProject.module.scss' -import TextInputModal from '../util/modals/TextInputModal' - -/** - * A container for creating a new project. - */ -const NewProject = () => { - const [isVisible, setVisible] = useState(false) - const { mutate: addProject } = useNewProject() - - const onSubmit = (name) => { - if (name) { - addProject({ name }) - } - setVisible(false) - } - - return ( - <> - <div className={buttonContainer}> - <Button - icon={<PlusIcon />} - color="primary" - className="pf-u-float-right" - onClick={() => setVisible(true)} - > - New Project - </Button> - </div> - <TextInputModal title="New Project" label="Project name" isOpen={isVisible} callback={onSubmit} /> - </> - ) -} - -export default NewProject diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss b/opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss deleted file mode 100644 index 5a0e74fc..00000000 --- a/opendc-web/opendc-web-ui/src/components/projects/NewProject.module.scss +++ /dev/null @@ -1,26 +0,0 @@ -/*! - * Copyright (c) 2021 AtLarge Research - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ - -.buttonContainer { - flex: 0 1 auto; - padding: 20px 0; -} diff --git a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js index be4256e3..780ec034 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js +++ b/opendc-web/opendc-web-ui/src/components/projects/NewTopologyModal.js @@ -57,9 +57,10 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan setErrors({ name: true }) return false } else { - const candidate = topologies.find((topology) => topology.id === originTopology) || { projectId, rooms: [] } + const candidate = topologies.find((topology) => topology.id === originTopology) || { rooms: [] } const topology = produce(candidate, (draft) => { - delete draft.id + delete draft.project + draft.projectId = projectId draft.name = name }) onSubmitUpstream(topology) @@ -87,7 +88,12 @@ const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCan <TextInput id="name" name="name" type="text" isRequired ref={nameInput} /> </FormGroup> <FormGroup label="Topology to duplicate" fieldId="origin" isRequired> - <FormSelect id="origin" name="origin" value={originTopology} onChange={setOriginTopology}> + <FormSelect + id="origin" + name="origin" + value={originTopology} + onChange={(v) => setOriginTopology(+v)} + > <FormSelectOption value={-1} key={-1} label="None - start from scratch" /> {topologies.map((topology) => ( <FormSelectOption value={topology.id} key={topology.id} label={topology.name} /> diff --git a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js index aa679843..0afeaeaf 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/PortfolioTable.js @@ -20,64 +20,75 @@ * SOFTWARE. */ +import { Bullseye } from '@patternfly/react-core' import PropTypes from 'prop-types' import Link from 'next/link' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' +import { TableComposable, Thead, Tbody, Tr, Th, Td, ActionsColumn } from '@patternfly/react-table' import React from 'react' import TableEmptyState from '../util/TableEmptyState' import { usePortfolios, useDeletePortfolio } from '../../data/project' -const PortfolioTable = ({ projectId }) => { +function PortfolioTable({ projectId }) { const { status, data: portfolios = [] } = usePortfolios(projectId) const { mutate: deletePortfolio } = useDeletePortfolio() - const columns = ['Name', 'Scenarios', 'Metrics', 'Repeats'] - const rows = - portfolios.length > 0 - ? portfolios.map((portfolio) => [ - { - title: ( - <Link href={`/projects/${projectId}/portfolios/${portfolio.number}`}>{portfolio.name}</Link> - ), - }, - portfolio.scenarios.length === 1 ? '1 scenario' : `${portfolio.scenarios.length} scenarios`, - portfolio.targets.metrics.length === 1 ? '1 metric' : `${portfolio.targets.metrics.length} metrics`, - portfolio.targets.repeats === 1 ? '1 repeat' : `${portfolio.targets.repeats} repeats`, - ]) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 4 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading portfolios" - emptyTitle="No portfolios" - emptyText="You have not created any portfolio for this project yet. Click the New Portfolio button to create one." - /> - ), - }, - ], - }, - ] - - const actions = - portfolios.length > 0 - ? [ - { - title: 'Delete Portfolio', - onClick: (_, rowId) => deletePortfolio({ projectId, number: portfolios[rowId].number }), - }, - ] - : [] + const actions = (portfolio) => [ + { + title: 'Delete Portfolio', + onClick: () => deletePortfolio({ projectId, number: portfolio.number }), + }, + ] return ( - <Table aria-label="Portfolio List" variant="compact" cells={columns} rows={rows} actions={actions}> - <TableHeader /> - <TableBody /> - </Table> + <TableComposable aria-label="Portfolio List" variant="compact"> + <Thead> + <Tr> + <Th>Name</Th> + <Th>Scenarios</Th> + <Th>Metrics</Th> + <Th>Repeats</Th> + </Tr> + </Thead> + <Tbody> + {portfolios.map((portfolio) => ( + <Tr key={portfolio.id}> + <Td dataLabel="Name"> + <Link href={`/projects/${projectId}/portfolios/${portfolio.number}`}>{portfolio.name}</Link> + </Td> + <Td dataLabel="Scenarios"> + {portfolio.scenarios.length === 1 + ? '1 scenario' + : `${portfolio.scenarios.length} scenarios`} + </Td> + <Td dataLabel="Metrics"> + {portfolio.targets.metrics.length === 1 + ? '1 metric' + : `${portfolio.targets.metrics.length} metrics`} + </Td> + <Td dataLabel="Repeats"> + {portfolio.targets.repeats === 1 ? '1 repeat' : `${portfolio.targets.repeats} repeats`} + </Td> + <Td isActionCell> + <ActionsColumn items={actions(portfolio)} /> + </Td> + </Tr> + ))} + {portfolios.length === 0 && ( + <Tr> + <Td colSpan={4}> + <Bullseye> + <TableEmptyState + status={status} + loadingTitle="Loading portfolios" + emptyTitle="No portfolios" + emptyText="You have not created any portfolio for this project yet. Click the New Portfolio button to create one." + /> + </Bullseye> + </Td> + </Tr> + )} + </Tbody> + </TableComposable> ) } diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js new file mode 100644 index 00000000..70f02812 --- /dev/null +++ b/opendc-web/opendc-web-ui/src/components/projects/ProjectCollection.js @@ -0,0 +1,137 @@ +import { + Gallery, + Bullseye, + EmptyState, + EmptyStateIcon, + Card, + CardTitle, + CardActions, + DropdownItem, + CardHeader, + Dropdown, + KebabToggle, + CardBody, + CardHeaderMain, + TextVariants, + Text, + TextContent, + Tooltip, + Button, + Label, +} from '@patternfly/react-core' +import { PlusIcon, FolderIcon, TrashIcon } from '@patternfly/react-icons' +import PropTypes from 'prop-types' +import React, { useReducer, useMemo } from 'react' +import { Project, Status } from '../../shapes' +import { parseAndFormatDateTime } from '../../util/date-time' +import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP, AUTH_NAME_MAP } from '../../util/authorizations' +import NavItemLink from '../util/NavItemLink' +import TableEmptyState from '../util/TableEmptyState' + +function ProjectCard({ project, onDelete }) { + const [isKebabOpen, toggleKebab] = useReducer((t) => !t, false) + const { id, role, name, updatedAt } = project + const Icon = AUTH_ICON_MAP[role] + + return ( + <Card + isCompact + isRounded + isFlat + className="pf-u-min-height" + style={{ '--pf-u-min-height--MinHeight': '175px' }} + > + <CardHeader className="pf-u-flex-grow-1"> + <CardHeaderMain className="pf-u-align-self-flex-start"> + <FolderIcon /> + </CardHeaderMain> + <CardActions> + <Tooltip content={AUTH_DESCRIPTION_MAP[role]}> + <Label icon={<Icon />}>{AUTH_NAME_MAP[role]}</Label> + </Tooltip> + <Dropdown + isPlain + position="right" + toggle={<KebabToggle className="pf-u-px-0" onToggle={toggleKebab} />} + isOpen={isKebabOpen} + dropdownItems={[ + <DropdownItem + key="trash" + onClick={() => { + onDelete() + toggleKebab() + }} + position="right" + icon={<TrashIcon />} + > + Delete + </DropdownItem>, + ]} + /> + </CardActions> + </CardHeader> + <CardTitle component={NavItemLink} className="pf-u-pb-0" href={`/projects/${id}`}> + {name} + </CardTitle> + <CardBody isFilled={false}> + <TextContent> + <Text component={TextVariants.small}>Last modified {parseAndFormatDateTime(updatedAt)}</Text> + </TextContent> + </CardBody> + </Card> + ) +} + +function ProjectCollection({ status, projects, onDelete, onCreate, isFiltering }) { + const sortedProjects = useMemo(() => { + const res = [...projects] + res.sort((a, b) => (new Date(a.updatedAt) < new Date(b.updatedAt) ? 1 : -1)) + return res + }, [projects]) + + if (sortedProjects.length === 0) { + return ( + <TableEmptyState + status={status} + isFiltering={isFiltering} + loadingTitle="Loading Projects" + emptyTitle="No projects" + emptyText="You have not created any projects yet. Create a new project to get started quickly." + emptyAction={ + <Button icon={<PlusIcon />} onClick={onCreate}> + Create Project + </Button> + } + /> + ) + } + + return ( + <Gallery hasGutter aria-label="Available projects"> + {sortedProjects.map((project) => ( + <ProjectCard key={project.id} project={project} onDelete={() => onDelete(project)} /> + ))} + <Card isCompact isFlat isRounded style={{ borderStyle: 'dotted' }}> + <Bullseye> + <EmptyState> + <Button isBlock variant="link" onClick={onCreate}> + <EmptyStateIcon icon={PlusIcon} /> + <br /> + Create Project + </Button> + </EmptyState> + </Bullseye> + </Card> + </Gallery> + ) +} + +ProjectCollection.propTypes = { + status: Status.isRequired, + isFiltering: PropTypes.bool, + projects: PropTypes.arrayOf(Project).isRequired, + onDelete: PropTypes.func, + onCreate: PropTypes.func, +} + +export default ProjectCollection diff --git a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js b/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js deleted file mode 100644 index 6921578c..00000000 --- a/opendc-web/opendc-web-ui/src/components/projects/ProjectTable.js +++ /dev/null @@ -1,73 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import Link from 'next/link' -import { Project, Status } from '../../shapes' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' -import { parseAndFormatDateTime } from '../../util/date-time' -import { AUTH_DESCRIPTION_MAP, AUTH_ICON_MAP } from '../../util/authorizations' -import TableEmptyState from '../util/TableEmptyState' - -const ProjectTable = ({ status, projects, onDelete, isFiltering }) => { - const columns = ['Project name', 'Last edited', 'Access Rights'] - const rows = - projects.length > 0 - ? projects.map((project) => { - const Icon = AUTH_ICON_MAP[project.role] - return [ - { - title: <Link href={`/projects/${project.id}`}>{project.name}</Link>, - }, - parseAndFormatDateTime(project.updatedAt), - { - title: ( - <> - <Icon className="pf-u-mr-md" key="auth" /> {AUTH_DESCRIPTION_MAP[project.role]} - </> - ), - }, - ] - }) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 3 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading Projects" - isFiltering={isFiltering} - /> - ), - }, - ], - }, - ] - - const actions = - projects.length > 0 - ? [ - { - title: 'Delete Project', - onClick: (_, rowId) => onDelete(projects[rowId]), - }, - ] - : [] - - return ( - <Table aria-label="Project List" variant="compact" cells={columns} rows={rows} actions={actions}> - <TableHeader /> - <TableBody /> - </Table> - ) -} - -ProjectTable.propTypes = { - status: Status.isRequired, - isFiltering: PropTypes.bool, - projects: PropTypes.arrayOf(Project).isRequired, - onDelete: PropTypes.func, -} - -export default ProjectTable diff --git a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js index ced5304a..62deace0 100644 --- a/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js +++ b/opendc-web/opendc-web-ui/src/components/projects/TopologyTable.js @@ -20,66 +20,67 @@ * SOFTWARE. */ +import { Bullseye } from '@patternfly/react-core' import PropTypes from 'prop-types' import Link from 'next/link' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' +import { Tr, Th, Thead, Td, ActionsColumn, Tbody, TableComposable } from '@patternfly/react-table' import React from 'react' import TableEmptyState from '../util/TableEmptyState' import { parseAndFormatDateTime } from '../../util/date-time' import { useTopologies, useDeleteTopology } from '../../data/topology' -const TopologyTable = ({ projectId }) => { +function TopologyTable({ projectId }) { const { status, data: topologies = [] } = useTopologies(projectId) const { mutate: deleteTopology } = useDeleteTopology() - const columns = ['Name', 'Rooms', 'Last Edited'] - const rows = - topologies.length > 0 - ? topologies.map((topology) => [ - { - title: <Link href={`/projects/${projectId}/topologies/${topology.number}`}>{topology.name}</Link>, - }, - topology.rooms.length === 1 ? '1 room' : `${topology.rooms.length} rooms`, - parseAndFormatDateTime(topology.updatedAt), - ]) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 3 }, - title: ( - <TableEmptyState - status={status} - loadingTitle="Loading topologies" - emptyTitle="No topologies" - emptyText="You have not created any topology for this project yet. Click the New Topology button to create one." - /> - ), - }, - ], - }, - ] - - const actionResolver = (_, { rowIndex }) => [ + const actions = ({ number }) => [ { title: 'Delete Topology', - onClick: (_, rowId) => deleteTopology({ projectId, number: topologies[rowId].number }), - isDisabled: rowIndex === 0, + onClick: () => deleteTopology({ projectId, number }), + isDisabled: number === 0, }, ] return ( - <Table - aria-label="Topology List" - variant="compact" - cells={columns} - rows={rows} - actionResolver={topologies.length > 0 ? actionResolver : () => []} - > - <TableHeader /> - <TableBody /> - </Table> + <TableComposable aria-label="Topology List" variant="compact"> + <Thead> + <Tr> + <Th>Name</Th> + <Th>Rooms</Th> + <Th>Last Edited</Th> + </Tr> + </Thead> + <Tbody> + {topologies.map((topology) => ( + <Tr key={topology.id}> + <Td dataLabel="Name"> + <Link href={`/projects/${projectId}/topologies/${topology.number}`}>{topology.name}</Link> + </Td> + <Td dataLabel="Rooms"> + {topology.rooms.length === 1 ? '1 room' : `${topology.rooms.length} rooms`} + </Td> + <Td dataLabel="Last Edited">{parseAndFormatDateTime(topology.updatedAt)}</Td> + <Td isActionCell> + <ActionsColumn items={actions(topology)} /> + </Td> + </Tr> + ))} + {topologies.length === 0 && ( + <Tr> + <Td colSpan={3}> + <Bullseye> + <TableEmptyState + status={status} + loadingTitle="Loading topologies" + emptyTitle="No topologies" + emptyText="You have not created any topology for this project yet. Click the New Topology button to create one." + /> + </Bullseye> + </Td> + </Tr> + )} + </Tbody> + </TableComposable> ) } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js index 49e5f095..7f7b4171 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/RoomTable.js @@ -1,63 +1,67 @@ -import { Button } from '@patternfly/react-core' +import { Button, Bullseye } from '@patternfly/react-core' import PropTypes from 'prop-types' import React from 'react' import { useDispatch } from 'react-redux' import { useTopology } from '../../data/topology' -import { Table, TableBody, TableHeader } from '@patternfly/react-table' +import { Tr, Th, Thead, TableComposable, Td, ActionsColumn, Tbody } from '@patternfly/react-table' import { deleteRoom } from '../../redux/actions/topology/room' import TableEmptyState from '../util/TableEmptyState' function RoomTable({ projectId, topologyId, onSelect }) { const dispatch = useDispatch() const { status, data: topology } = useTopology(projectId, topologyId) - const onDelete = (room) => dispatch(deleteRoom(room.id)) - - const columns = ['Name', 'Tiles', 'Racks'] - const rows = - topology?.rooms.length > 0 - ? topology.rooms.map((room) => { - const tileCount = room.tiles.length - const rackCount = room.tiles.filter((tile) => tile.rack).length - return [ - { - title: ( - <Button variant="link" isInline onClick={() => onSelect(room)}> - {room.name} - </Button> - ), - }, - tileCount === 1 ? '1 tile' : `${tileCount} tiles`, - rackCount === 1 ? '1 rack' : `${rackCount} racks`, - ] - }) - : [ - { - heightAuto: true, - cells: [ - { - props: { colSpan: 3 }, - title: <TableEmptyState status={status} loadingTitle="Loading Rooms" />, - }, - ], - }, - ] - - const actions = - topology?.rooms.length > 0 - ? [ - { - title: 'Delete room', - onClick: (_, rowId) => onDelete(topology.rooms[rowId]), - }, - ] - : [] + const actions = (room) => [ + { + title: 'Delete room', + onClick: () => onDelete(room), + }, + ] return ( - <Table aria-label="Room list" variant="compact" cells={columns} rows={rows} actions={actions}> - <TableHeader /> - <TableBody /> - </Table> + <TableComposable aria-label="Room list" variant="compact"> + <Thead> + <Tr> + <Th>Name</Th> + <Th>Tiles</Th> + <Th>Racks</Th> + </Tr> + </Thead> + <Tbody> + {topology?.rooms.map((room) => { + const tileCount = room.tiles.length + const rackCount = room.tiles.filter((tile) => tile.rack).length + return ( + <Tr key={room.id}> + <Td dataLabel="Name"> + <Button variant="link" isInline onClick={() => onSelect(room)}> + {room.name} + </Button> + </Td> + <Td dataLabel="Tiles">{tileCount === 1 ? '1 tile' : `${tileCount} tiles`}</Td> + <Td dataLabel="Racks">{rackCount === 1 ? '1 rack' : `${rackCount} racks`}</Td> + <Td isActionCell> + <ActionsColumn items={actions(room)} /> + </Td> + </Tr> + ) + })} + {topology?.rooms.length === 0 && ( + <Tr> + <Td colSpan={4}> + <Bullseye> + <TableEmptyState + status={status} + loadingTitle="Loading Rooms" + emptyTitle="No rooms" + emptyText="There are currently no rooms in this topology. Open the Floor Plan to create a room" + /> + </Bullseye> + </Td> + </Tr> + )} + </Tbody> + </TableComposable> ) } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js b/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js index 47235c7e..ff583750 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/TopologyMap.js @@ -55,9 +55,9 @@ function TopologyMap() { </EmptyState> </Bullseye> ) : ( - <Drawer isExpanded={isExpanded} className="full-height"> + <Drawer isExpanded={isExpanded}> <DrawerContent panelContent={panelContent}> - <DrawerContentBody> + <DrawerContentBody style={{ position: 'relative' }}> <MapStage hotkeysRef={hotkeysRef} /> <Collapse onClick={() => setExpanded(true)} /> </DrawerContentBody> diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js index 7b96f548..8bf529b2 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.js @@ -15,7 +15,7 @@ import Toolbar from './controls/Toolbar' function MapStage({ hotkeysRef }) { const reduxContext = useContext(ReactReduxContext) const stageRef = useRef(null) - const { width = 100, height = 100 } = useResizeObserver({ ref: stageRef.current?.attrs?.container }) + const { width = 500, height = 500 } = useResizeObserver({ ref: stageRef.current?.attrs?.container }) const [[x, y], setPos] = useState([0, 0]) const [scale, setScale] = useState(1) diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss index d879b4c8..47c3dde2 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/MapStage.module.scss @@ -24,8 +24,6 @@ background-color: var(--pf-global--Color--light-200); position: relative; display: flex; - justify-content: center; - align-items: center; width: 100%; height: 100%; } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js index be1f3e45..a1ca7426 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/RackEnergyFillContainer.js @@ -3,24 +3,26 @@ import PropTypes from 'prop-types' import { useSelector } from 'react-redux' import RackFillBar from './elements/RackFillBar' -function RackSpaceFillContainer({ tileId, ...props }) { +function RackSpaceFillContainer({ rackId, ...props }) { const fillFraction = useSelector((state) => { + const rack = state.topology.racks[rackId] + if (!rack) { + return 0 + } + + const { machines, cpus, gpus, memories, storages } = state.topology let energyConsumptionTotal = 0 - const rack = state.topology.racks[state.topology.tiles[tileId].rack] - const machineIds = rack.machines - machineIds.forEach((machineId) => { - if (machineId !== null) { - const machine = state.topology.machines[machineId] - machine.cpus.forEach((id) => (energyConsumptionTotal += state.topology.cpus[id].energyConsumptionW)) - machine.gpus.forEach((id) => (energyConsumptionTotal += state.topology.gpus[id].energyConsumptionW)) - machine.memories.forEach( - (id) => (energyConsumptionTotal += state.topology.memories[id].energyConsumptionW) - ) - machine.storages.forEach( - (id) => (energyConsumptionTotal += state.topology.storages[id].energyConsumptionW) - ) + + for (const machineId of rack.machines) { + if (!machineId) { + continue } - }) + const machine = machines[machineId] + machine.cpus.forEach((id) => (energyConsumptionTotal += cpus[id].energyConsumptionW)) + machine.gpus.forEach((id) => (energyConsumptionTotal += gpus[id].energyConsumptionW)) + machine.memories.forEach((id) => (energyConsumptionTotal += memories[id].energyConsumptionW)) + machine.storages.forEach((id) => (energyConsumptionTotal += storages[id].energyConsumptionW)) + } return Math.min(1, energyConsumptionTotal / rack.powerCapacityW) }) @@ -28,7 +30,7 @@ function RackSpaceFillContainer({ tileId, ...props }) { } RackSpaceFillContainer.propTypes = { - tileId: PropTypes.string.isRequired, + rackId: PropTypes.string.isRequired, } export default RackSpaceFillContainer diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js index 0c15d54b..2039a9d3 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/RackSpaceFillContainer.js @@ -25,13 +25,18 @@ import PropTypes from 'prop-types' import { useSelector } from 'react-redux' import RackFillBar from './elements/RackFillBar' -function RackSpaceFillContainer({ tileId, ...props }) { - const rack = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack]) +function RackSpaceFillContainer({ rackId, ...props }) { + const rack = useSelector((state) => state.topology.racks[rackId]) + + if (!rack) { + return null + } + return <RackFillBar {...props} type="space" fillFraction={rack.machines.length / rack.capacity} /> } RackSpaceFillContainer.propTypes = { - tileId: PropTypes.string.isRequired, + rackId: PropTypes.string.isRequired, } export default RackSpaceFillContainer diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js index 65189891..76785bea 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/RoomContainer.js @@ -27,15 +27,24 @@ import { goFromBuildingToRoom } from '../../../redux/actions/interaction-level' import RoomGroup from './groups/RoomGroup' function RoomContainer({ roomId, ...props }) { - const state = useSelector((state) => { - return { - interactionLevel: state.interactionLevel, - currentRoomInConstruction: state.construction.currentRoomInConstruction, - room: state.topology.rooms[roomId], - } - }) + const interactionLevel = useSelector((state) => state.interactionLevel) + const currentRoomInConstruction = useSelector((state) => state.construction.currentRoomInConstruction) + const room = useSelector((state) => state.topology.rooms[roomId]) const dispatch = useDispatch() - return <RoomGroup {...props} {...state} onClick={() => dispatch(goFromBuildingToRoom(roomId))} /> + + if (!room) { + return null + } + + return ( + <RoomGroup + {...props} + interactionLevel={interactionLevel} + currentRoomInConstruction={currentRoomInConstruction} + room={room} + onClick={() => dispatch(goFromBuildingToRoom(roomId))} + /> + ) } RoomContainer.propTypes = { diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js index 21be3c79..0788b894 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/TileContainer.js @@ -28,9 +28,13 @@ import TileGroup from './groups/TileGroup' function TileContainer({ tileId, ...props }) { const interactionLevel = useSelector((state) => state.interactionLevel) + const dispatch = useDispatch() const tile = useSelector((state) => state.topology.tiles[tileId]) - const dispatch = useDispatch() + if (!tile) { + return null + } + const onClick = (tile) => { if (tile.rack) { dispatch(goFromRoomToRack(tile.id)) diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js b/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js index 143f70c2..106d8d3d 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/WallContainer.js @@ -27,7 +27,7 @@ import WallGroup from './groups/WallGroup' function WallContainer({ roomId, ...props }) { const tiles = useSelector((state) => { - return state.topology.rooms[roomId].tiles.map((tileId) => state.topology.tiles[tileId]) + return state.topology.rooms[roomId]?.tiles.map((tileId) => state.topology.tiles[tileId]) ?? [] }) return <WallGroup {...props} tiles={tiles} /> } diff --git a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js index dad2d62d..ed942661 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js +++ b/opendc-web/opendc-web-ui/src/components/topologies/map/groups/RackGroup.js @@ -11,8 +11,8 @@ function RackGroup({ tile }) { <Group> <TileObject positionX={tile.positionX} positionY={tile.positionY} color={RACK_BACKGROUND_COLOR} /> <Group> - <RackSpaceFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} /> - <RackEnergyFillContainer tileId={tile.id} positionX={tile.positionX} positionY={tile.positionY} /> + <RackSpaceFillContainer rackId={tile.rack} positionX={tile.positionX} positionY={tile.positionY} /> + <RackEnergyFillContainer rackId={tile.rack} positionX={tile.positionX} positionY={tile.positionY} /> </Group> </Group> ) diff --git a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss index 6f258aec..f4c8829f 100644 --- a/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss +++ b/opendc-web/opendc-web-ui/src/components/topologies/sidebar/rack/RackSidebar.module.scss @@ -1,12 +1,14 @@ .sidebarContainer { display: flex; - height: 100%; - max-height: 100%; flex-direction: column; + + height: 100%; } .machineListContainer { - flex: 1; - overflow-y: scroll; + overflow-y: auto; + + flex: 1 0 300px; + margin-top: 10px; } diff --git a/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js b/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js index c0d109bd..83301361 100644 --- a/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js +++ b/opendc-web/opendc-web-ui/src/components/util/NavItemLink.js @@ -23,11 +23,13 @@ import Link from 'next/link' import PropTypes from 'prop-types' -const NavItemLink = ({ children, href, ...props }) => ( - <Link href={href}> - <a {...props}>{children}</a> - </Link> -) +function NavItemLink({ children, href, ...props }) { + return ( + <Link href={href}> + <a {...props}>{children}</a> + </Link> + ) +} NavItemLink.propTypes = { children: PropTypes.node, |
