diff options
Diffstat (limited to 'opendc-web/opendc-web-server/src/main/webui/components')
100 files changed, 5486 insertions, 0 deletions
diff --git a/opendc-web/opendc-web-server/src/main/webui/components/AppHeader.js b/opendc-web/opendc-web-server/src/main/webui/components/AppHeader.js new file mode 100644 index 00000000..514dce2a --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/AppHeader.js @@ -0,0 +1,69 @@ +/* + * 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 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 AppHeaderUser from './AppHeaderUser' +import ProjectSelector from './context/ProjectSelector' + +import styles from './AppHeader.module.css' + +export default function AppHeader({ nav }) { + return ( + <Masthead id="app-header" className={styles.header}> + <MastheadMain> + <MastheadBrand className={styles.logo} component={(props) => <Link href="/projects" {...props} />}> + <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 = { + nav: PropTypes.node, +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/AppHeader.module.css b/opendc-web/opendc-web-server/src/main/webui/components/AppHeader.module.css new file mode 100644 index 00000000..9d5dbed1 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/AppHeader.module.css @@ -0,0 +1,42 @@ +/*! + * 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. + */ + +.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: 8px; + color: #fff; + align-self: center; + font-weight: 600; + font-size: 0.9rem; +} + +.logo:hover, +.logo:focus > span { + --pf-global--link--TextDecoration: none; +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/AppHeaderTools.js b/opendc-web/opendc-web-server/src/main/webui/components/AppHeaderTools.js new file mode 100644 index 00000000..499bceef --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/AppHeaderTools.js @@ -0,0 +1,93 @@ +/* + * 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 { + Button, + ButtonVariant, + Dropdown, + DropdownItem, + KebabToggle, + ToolbarGroup, + ToolbarItem, +} from '@patternfly/react-core' +import { useReducer } from 'react' +import { GithubIcon, HelpIcon } from '@patternfly/react-icons' + +function AppHeaderTools() { + const [isKebabDropdownOpen, toggleKebabDropdown] = useReducer((t) => !t, false) + const kebabDropdownItems = [ + <DropdownItem + key={0} + component={ + <a href="https://opendc.org" target="_blank" rel="noreferrer"> + <HelpIcon /> Help + </a> + } + />, + ] + + return ( + <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" + target="_blank" + aria-label="Source code" + variant={ButtonVariant.plain} + > + <GithubIcon /> + </Button> + </ToolbarItem> + <ToolbarItem> + <Button + component="a" + href="https://opendc.org/" + target="_blank" + aria-label="Help actions" + variant={ButtonVariant.plain} + > + <HelpIcon /> + </Button> + </ToolbarItem> + </ToolbarGroup> + <ToolbarItem visibility={{ lg: 'hidden' }}> + <Dropdown + isPlain + position="right" + toggle={<KebabToggle onToggle={toggleKebabDropdown} />} + isOpen={isKebabDropdownOpen} + dropdownItems={kebabDropdownItems} + /> + </ToolbarItem> + </ToolbarGroup> + ) +} + +AppHeaderTools.propTypes = {} + +export default AppHeaderTools diff --git a/opendc-web/opendc-web-server/src/main/webui/components/AppHeaderUser.js b/opendc-web/opendc-web-server/src/main/webui/components/AppHeaderUser.js new file mode 100644 index 00000000..3a73d9ba --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/AppHeaderUser.js @@ -0,0 +1,99 @@ +/* + * 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, + Progress, + ProgressSize, + DropdownSeparator, +} from '@patternfly/react-core' +import { useReducer } from 'react' +import { useAuth } from '../auth' +import useUser from '../data/user' + +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 { data } = useUser() + const simulationBudget = data?.accounting?.simulationTimeBudget ?? 3600 + const simulationTime = data?.accounting?.simulationTime | 0 + + const [isDropdownOpen, toggleDropdown] = useReducer((t) => !t, false) + const userDropdownItems = [ + <DropdownGroup key="budget" label="Monthly Simulation Budget"> + <DropdownItem isDisabled> + <Progress + min={0} + max={simulationBudget} + value={simulationTime} + title={`${Math.ceil(simulationTime / 60)} of ${Math.ceil(simulationBudget / 60)} minutes`} + size={ProgressSize.sm} + /> + </DropdownItem> + </DropdownGroup>, + <DropdownSeparator key="separator" />, + <DropdownItem + key="group 2 logout" + isDisabled={!isAuthenticated} + onClick={() => logout({ returnTo: window.location.origin })} + > + Logout + </DropdownItem>, + ] + + 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-server/src/main/webui/components/AppPage.js b/opendc-web/opendc-web-server/src/main/webui/components/AppPage.js new file mode 100644 index 00000000..2893146e --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/AppPage.js @@ -0,0 +1,44 @@ +/* + * 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 AppHeader from './AppHeader' +import React from 'react' +import { Page, PageGroup, PageBreadcrumb } from '@patternfly/react-core' + +export function AppPage({ children, breadcrumb, contextSelectors }) { + return ( + <Page header={<AppHeader />}> + <PageGroup> + {contextSelectors} + {breadcrumb && <PageBreadcrumb>{breadcrumb}</PageBreadcrumb>} + </PageGroup> + {children} + </Page> + ) +} + +AppPage.propTypes = { + breadcrumb: PropTypes.node, + contextSelectors: PropTypes.node, + children: PropTypes.node, +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelectionSection.js b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelectionSection.js new file mode 100644 index 00000000..f3c25b79 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelectionSection.js @@ -0,0 +1,34 @@ +/* + * 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 { contextSelectionSection } from './ContextSelectionSection.module.css' + +function ContextSelectionSection({ children }) { + return <section className={contextSelectionSection}>{children}</section> +} + +ContextSelectionSection.propTypes = { + children: PropTypes.node, +} + +export default ContextSelectionSection diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelectionSection.module.css b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelectionSection.module.css new file mode 100644 index 00000000..0e902af0 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelectionSection.module.css @@ -0,0 +1,28 @@ +/*! + * 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. + */ + +.contextSelectionSection { + padding-left: var(--pf-c-page__main-breadcrumb--PaddingLeft); + flex-shrink: 0; + border-bottom: var(--pf-global--BorderWidth--sm) solid var(--pf-global--BorderColor--100); + background-color: var(--pf-c-page__main-breadcrumb--BackgroundColor); +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelector.js b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelector.js new file mode 100644 index 00000000..d2601008 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelector.js @@ -0,0 +1,79 @@ +/* + * 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 { ContextSelector as PFContextSelector, ContextSelectorItem } from '@patternfly/react-core' +import { useMemo, useState } from 'react' + +import styles from './ContextSelector.module.css' + +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, + [items, searchValue] + ) + + return ( + <PFContextSelector + id={id} + className={type === 'page' && styles.pageSelector} + toggleText={toggleText} + onSearchInputChange={(value) => setSearchValue(value)} + searchInputValue={searchValue} + onToggle={(_, isOpen) => onToggle(isOpen)} + onSelect={(event) => { + const targetId = +event.target.value + const target = items.find((item) => item.id === targetId) + + onSelect(target) + onToggle(!isOpen) + }} + isOpen={isOpen} + isFullHeight={isFullHeight} + > + {filteredItems.map((item) => ( + <ContextSelectorItem key={item.id} value={item.id}> + {item.name} + </ContextSelectorItem> + ))} + </PFContextSelector> + ) +} + +const Item = PropTypes.shape({ + id: PropTypes.any.isRequired, + name: PropTypes.string.isRequired, +}) + +ContextSelector.propTypes = { + 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, + isFullHeight: PropTypes.bool, +} + +export default ContextSelector diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelector.module.css b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelector.module.css new file mode 100644 index 00000000..7662d00c --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/ContextSelector.module.css @@ -0,0 +1,44 @@ +/* + * 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. + */ + +.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); + --pf-c-context-selector__toggle--PaddingLeft: 0; + --pf-c-context-selector__toggle--BorderWidth: 0; + --pf-c-context-selector__toggle-text--FontSize: var(--pf-global--FontSize--sm); +} + +.pageSelector.pageSelector :global(.pf-c-context-selector__toggle):active, +.pageSelector.pageSelector :global(.pf-c-context-selector__toggle):focus-within, +.pageSelector.pageSelector :global(.pf-c-context-selector__toggle):global(.pf-m-active) { + --pf-c-context-selector__toggle--after--BorderBottomWidth: 0; +} + +.pageSelector.pageSelector:global(.pf-m-expanded) > :global(.pf-c-context-selector__toggle) { + --pf-c-context-selector__toggle--after--BorderBottomWidth: 0; +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/PortfolioSelector.js b/opendc-web/opendc-web-server/src/main/webui/components/context/PortfolioSelector.js new file mode 100644 index 00000000..e401e6fc --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/PortfolioSelector.js @@ -0,0 +1,52 @@ +/* + * 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 { useRouter } from 'next/router' +import { useState } from 'react' +import { usePortfolios } from '../../data/project' +import { Portfolio } from '../../shapes' +import ContextSelector from './ContextSelector' + +function PortfolioSelector({ activePortfolio }) { + const router = useRouter() + + const [isOpen, setOpen] = useState(false) + const { data: portfolios = [] } = usePortfolios(activePortfolio?.project?.id, { enabled: isOpen }) + + return ( + <ContextSelector + id="portfolio" + toggleText={activePortfolio ? `Portfolio: ${activePortfolio.name}` : 'Select portfolio'} + activeItem={activePortfolio} + items={portfolios} + onSelect={(portfolio) => router.push(`/projects/${portfolio.project.id}/portfolios/${portfolio.number}`)} + onToggle={setOpen} + isOpen={isOpen} + /> + ) +} + +PortfolioSelector.propTypes = { + activePortfolio: Portfolio, +} + +export default PortfolioSelector diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/ProjectSelector.js b/opendc-web/opendc-web-server/src/main/webui/components/context/ProjectSelector.js new file mode 100644 index 00000000..f2791b38 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/ProjectSelector.js @@ -0,0 +1,55 @@ +/* + * 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 { useRouter } from 'next/router' +import { useState } from 'react' +import { useProjects, useProject } from '../../data/project' +import { Project } from '../../shapes' +import ContextSelector from './ContextSelector' + +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 + id="project" + type="app" + toggleText={activeProject ? activeProject.name : 'Select project'} + items={projects} + onSelect={(project) => router.push(`/projects/${project.id}`)} + onToggle={setOpen} + isOpen={isOpen} + isFullHeight + /> + ) +} + +ProjectSelector.propTypes = { + activeProject: Project, +} + +export default ProjectSelector diff --git a/opendc-web/opendc-web-server/src/main/webui/components/context/TopologySelector.js b/opendc-web/opendc-web-server/src/main/webui/components/context/TopologySelector.js new file mode 100644 index 00000000..355d9f4b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/context/TopologySelector.js @@ -0,0 +1,52 @@ +/* + * 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 { useRouter } from 'next/router' +import { useState } from 'react' +import { useTopologies } from '../../data/topology' +import { Topology } from '../../shapes' +import ContextSelector from './ContextSelector' + +function TopologySelector({ activeTopology }) { + const router = useRouter() + + const [isOpen, setOpen] = useState(false) + const { data: topologies = [] } = useTopologies(activeTopology?.project?.id, { enabled: isOpen }) + + return ( + <ContextSelector + id="topology" + toggleText={activeTopology ? `Topology: ${activeTopology.name}` : 'Select topology'} + activeItem={activeTopology} + items={topologies} + onSelect={(topology) => router.push(`/projects/${topology.project.id}/topologies/${topology.number}`)} + onToggle={setOpen} + isOpen={isOpen} + /> + ) +} + +TopologySelector.propTypes = { + activeTopology: Topology, +} + +export default TopologySelector diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/NewScenario.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/NewScenario.js new file mode 100644 index 00000000..fd9a72d2 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/NewScenario.js @@ -0,0 +1,60 @@ +/* + * 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 { PlusIcon } from '@patternfly/react-icons' +import { Button } from '@patternfly/react-core' +import { useState } from 'react' +import { useNewScenario } from '../../data/project' +import NewScenarioModal from './NewScenarioModal' + +function NewScenario({ projectId, portfolioId }) { + const [isVisible, setVisible] = useState(false) + const { mutate: addScenario } = useNewScenario() + + const onSubmit = (projectId, portfolioNumber, data) => { + addScenario({ projectId, portfolioNumber, data }) + setVisible(false) + } + + return ( + <> + <Button icon={<PlusIcon />} isSmall onClick={() => setVisible(true)}> + New Scenario + </Button> + <NewScenarioModal + projectId={projectId} + portfolioId={portfolioId} + isOpen={isVisible} + onSubmit={onSubmit} + onCancel={() => setVisible(false)} + /> + </> + ) +} + +NewScenario.propTypes = { + projectId: PropTypes.number, + portfolioId: PropTypes.number, +} + +export default NewScenario diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/NewScenarioModal.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/NewScenarioModal.js new file mode 100644 index 00000000..ed35c163 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/NewScenarioModal.js @@ -0,0 +1,157 @@ +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import Modal from '../util/modals/Modal' +import { + Checkbox, + Form, + FormGroup, + FormSection, + FormSelect, + FormSelectOption, + NumberInput, + TextInput, +} from '@patternfly/react-core' +import { useSchedulers, useTraces } from '../../data/experiments' +import { useTopologies } from '../../data/topology' +import { usePortfolio } from '../../data/project' + +function NewScenarioModal({ projectId, portfolioId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) { + const { data: portfolio } = usePortfolio(projectId, portfolioId) + const { data: topologies = [] } = useTopologies(projectId, { enabled: isOpen }) + const { data: traces = [] } = useTraces({ enabled: isOpen }) + const { data: schedulers = [] } = useSchedulers({ enabled: isOpen }) + + // eslint-disable-next-line no-unused-vars + const [isSubmitted, setSubmitted] = useState(false) + const [traceLoad, setTraceLoad] = useState(100) + const [trace, setTrace] = useState(undefined) + const [topology, setTopology] = useState(undefined) + const [scheduler, setScheduler] = useState(undefined) + const [failuresEnabled, setFailuresEnabled] = useState(false) + const [opPhenEnabled, setOpPhenEnabled] = useState(false) + const nameInput = useRef(null) + + const resetState = () => { + setSubmitted(false) + setTraceLoad(100) + setTrace(undefined) + setTopology(undefined) + setScheduler(undefined) + setFailuresEnabled(false) + setOpPhenEnabled(false) + nameInput.current.value = '' + } + + const onSubmit = (event) => { + setSubmitted(true) + + if (event) { + event.preventDefault() + } + + const name = nameInput.current.value + + onSubmitUpstream(portfolio.project.id, portfolio.number, { + name, + workload: { + trace: trace || traces[0].id, + samplingFraction: traceLoad / 100, + }, + topology: topology || topologies[0].number, + phenomena: { + failures: failuresEnabled, + interference: opPhenEnabled, + }, + schedulerName: scheduler || schedulers[0], + }) + + resetState() + return true + } + const onCancel = () => { + onCancelUpstream() + resetState() + } + + return ( + <Modal title="New Scenario" isOpen={isOpen} onSubmit={onSubmit} onCancel={onCancel}> + <Form onSubmit={onSubmit}> + <FormGroup label="Name" fieldId="name" isRequired> + <TextInput + id="name" + name="name" + type="text" + isDisabled={portfolio?.scenarios?.length === 0} + defaultValue={portfolio?.scenarios?.length === 0 ? 'Base scenario' : ''} + ref={nameInput} + /> + </FormGroup> + <FormSection title="Workload"> + <FormGroup label="Trace" fieldId="trace" isRequired> + <FormSelect id="trace" name="trace" value={trace} onChange={setTrace}> + {traces.map((trace) => ( + <FormSelectOption value={trace.id} key={trace.id} label={trace.name} /> + ))} + </FormSelect> + </FormGroup> + <FormGroup label="Load Sampling Fraction" fieldId="trace-load" isRequired> + <NumberInput + name="trace-load" + type="number" + min={0} + max={100} + value={traceLoad} + onMinus={() => setTraceLoad((load) => load - 1)} + onPlus={() => setTraceLoad((load) => load + 1)} + onChange={(e) => setTraceLoad(Number(e.target.value))} + unit="%" + /> + </FormGroup> + </FormSection> + <FormSection title="Topology"> + <FormGroup label="Topology" fieldId="topology" isRequired> + <FormSelect id="topology" name="topology" value={topology} onChange={setTopology}> + {topologies.map((topology) => ( + <FormSelectOption value={topology.number} key={topology.number} label={topology.name} /> + ))} + </FormSelect> + </FormGroup> + + <FormGroup label="Scheduler" fieldId="scheduler" isRequired> + <FormSelect id="scheduler" name="scheduler" value={scheduler} onChange={setScheduler}> + {schedulers.map((scheduler) => ( + <FormSelectOption value={scheduler} key={scheduler} label={scheduler} /> + ))} + </FormSelect> + </FormGroup> + </FormSection> + <FormSection title="Operational Phenomena"> + <Checkbox + label="Failures" + id="failures" + name="failures" + isChecked={failuresEnabled} + onChange={() => setFailuresEnabled((e) => !e)} + /> + <Checkbox + label="Performance Interference" + id="perf-interference" + name="perf-interference" + isChecked={opPhenEnabled} + onChange={() => setOpPhenEnabled((e) => !e)} + /> + </FormSection> + </Form> + </Modal> + ) +} + +NewScenarioModal.propTypes = { + projectId: PropTypes.number, + portfolioId: PropTypes.number, + isOpen: PropTypes.bool.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +} + +export default NewScenarioModal diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioOverview.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioOverview.js new file mode 100644 index 00000000..e561b655 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioOverview.js @@ -0,0 +1,120 @@ +/* + * 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 { + Card, + CardActions, + CardBody, + CardHeader, + CardTitle, + Chip, + ChipGroup, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Grid, + GridItem, + Skeleton, +} from '@patternfly/react-core' +import React from 'react' +import { usePortfolio } from '../../data/project' +import { METRIC_NAMES } from '../../util/available-metrics' +import NewScenario from './NewScenario' +import ScenarioTable from './ScenarioTable' + +function PortfolioOverview({ projectId, portfolioId }) { + const { status, data: portfolio } = usePortfolio(projectId, portfolioId) + + return ( + <Grid hasGutter> + <GridItem md={2}> + <Card> + <CardTitle>Details</CardTitle> + <CardBody> + <DescriptionList> + <DescriptionListGroup> + <DescriptionListTerm>Name</DescriptionListTerm> + <DescriptionListDescription> + {portfolio?.name ?? <Skeleton screenreaderText="Loading portfolio" />} + </DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Scenarios</DescriptionListTerm> + <DescriptionListDescription> + {portfolio?.scenarios?.length ?? <Skeleton screenreaderText="Loading portfolio" />} + </DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Metrics</DescriptionListTerm> + <DescriptionListDescription> + {portfolio ? ( + portfolio.targets.metrics.length > 0 ? ( + <ChipGroup> + {portfolio.targets.metrics.map((metric) => ( + <Chip isReadOnly key={metric}> + {METRIC_NAMES[metric]} + </Chip> + ))} + </ChipGroup> + ) : ( + 'No metrics enabled' + ) + ) : ( + <Skeleton screenreaderText="Loading portfolio" /> + )} + </DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Repeats per Scenario</DescriptionListTerm> + <DescriptionListDescription> + {portfolio?.targets?.repeats ?? <Skeleton screenreaderText="Loading portfolio" />} + </DescriptionListDescription> + </DescriptionListGroup> + </DescriptionList> + </CardBody> + </Card> + </GridItem> + <GridItem md={6}> + <Card> + <CardHeader> + <CardActions> + <NewScenario projectId={projectId} portfolioId={portfolioId} /> + </CardActions> + <CardTitle>Scenarios</CardTitle> + </CardHeader> + <CardBody> + <ScenarioTable portfolio={portfolio} status={status} /> + </CardBody> + </Card> + </GridItem> + </Grid> + ) +} + +PortfolioOverview.propTypes = { + projectId: PropTypes.number, + portfolioId: PropTypes.number, +} + +export default PortfolioOverview diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioResultInfo.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioResultInfo.js new file mode 100644 index 00000000..dbfa928f --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioResultInfo.js @@ -0,0 +1,40 @@ +/* + * 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 { Tooltip } from '@patternfly/react-core' +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons' +import { METRIC_DESCRIPTIONS } from '../../util/available-metrics' + +function PortfolioResultInfo({ metric }) { + return ( + <Tooltip position="top" content={<div>{METRIC_DESCRIPTIONS[metric]}</div>}> + <OutlinedQuestionCircleIcon title="Metric information" /> + </Tooltip> + ) +} + +PortfolioResultInfo.propTypes = { + metric: PropTypes.string.isRequired, +} + +export default PortfolioResultInfo diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioResults.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioResults.js new file mode 100644 index 00000000..62150fa7 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/PortfolioResults.js @@ -0,0 +1,180 @@ +/* + * 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 { mean, std } from 'mathjs' +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, + CardActions, + CardBody, + CardHeader, + CardTitle, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Grid, + GridItem, + 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' + +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.jobs && scenario.jobs[scenario.jobs.length - 1].results) + .map((scenario) => { + const job = scenario.jobs[scenario.jobs.length - 1] + return { + metric, + x: scenario.name, + y: mean(job.results[metric]), + errorY: std(job.results[metric]), + label, + } + }) + }) + return dataPerMetric + }, [scenarios]) + + const categories = useMemo(() => ({ x: scenarios.map((s) => s.name).reverse() }), [scenarios]) + + if (status === 'loading') { + return ( + <Bullseye> + <EmptyState> + <EmptyStateIcon variant="container" component={Spinner} /> + <Title size="lg" headingLevel="h4"> + Loading Results + </Title> + </EmptyState> + </Bullseye> + ) + } else if (status === 'error') { + return ( + <Bullseye> + <EmptyState> + <EmptyStateIcon variant="container" component={ErrorCircleOIcon} /> + <Title size="lg" headingLevel="h4"> + Unable to connect + </Title> + <EmptyStateBody> + There was an error retrieving data. Check your connection and try again. + </EmptyStateBody> + </EmptyState> + </Bullseye> + ) + } else if (scenarios.length === 0) { + return ( + <Bullseye> + <EmptyState> + <EmptyStateIcon variant="container" component={CubesIcon} /> + <Title size="lg" headingLevel="h4"> + No results + </Title> + <EmptyStateBody> + No results are currently available for this portfolio. Run a scenario to obtain simulation + results. + </EmptyStateBody> + <NewScenario projectId={projectId} portfolioId={portfolioId} /> + </EmptyState> + </Bullseye> + ) + } + + return ( + <Grid hasGutter> + {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 + /> + </Chart> + </CardBody> + </Card> + </GridItem> + ) + )} + </Grid> + ) +} + +PortfolioResults.propTypes = { + projectId: PropTypes.number, + portfolioId: PropTypes.number, +} + +export default PortfolioResults diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioState.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioState.js new file mode 100644 index 00000000..99d83f64 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioState.js @@ -0,0 +1,62 @@ +/* + * 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 { ClockIcon, CheckCircleIcon, ErrorCircleOIcon } from '@patternfly/react-icons' +import { JobState } from '../../shapes' + +function ScenarioState({ state }) { + switch (state) { + case 'PENDING': + case 'CLAIMED': + return ( + <span> + <ClockIcon color="blue" /> Queued + </span> + ) + case 'RUNNING': + return ( + <span> + <ClockIcon color="green" /> Running + </span> + ) + case 'FINISHED': + return ( + <span> + <CheckCircleIcon color="green" /> Finished + </span> + ) + case 'FAILED': + return ( + <span> + <ErrorCircleOIcon color="red" /> Failed + </span> + ) + } + + return 'Unknown' +} + +ScenarioState.propTypes = { + state: JobState.isRequired, +} + +export default ScenarioState diff --git a/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js new file mode 100644 index 00000000..b068d045 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/portfolios/ScenarioTable.js @@ -0,0 +1,103 @@ +/* + * 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 { Bullseye } from '@patternfly/react-core' +import Link from 'next/link' +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' +import ScenarioState from './ScenarioState' +import { useDeleteScenario } from '../../data/project' + +function ScenarioTable({ portfolio, status }) { + const { mutate: deleteScenario } = useDeleteScenario() + const projectId = portfolio?.project?.id + const scenarios = portfolio?.scenarios ?? [] + + const actions = ({ number }) => [ + { + title: 'Delete Scenario', + onClick: () => deleteScenario({ projectId: projectId, number }), + isDisabled: number === 0, + }, + ] + + return ( + <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}`}> + {scenario.topology.name} + </Link> + ) : ( + 'Unknown Topology' + )} + </Td> + <Td dataLabel="Workload">{`${scenario.workload.trace.name} (${ + scenario.workload.samplingFraction * 100 + }%)`}</Td> + <Td dataLabel="State"> + <ScenarioState state={scenario.jobs[scenario.jobs.length - 1].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> + ) +} + +ScenarioTable.propTypes = { + portfolio: Portfolio, + status: Status.isRequired, +} + +export default ScenarioTable diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/FilterPanel.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/FilterPanel.js new file mode 100644 index 00000000..5aaa56ac --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/FilterPanel.js @@ -0,0 +1,26 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { ToggleGroup, ToggleGroupItem } from '@patternfly/react-core' +import { filterPanel } from './FilterPanel.module.css' + +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} pf-u-mb-sm`}> + {Object.keys(FILTERS).map((filter) => ( + <ToggleGroupItem + key={filter} + onChange={() => activeFilter === filter || onSelect(filter)} + isSelected={activeFilter === filter} + text={FILTERS[filter]} + /> + ))} + </ToggleGroup> +) + +FilterPanel.propTypes = { + onSelect: PropTypes.func.isRequired, + activeFilter: PropTypes.string, +} + +export default FilterPanel diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/FilterPanel.module.css b/opendc-web/opendc-web-server/src/main/webui/components/projects/FilterPanel.module.css new file mode 100644 index 00000000..15c36821 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/FilterPanel.module.css @@ -0,0 +1,7 @@ +.filterPanel { + display: flex; +} + +.filterPanel > button { + flex: 1 !important; +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/NewPortfolio.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewPortfolio.js new file mode 100644 index 00000000..aebcc3c9 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewPortfolio.js @@ -0,0 +1,53 @@ +/* + * 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 { PlusIcon } from '@patternfly/react-icons' +import { Button } from '@patternfly/react-core' +import { useState } from 'react' +import { useNewPortfolio } from '../../data/project' +import NewPortfolioModal from './NewPortfolioModal' + +function NewPortfolio({ projectId }) { + const [isVisible, setVisible] = useState(false) + const { mutate: addPortfolio } = useNewPortfolio() + + const onSubmit = (name, targets) => { + addPortfolio({ projectId, name, targets }) + setVisible(false) + } + + return ( + <> + <Button icon={<PlusIcon />} isSmall onClick={() => setVisible(true)}> + New Portfolio + </Button> + <NewPortfolioModal isOpen={isVisible} onSubmit={onSubmit} onCancel={() => setVisible(false)} /> + </> + ) +} + +NewPortfolio.propTypes = { + projectId: PropTypes.number, +} + +export default NewPortfolio diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/NewPortfolioModal.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewPortfolioModal.js new file mode 100644 index 00000000..ba4bc819 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewPortfolioModal.js @@ -0,0 +1,161 @@ +/* + * 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 React, { useRef, useState } from 'react' +import { + Form, + FormGroup, + FormSection, + NumberInput, + Select, + SelectGroup, + SelectOption, + SelectVariant, + TextInput, +} from '@patternfly/react-core' +import Modal from '../util/modals/Modal' +import { METRIC_GROUPS, METRIC_NAMES } from '../../util/available-metrics' + +const NewPortfolioModal = ({ isOpen, onSubmit: onSubmitUpstream, onCancel: onUpstreamCancel }) => { + const nameInput = useRef(null) + const [repeats, setRepeats] = useState(1) + const [isSelectOpen, setSelectOpen] = useState(false) + const [selectedMetrics, setSelectedMetrics] = useState([]) + + const [isSubmitted, setSubmitted] = useState(false) + const [errors, setErrors] = useState({}) + + const clearState = () => { + setSubmitted(false) + setErrors({}) + nameInput.current.value = '' + setRepeats(1) + setSelectOpen(false) + setSelectedMetrics([]) + } + + const onSubmit = (event) => { + setSubmitted(true) + + if (event) { + event.preventDefault() + } + + const name = nameInput.current.value + + if (!name) { + setErrors({ name: true }) + return false + } else { + onSubmitUpstream(name, { metrics: selectedMetrics, repeats }) + } + + clearState() + return false + } + const onCancel = () => { + onUpstreamCancel() + clearState() + } + + const onSelect = (event, selection) => { + if (selectedMetrics.includes(selection)) { + setSelectedMetrics((metrics) => metrics.filter((item) => item !== selection)) + } else { + setSelectedMetrics((metrics) => [...metrics, selection]) + } + } + + return ( + <Modal title="New Portfolio" isOpen={isOpen} onSubmit={onSubmit} onCancel={onCancel}> + <Form onSubmit={onSubmit}> + <FormSection> + <FormGroup + label="Name" + fieldId="name" + isRequired + validated={isSubmitted && errors.name ? 'error' : 'default'} + helperTextInvalid="This field cannot be empty" + > + <TextInput + name="name" + id="name" + type="text" + isRequired + ref={nameInput} + placeholder="My Portfolio" + /> + </FormGroup> + </FormSection> + <FormSection title="Targets" titleElement="h4"> + <FormGroup label="Metrics" fieldId="metrics"> + <Select + variant={SelectVariant.typeaheadMulti} + typeAheadAriaLabel="Select a metric" + onToggle={() => setSelectOpen(!isSelectOpen)} + onSelect={onSelect} + onClear={() => setSelectedMetrics([])} + selections={selectedMetrics} + isOpen={isSelectOpen} + placeholderText="Select a metric" + menuAppendTo="parent" + maxHeight="300px" + chipGroupProps={{ numChips: 1 }} + isGrouped + > + {Object.entries(METRIC_GROUPS).map(([group, metrics]) => ( + <SelectGroup label={group} key={group}> + {metrics.map((metric) => ( + <SelectOption key={metric} value={metric}> + {METRIC_NAMES[metric]} + </SelectOption> + ))} + </SelectGroup> + ))} + </Select> + </FormGroup> + <FormGroup label="Repeats per Scenario" fieldId="repeats"> + <NumberInput + id="repeats" + inputName="repeats" + type="number" + value={repeats} + onChange={(e) => setRepeats(Number(e.target.value))} + onPlus={() => setRepeats((r) => r + 1)} + onMinus={() => setRepeats((r) => r - 1)} + min={1} + /> + </FormGroup> + </FormSection> + </Form> + </Modal> + ) +} + +NewPortfolioModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +} + +export default NewPortfolioModal diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopology.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopology.js new file mode 100644 index 00000000..4c569c56 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopology.js @@ -0,0 +1,57 @@ +/* + * 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 { PlusIcon } from '@patternfly/react-icons' +import { Button } from '@patternfly/react-core' +import { useState } from 'react' +import { useNewTopology } from '../../data/topology' +import NewTopologyModal from './NewTopologyModal' + +function NewTopology({ projectId }) { + const [isVisible, setVisible] = useState(false) + const { mutate: addTopology } = useNewTopology() + + const onSubmit = (topology) => { + addTopology(topology) + setVisible(false) + } + return ( + <> + <Button icon={<PlusIcon />} isSmall onClick={() => setVisible(true)}> + New Topology + </Button> + <NewTopologyModal + projectId={projectId} + isOpen={isVisible} + onSubmit={onSubmit} + onCancel={() => setVisible(false)} + /> + </> + ) +} + +NewTopology.propTypes = { + projectId: PropTypes.number, +} + +export default NewTopology diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js new file mode 100644 index 00000000..780ec034 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/NewTopologyModal.js @@ -0,0 +1,115 @@ +/* + * 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 produce from 'immer' +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { Form, FormGroup, FormSelect, FormSelectOption, TextInput } from '@patternfly/react-core' +import { useTopologies } from '../../data/topology' +import Modal from '../util/modals/Modal' + +const NewTopologyModal = ({ projectId, isOpen, onSubmit: onSubmitUpstream, onCancel: onCancelUpstream }) => { + const nameInput = useRef(null) + const [isSubmitted, setSubmitted] = useState(false) + const [originTopology, setOriginTopology] = useState(-1) + const [errors, setErrors] = useState({}) + + const { data: topologies = [] } = useTopologies(projectId, { enabled: isOpen }) + + const clearState = () => { + if (nameInput.current) { + nameInput.current.value = '' + } + setSubmitted(false) + setOriginTopology(-1) + setErrors({}) + } + + const onSubmit = (event) => { + setSubmitted(true) + + if (event) { + event.preventDefault() + } + + const name = nameInput.current.value + + if (!name) { + setErrors({ name: true }) + return false + } else { + const candidate = topologies.find((topology) => topology.id === originTopology) || { rooms: [] } + const topology = produce(candidate, (draft) => { + delete draft.project + draft.projectId = projectId + draft.name = name + }) + onSubmitUpstream(topology) + } + + clearState() + return true + } + + const onCancel = () => { + onCancelUpstream() + clearState() + } + + return ( + <Modal title="New Topology" isOpen={isOpen} onSubmit={onSubmit} onCancel={onCancel}> + <Form onSubmit={onSubmit}> + <FormGroup + label="Name" + fieldId="name" + isRequired + validated={isSubmitted && errors.name ? 'error' : 'default'} + helperTextInvalid="This field cannot be empty" + > + <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={(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} /> + ))} + </FormSelect> + </FormGroup> + </Form> + </Modal> + ) +} + +NewTopologyModal.propTypes = { + projectId: PropTypes.number, + isOpen: PropTypes.bool.isRequired, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, +} + +export default NewTopologyModal diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/PortfolioTable.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/PortfolioTable.js new file mode 100644 index 00000000..0afeaeaf --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/PortfolioTable.js @@ -0,0 +1,99 @@ +/* + * 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 { Bullseye } from '@patternfly/react-core' +import PropTypes from 'prop-types' +import Link from 'next/link' +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' + +function PortfolioTable({ projectId }) { + const { status, data: portfolios = [] } = usePortfolios(projectId) + const { mutate: deletePortfolio } = useDeletePortfolio() + + const actions = (portfolio) => [ + { + title: 'Delete Portfolio', + onClick: () => deletePortfolio({ projectId, number: portfolio.number }), + }, + ] + + return ( + <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> + ) +} + +PortfolioTable.propTypes = { + projectId: PropTypes.number, +} + +export default PortfolioTable diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectCollection.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectCollection.js new file mode 100644 index 00000000..a26fed46 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectCollection.js @@ -0,0 +1,137 @@ +import Link from 'next/link' +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 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={Link} 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-server/src/main/webui/components/projects/ProjectOverview.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectOverview.js new file mode 100644 index 00000000..3e1656f6 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/ProjectOverview.js @@ -0,0 +1,98 @@ +/* + * 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 { + Card, + CardActions, + CardBody, + CardHeader, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Grid, + GridItem, + Skeleton, +} from '@patternfly/react-core' +import NewTopology from './NewTopology' +import TopologyTable from './TopologyTable' +import NewPortfolio from './NewPortfolio' +import PortfolioTable from './PortfolioTable' +import { useProject } from '../../data/project' + +function ProjectOverview({ projectId }) { + const { data: project } = useProject(projectId) + + return ( + <Grid hasGutter> + <GridItem md={2}> + <Card> + <CardTitle>Details</CardTitle> + <CardBody> + <DescriptionList> + <DescriptionListGroup> + <DescriptionListTerm>Name</DescriptionListTerm> + <DescriptionListDescription> + {project?.name ?? <Skeleton screenreaderText="Loading project" />} + </DescriptionListDescription> + </DescriptionListGroup> + </DescriptionList> + </CardBody> + </Card> + </GridItem> + <GridItem md={5}> + <Card> + <CardHeader> + <CardActions> + <NewTopology projectId={projectId} /> + </CardActions> + <CardTitle>Topologies</CardTitle> + </CardHeader> + <CardBody> + <TopologyTable projectId={projectId} /> + </CardBody> + </Card> + </GridItem> + <GridItem md={5}> + <Card> + <CardHeader> + <CardActions> + <NewPortfolio projectId={projectId} /> + </CardActions> + <CardTitle>Portfolios</CardTitle> + </CardHeader> + <CardBody> + <PortfolioTable projectId={projectId} /> + </CardBody> + </Card> + </GridItem> + </Grid> + ) +} + +ProjectOverview.propTypes = { + projectId: PropTypes.number, +} + +export default ProjectOverview diff --git a/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js b/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js new file mode 100644 index 00000000..1c2c4f04 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/projects/TopologyTable.js @@ -0,0 +1,115 @@ +/* + * 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 { Bullseye, AlertGroup, Alert, AlertVariant, AlertActionCloseButton } from '@patternfly/react-core' +import PropTypes from 'prop-types' +import Link from 'next/link' +import { Tr, Th, Thead, Td, ActionsColumn, Tbody, TableComposable } from '@patternfly/react-table' +import React, { useState } from 'react' +import TableEmptyState from '../util/TableEmptyState' +import { parseAndFormatDateTime } from '../../util/date-time' +import { useTopologies, useDeleteTopology } from '../../data/topology' + +function TopologyTable({ projectId }) { + const [error, setError] = useState('') + + const { status, data: topologies = [] } = useTopologies(projectId) + const { mutate: deleteTopology } = useDeleteTopology({ + onError: (error) => setError(error), + }) + + const actions = ({ number }) => [ + { + title: 'Delete Topology', + onClick: () => deleteTopology({ projectId, number }), + isDisabled: number === 0, + }, + ] + + return ( + <> + <AlertGroup isToast> + {error && ( + <Alert + isLiveRegion + variant={AlertVariant.danger} + title={error} + actionClose={ + <AlertActionCloseButton + title={error} + variantLabel="danger alert" + onClose={() => setError(null)} + /> + } + /> + )} + </AlertGroup> + <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> + </> + ) +} + +TopologyTable.propTypes = { + projectId: PropTypes.number, +} + +export default TopologyTable diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/RoomTable.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/RoomTable.js new file mode 100644 index 00000000..7f7b4171 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/RoomTable.js @@ -0,0 +1,74 @@ +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 { 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 actions = (room) => [ + { + title: 'Delete room', + onClick: () => onDelete(room), + }, + ] + + return ( + <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> + ) +} + +RoomTable.propTypes = { + projectId: PropTypes.number, + topologyId: PropTypes.number, + onSelect: PropTypes.func, +} + +export default RoomTable diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/TopologyMap.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/TopologyMap.js new file mode 100644 index 00000000..ff583750 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/TopologyMap.js @@ -0,0 +1,69 @@ +/* + * 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 React, { useState, useRef } from 'react' +import { + Bullseye, + Drawer, + DrawerContent, + DrawerContentBody, + EmptyState, + EmptyStateIcon, + Spinner, + Title, +} from '@patternfly/react-core' +import MapStage from './map/MapStage' +import Collapse from './map/controls/Collapse' +import { useSelector } from 'react-redux' +import TopologySidebar from './sidebar/TopologySidebar' + +function TopologyMap() { + const topologyIsLoading = useSelector((state) => !state.topology.root) + const interactionLevel = useSelector((state) => state.interactionLevel) + + const [isExpanded, setExpanded] = useState(true) + const panelContent = <TopologySidebar interactionLevel={interactionLevel} onClose={() => setExpanded(false)} /> + + const hotkeysRef = useRef() + + return topologyIsLoading ? ( + <Bullseye> + <EmptyState> + <EmptyStateIcon variant="container" component={Spinner} /> + <Title size="lg" headingLevel="h4"> + Loading Topology + </Title> + </EmptyState> + </Bullseye> + ) : ( + <Drawer isExpanded={isExpanded}> + <DrawerContent panelContent={panelContent}> + <DrawerContentBody style={{ position: 'relative' }}> + <MapStage hotkeysRef={hotkeysRef} /> + <Collapse onClick={() => setExpanded(true)} /> + </DrawerContentBody> + </DrawerContent> + </Drawer> + ) +} + +export default TopologyMap diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/TopologyOverview.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/TopologyOverview.js new file mode 100644 index 00000000..f8ee4990 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/TopologyOverview.js @@ -0,0 +1,92 @@ +/* + * 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 { + Card, + CardBody, + CardTitle, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + Grid, + GridItem, + Skeleton, +} from '@patternfly/react-core' +import React from 'react' +import { useTopology } from '../../data/topology' +import { parseAndFormatDateTime } from '../../util/date-time' +import RoomTable from './RoomTable' + +function TopologyOverview({ projectId, topologyNumber, onSelect }) { + const { data: topology } = useTopology(projectId, topologyNumber) + return ( + <Grid hasGutter> + <GridItem md={2}> + <Card> + <CardTitle>Details</CardTitle> + <CardBody> + <DescriptionList> + <DescriptionListGroup> + <DescriptionListTerm>Name</DescriptionListTerm> + <DescriptionListDescription> + {topology?.name ?? <Skeleton screenreaderText="Loading topology" />} + </DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Last edited</DescriptionListTerm> + <DescriptionListDescription> + {topology ? ( + parseAndFormatDateTime(topology.updatedAt) + ) : ( + <Skeleton screenreaderText="Loading topology" /> + )} + </DescriptionListDescription> + </DescriptionListGroup> + </DescriptionList> + </CardBody> + </Card> + </GridItem> + <GridItem md={5}> + <Card> + <CardTitle>Rooms</CardTitle> + <CardBody> + <RoomTable + projectId={projectId} + topologyId={topologyNumber} + onSelect={(room) => onSelect('room', room)} + /> + </CardBody> + </Card> + </GridItem> + </Grid> + ) +} + +TopologyOverview.propTypes = { + projectId: PropTypes.number, + topologyNumber: PropTypes.number, + onSelect: PropTypes.func, +} + +export default TopologyOverview diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/GrayContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/GrayContainer.js new file mode 100644 index 00000000..ccf637e5 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/GrayContainer.js @@ -0,0 +1,34 @@ +/* + * 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 React from 'react' +import { useDispatch } from 'react-redux' +import { goDownOneInteractionLevel } from '../../../redux/actions/interaction-level' +import GrayLayer from './elements/GrayLayer' + +function GrayContainer() { + const dispatch = useDispatch() + const onClick = () => dispatch(goDownOneInteractionLevel()) + return <GrayLayer onClick={onClick} /> +} + +export default GrayContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapConstants.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapConstants.js new file mode 100644 index 00000000..4c3b2757 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapConstants.js @@ -0,0 +1,25 @@ +export const MAP_SIZE = 50 +export const TILE_SIZE_IN_PIXELS = 100 +export const TILE_SIZE_IN_METERS = 0.5 +export const MAP_SIZE_IN_PIXELS = MAP_SIZE * TILE_SIZE_IN_PIXELS + +export const OBJECT_MARGIN_IN_PIXELS = TILE_SIZE_IN_PIXELS / 5 +export const TILE_PLUS_MARGIN_IN_PIXELS = TILE_SIZE_IN_PIXELS / 3 +export const OBJECT_SIZE_IN_PIXELS = TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2 + +export const GRID_LINE_WIDTH_IN_PIXELS = 2 +export const WALL_WIDTH_IN_PIXELS = TILE_SIZE_IN_PIXELS / 16 +export const OBJECT_BORDER_WIDTH_IN_PIXELS = TILE_SIZE_IN_PIXELS / 16 +export const TILE_PLUS_WIDTH_IN_PIXELS = TILE_SIZE_IN_PIXELS / 10 + +export const RACK_FILL_ICON_WIDTH = OBJECT_SIZE_IN_PIXELS / 3 +export const RACK_FILL_ICON_OPACITY = 0.8 + +export const MAP_MOVE_PIXELS_PER_EVENT = 20 +export const MAP_SCALE_PER_EVENT = 1.1 +export const MAP_MIN_SCALE = 0.5 +export const MAP_MAX_SCALE = 1.5 + +export const MAX_NUM_UNITS_PER_MACHINE = 6 +export const DEFAULT_RACK_SLOT_CAPACITY = 42 +export const DEFAULT_RACK_POWER_CAPACITY = 10000 diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapStage.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapStage.js new file mode 100644 index 00000000..e2b626ec --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapStage.js @@ -0,0 +1,83 @@ +import React, { useRef, useState } from 'react' +import PropTypes from 'prop-types' +import { useHotkeys } from 'react-hotkeys-hook' +import { Stage } from 'react-konva' +import { MAP_MAX_SCALE, MAP_MIN_SCALE, MAP_MOVE_PIXELS_PER_EVENT, MAP_SCALE_PER_EVENT } from './MapConstants' +import useResizeObserver from 'use-resize-observer' +import { mapContainer } from './MapStage.module.css' +import MapLayer from './layers/MapLayer' +import RoomHoverLayer from './layers/RoomHoverLayer' +import ObjectHoverLayer from './layers/ObjectHoverLayer' +import ScaleIndicator from './controls/ScaleIndicator' +import Toolbar from './controls/Toolbar' + +function MapStage({ hotkeysRef }) { + const stageRef = useRef(null) + const { width = 500, height = 500 } = useResizeObserver({ ref: stageRef.current?.attrs?.container }) + const [[x, y], setPos] = useState([0, 0]) + const [scale, setScale] = useState(1) + + const clampScale = (target) => Math.min(Math.max(target, MAP_MIN_SCALE), MAP_MAX_SCALE) + const moveWithDelta = (deltaX, deltaY) => setPos(([x, y]) => [x + deltaX, y + deltaY]) + + const onZoom = (e) => { + e.evt.preventDefault() + + const stage = stageRef.current.getStage() + const oldScale = scale + + const pointer = stage.getPointerPosition() + const mousePointTo = { + x: (pointer.x - x) / oldScale, + y: (pointer.y - y) / oldScale, + } + + const newScale = clampScale(e.evt.deltaY > 0 ? oldScale * MAP_SCALE_PER_EVENT : oldScale / MAP_SCALE_PER_EVENT) + + setScale(newScale) + setPos([pointer.x - mousePointTo.x * newScale, pointer.y - mousePointTo.y * newScale]) + } + const onZoomButton = (zoomIn) => + setScale((scale) => clampScale(zoomIn ? scale * MAP_SCALE_PER_EVENT : scale / MAP_SCALE_PER_EVENT)) + const onDragEnd = (e) => setPos([e.target.x(), e.target.y()]) + const onExport = () => { + const download = document.createElement('a') + download.href = stageRef.current.getStage().toDataURL() + download.download = 'opendc-canvas-export-' + Date.now() + '.png' + download.click() + } + + useHotkeys('left, a', () => moveWithDelta(MAP_MOVE_PIXELS_PER_EVENT, 0), { element: hotkeysRef.current }) + useHotkeys('right, d', () => moveWithDelta(-MAP_MOVE_PIXELS_PER_EVENT, 0), { element: hotkeysRef.current }) + useHotkeys('up, w', () => moveWithDelta(0, MAP_MOVE_PIXELS_PER_EVENT), { element: hotkeysRef.current }) + useHotkeys('down, s', () => moveWithDelta(0, -MAP_MOVE_PIXELS_PER_EVENT), { element: hotkeysRef.current }) + + return ( + <> + <Stage + className={mapContainer} + ref={stageRef} + onWheel={onZoom} + onDragEnd={onDragEnd} + draggable + width={width} + height={height} + scale={{ x: scale, y: scale }} + x={x} + y={y} + > + <MapLayer /> + <RoomHoverLayer /> + <ObjectHoverLayer /> + </Stage> + <ScaleIndicator scale={scale} /> + <Toolbar onZoom={onZoomButton} onExport={onExport} /> + </> + ) +} + +MapStage.propTypes = { + hotkeysRef: PropTypes.object.isRequired, +} + +export default MapStage diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapStage.module.css b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapStage.module.css new file mode 100644 index 00000000..47c3dde2 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/MapStage.module.css @@ -0,0 +1,29 @@ +/*! + * 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. + */ + +.mapContainer { + background-color: var(--pf-global--Color--light-200); + position: relative; + display: flex; + width: 100%; + height: 100%; +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackContainer.js new file mode 100644 index 00000000..14449a91 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackContainer.js @@ -0,0 +1,37 @@ +/* + * 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 React from 'react' +import { useSelector } from 'react-redux' +import { Tile } from '../../../shapes' +import RackGroup from './groups/RackGroup' + +function RackContainer({ tile }) { + const interactionLevel = useSelector((state) => state.interactionLevel) + return <RackGroup interactionLevel={interactionLevel} tile={tile} /> +} + +RackContainer.propTypes = { + tile: Tile, +} + +export default RackContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackEnergyFillContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackEnergyFillContainer.js new file mode 100644 index 00000000..a1ca7426 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackEnergyFillContainer.js @@ -0,0 +1,36 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import RackFillBar from './elements/RackFillBar' + +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 + + 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) + }) + return <RackFillBar {...props} type="energy" fillFraction={fillFraction} /> +} + +RackSpaceFillContainer.propTypes = { + rackId: PropTypes.string.isRequired, +} + +export default RackSpaceFillContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackSpaceFillContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackSpaceFillContainer.js new file mode 100644 index 00000000..2039a9d3 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RackSpaceFillContainer.js @@ -0,0 +1,42 @@ +/* + * 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 React from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +import RackFillBar from './elements/RackFillBar' + +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 = { + rackId: PropTypes.string.isRequired, +} + +export default RackSpaceFillContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RoomContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RoomContainer.js new file mode 100644 index 00000000..76785bea --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/RoomContainer.js @@ -0,0 +1,54 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { goFromBuildingToRoom } from '../../../redux/actions/interaction-level' +import RoomGroup from './groups/RoomGroup' + +function RoomContainer({ roomId, ...props }) { + const interactionLevel = useSelector((state) => state.interactionLevel) + const currentRoomInConstruction = useSelector((state) => state.construction.currentRoomInConstruction) + const room = useSelector((state) => state.topology.rooms[roomId]) + const dispatch = useDispatch() + + if (!room) { + return null + } + + return ( + <RoomGroup + {...props} + interactionLevel={interactionLevel} + currentRoomInConstruction={currentRoomInConstruction} + room={room} + onClick={() => dispatch(goFromBuildingToRoom(roomId))} + /> + ) +} + +RoomContainer.propTypes = { + roomId: PropTypes.string, +} + +export default RoomContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/TileContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/TileContainer.js new file mode 100644 index 00000000..0788b894 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/TileContainer.js @@ -0,0 +1,50 @@ +/* + * 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 React from 'react' +import PropTypes from 'prop-types' +import { useDispatch, useSelector } from 'react-redux' +import { goFromRoomToRack } from '../../../redux/actions/interaction-level' +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]) + + if (!tile) { + return null + } + + const onClick = (tile) => { + if (tile.rack) { + dispatch(goFromRoomToRack(tile.id)) + } + } + return <TileGroup {...props} onClick={onClick} tile={tile} interactionLevel={interactionLevel} /> +} + +TileContainer.propTypes = { + tileId: PropTypes.string.isRequired, +} + +export default TileContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/TopologyContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/TopologyContainer.js new file mode 100644 index 00000000..cc0d46b3 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/TopologyContainer.js @@ -0,0 +1,34 @@ +/* + * 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 React from 'react' +import { useSelector } from 'react-redux' +import TopologyGroup from './groups/TopologyGroup' + +function TopologyContainer() { + const topology = useSelector((state) => state.topology.root) + const interactionLevel = useSelector((state) => state.interactionLevel) + + return <TopologyGroup topology={topology} interactionLevel={interactionLevel} /> +} + +export default TopologyContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/WallContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/WallContainer.js new file mode 100644 index 00000000..106d8d3d --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/WallContainer.js @@ -0,0 +1,39 @@ +/* + * 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 React from 'react' +import PropTypes from 'prop-types' +import { useSelector } from 'react-redux' +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 <WallGroup {...props} tiles={tiles} /> +} + +WallContainer.propTypes = { + roomId: PropTypes.string.isRequired, +} + +export default WallContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Collapse.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Collapse.js new file mode 100644 index 00000000..931ded94 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Collapse.js @@ -0,0 +1,42 @@ +/* + * 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 { ChevronLeftIcon } from '@patternfly/react-icons' +import { collapseContainer } from './Collapse.module.css' +import { Button } from '@patternfly/react-core' + +function Collapse({ onClick }) { + return ( + <div className={collapseContainer}> + <Button variant="tertiary" onClick={onClick}> + <ChevronLeftIcon /> + </Button> + </div> + ) +} + +Collapse.propTypes = { + onClick: PropTypes.func, +} + +export default Collapse diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Collapse.module.css b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Collapse.module.css new file mode 100644 index 00000000..70fd465f --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Collapse.module.css @@ -0,0 +1,55 @@ +/*! + * 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. + */ + +.collapseContainer { + position: absolute; + right: var(--pf-global--spacer--xs); + top: 0; + bottom: 10%; + margin: auto 0; + height: 50px; +} + +.collapseContainer > button:global(.pf-m-tertiary) { + height: 100%; + padding: 2px; + + margin-right: var(--pf-global--spacer--xs); + margin-top: var(--pf-global--spacer--xs); + background-color: var(--pf-global--BackgroundColor--100); + border: none; + border-radius: var(--pf-global--BorderRadius--sm); + box-shadow: var(--pf-global--BoxShadow--sm); +} + +.collapseContainer > button:global(.pf-m-tertiary):not(:global(.pf-m-disabled)) { + background-color: var(--pf-global--BackgroundColor--100); +} + +.collapseContainer > button:global(.pf-m-tertiary):after { + display: none; +} + +.collapseContainer > button:global(.pf-m-tertiary):hover { + border: none; + box-shadow: var(--pf-global--BoxShadow--md); +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/ScaleIndicator.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/ScaleIndicator.js new file mode 100644 index 00000000..3ec893fb --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/ScaleIndicator.js @@ -0,0 +1,18 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { TILE_SIZE_IN_METERS, TILE_SIZE_IN_PIXELS } from '../MapConstants' +import { scaleIndicator } from './ScaleIndicator.module.css' + +function ScaleIndicator({ scale }) { + return ( + <div className={scaleIndicator} style={{ width: TILE_SIZE_IN_PIXELS * scale }}> + {TILE_SIZE_IN_METERS}m + </div> + ) +} + +ScaleIndicator.propTypes = { + scale: PropTypes.number.isRequired, +} + +export default ScaleIndicator diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/ScaleIndicator.module.css b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/ScaleIndicator.module.css new file mode 100644 index 00000000..f19e0ff2 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/ScaleIndicator.module.css @@ -0,0 +1,10 @@ +.scaleIndicator { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 50; + + border: solid 2px #212529; + border-top: none; + border-left: none; +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Toolbar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Toolbar.js new file mode 100644 index 00000000..00aaf3e1 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Toolbar.js @@ -0,0 +1,33 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { control, toolBar } from './Toolbar.module.css' +import { Button } from '@patternfly/react-core' +import { SearchPlusIcon, SearchMinusIcon, CameraIcon } from '@patternfly/react-icons' + +function Toolbar({ onZoom, onExport }) { + return ( + <div className={toolBar}> + <Button variant="tertiary" title="Zoom in" onClick={() => onZoom(true)} className={control}> + <SearchPlusIcon /> + </Button> + <Button variant="tertiary" title="Zoom out" onClick={() => onZoom(false)} className={control}> + <SearchMinusIcon /> + </Button> + <Button + variant="tertiary" + title="Export Canvas to PNG Image" + onClick={() => onExport()} + className={control} + > + <CameraIcon /> + </Button> + </div> + ) +} + +Toolbar.propTypes = { + onZoom: PropTypes.func, + onExport: PropTypes.func, +} + +export default Toolbar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Toolbar.module.css b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Toolbar.module.css new file mode 100644 index 00000000..007389da --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/controls/Toolbar.module.css @@ -0,0 +1,27 @@ +.toolBar { + position: absolute; + bottom: var(--pf-global--spacer--md); + left: var(--pf-global--spacer--xl); +} + +.control:global(.pf-m-tertiary) { + margin-right: var(--pf-global--spacer--xs); + margin-top: var(--pf-global--spacer--xs); + background-color: var(--pf-global--BackgroundColor--100); + border: none; + border-radius: var(--pf-global--BorderRadius--sm); + box-shadow: var(--pf-global--BoxShadow--sm); +} + +.control:global(.pf-m-tertiary):not(:global(.pf-m-disabled)) { + background-color: var(--pf-global--BackgroundColor--100); +} + +.control:global(.pf-m-tertiary):after { + display: none; +} + +.control:global(.pf-m-tertiary):hover { + border: none; + box-shadow: var(--pf-global--BoxShadow--md); +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/Backdrop.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/Backdrop.js new file mode 100644 index 00000000..93037b51 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/Backdrop.js @@ -0,0 +1,10 @@ +import React from 'react' +import { Rect } from 'react-konva' +import { BACKDROP_COLOR } from '../../../../util/colors' +import { MAP_SIZE_IN_PIXELS } from '../MapConstants' + +function Backdrop() { + return <Rect x={0} y={0} width={MAP_SIZE_IN_PIXELS} height={MAP_SIZE_IN_PIXELS} fill={BACKDROP_COLOR} /> +} + +export default Backdrop diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/GrayLayer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/GrayLayer.js new file mode 100644 index 00000000..08c687f6 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/GrayLayer.js @@ -0,0 +1,24 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Rect } from 'react-konva' +import { GRAYED_OUT_AREA_COLOR } from '../../../../util/colors' +import { MAP_SIZE_IN_PIXELS } from '../MapConstants' + +function GrayLayer({ onClick }) { + return ( + <Rect + x={0} + y={0} + width={MAP_SIZE_IN_PIXELS} + height={MAP_SIZE_IN_PIXELS} + fill={GRAYED_OUT_AREA_COLOR} + onClick={onClick} + /> + ) +} + +GrayLayer.propTypes = { + onClick: PropTypes.func, +} + +export default GrayLayer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/HoverTile.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/HoverTile.js new file mode 100644 index 00000000..20c2c6d1 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/HoverTile.js @@ -0,0 +1,30 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Rect } from 'react-konva' +import { ROOM_HOVER_INVALID_COLOR, ROOM_HOVER_VALID_COLOR } from '../../../../util/colors' +import { TILE_SIZE_IN_PIXELS } from '../MapConstants' + +function HoverTile({ x, y, isValid, scale = 1, onClick }) { + return ( + <Rect + x={x} + y={y} + scaleX={scale} + scaleY={scale} + width={TILE_SIZE_IN_PIXELS} + height={TILE_SIZE_IN_PIXELS} + fill={isValid ? ROOM_HOVER_VALID_COLOR : ROOM_HOVER_INVALID_COLOR} + onClick={onClick} + /> + ) +} + +HoverTile.propTypes = { + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + isValid: PropTypes.bool.isRequired, + scale: PropTypes.number, + onClick: PropTypes.func.isRequired, +} + +export default HoverTile diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/ImageComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/ImageComponent.js new file mode 100644 index 00000000..fdae53f2 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/ImageComponent.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { Image } from 'react-konva' + +const imageCaches = {} + +function ImageComponent({ src, x, y, width, height, opacity }) { + const [image, setImage] = useState(null) + + useEffect(() => { + if (imageCaches[src]) { + setImage(imageCaches[src]) + return + } + + const image = new window.Image() + image.src = src + image.onload = () => { + setImage(image) + imageCaches[src] = image + } + }, [src]) + + // eslint-disable-next-line jsx-a11y/alt-text + return <Image image={image} x={x} y={y} width={width} height={height} opacity={opacity} /> +} + +ImageComponent.propTypes = { + src: PropTypes.string.isRequired, + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + height: PropTypes.number.isRequired, + opacity: PropTypes.number.isRequired, +} + +export default ImageComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/RackFillBar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/RackFillBar.js new file mode 100644 index 00000000..aa284944 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/RackFillBar.js @@ -0,0 +1,68 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Group, Rect } from 'react-konva' +import { + RACK_ENERGY_BAR_BACKGROUND_COLOR, + RACK_ENERGY_BAR_FILL_COLOR, + RACK_SPACE_BAR_BACKGROUND_COLOR, + RACK_SPACE_BAR_FILL_COLOR, +} from '../../../../util/colors' +import { + OBJECT_BORDER_WIDTH_IN_PIXELS, + OBJECT_MARGIN_IN_PIXELS, + RACK_FILL_ICON_OPACITY, + RACK_FILL_ICON_WIDTH, + TILE_SIZE_IN_PIXELS, +} from '../MapConstants' +import ImageComponent from './ImageComponent' + +function RackFillBar({ positionX, positionY, type, fillFraction }) { + const halfOfObjectBorderWidth = OBJECT_BORDER_WIDTH_IN_PIXELS / 2 + const x = + positionX * TILE_SIZE_IN_PIXELS + + OBJECT_MARGIN_IN_PIXELS + + (type === 'space' ? halfOfObjectBorderWidth : 0.5 * (TILE_SIZE_IN_PIXELS - 2 * OBJECT_MARGIN_IN_PIXELS)) + const startY = positionY * TILE_SIZE_IN_PIXELS + OBJECT_MARGIN_IN_PIXELS + halfOfObjectBorderWidth + const width = 0.5 * (TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2) - halfOfObjectBorderWidth + const fullHeight = TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2 - OBJECT_BORDER_WIDTH_IN_PIXELS + + const fractionHeight = fillFraction * fullHeight + const fractionY = + (positionY + 1) * TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS - halfOfObjectBorderWidth - fractionHeight + + return ( + <Group> + <Rect + x={x} + y={startY} + width={width} + height={fullHeight} + fill={type === 'space' ? RACK_SPACE_BAR_BACKGROUND_COLOR : RACK_ENERGY_BAR_BACKGROUND_COLOR} + /> + <Rect + x={x} + y={fractionY} + width={width} + height={fractionHeight} + fill={type === 'space' ? RACK_SPACE_BAR_FILL_COLOR : RACK_ENERGY_BAR_FILL_COLOR} + /> + <ImageComponent + src={'/img/topology/rack-' + type + '-icon.png'} + x={x + width * 0.5 - RACK_FILL_ICON_WIDTH * 0.5} + y={startY + fullHeight * 0.5 - RACK_FILL_ICON_WIDTH * 0.5} + width={RACK_FILL_ICON_WIDTH} + height={RACK_FILL_ICON_WIDTH} + opacity={RACK_FILL_ICON_OPACITY} + /> + </Group> + ) +} + +RackFillBar.propTypes = { + positionX: PropTypes.number.isRequired, + positionY: PropTypes.number.isRequired, + type: PropTypes.string.isRequired, + fillFraction: PropTypes.number.isRequired, +} + +export default RackFillBar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/RoomTile.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/RoomTile.js new file mode 100644 index 00000000..e7329dc0 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/RoomTile.js @@ -0,0 +1,24 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Rect } from 'react-konva' +import { Tile } from '../../../../shapes' +import { TILE_SIZE_IN_PIXELS } from '../MapConstants' + +function RoomTile({ tile, color }) { + return ( + <Rect + x={tile.positionX * TILE_SIZE_IN_PIXELS} + y={tile.positionY * TILE_SIZE_IN_PIXELS} + width={TILE_SIZE_IN_PIXELS} + height={TILE_SIZE_IN_PIXELS} + fill={color} + /> + ) +} + +RoomTile.propTypes = { + tile: Tile, + color: PropTypes.string, +} + +export default RoomTile diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/TileObject.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/TileObject.js new file mode 100644 index 00000000..3211f187 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/TileObject.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Rect } from 'react-konva' +import { OBJECT_BORDER_COLOR } from '../../../../util/colors' +import { OBJECT_BORDER_WIDTH_IN_PIXELS, OBJECT_MARGIN_IN_PIXELS, TILE_SIZE_IN_PIXELS } from '../MapConstants' + +function TileObject({ positionX, positionY, color }) { + return ( + <Rect + x={positionX * TILE_SIZE_IN_PIXELS + OBJECT_MARGIN_IN_PIXELS} + y={positionY * TILE_SIZE_IN_PIXELS + OBJECT_MARGIN_IN_PIXELS} + width={TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2} + height={TILE_SIZE_IN_PIXELS - OBJECT_MARGIN_IN_PIXELS * 2} + fill={color} + stroke={OBJECT_BORDER_COLOR} + strokeWidth={OBJECT_BORDER_WIDTH_IN_PIXELS} + /> + ) +} + +TileObject.propTypes = { + positionX: PropTypes.number.isRequired, + positionY: PropTypes.number.isRequired, + color: PropTypes.string.isRequired, +} + +export default TileObject diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/TilePlusIcon.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/TilePlusIcon.js new file mode 100644 index 00000000..186c2b3a --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/TilePlusIcon.js @@ -0,0 +1,44 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Group, Line } from 'react-konva' +import { TILE_PLUS_COLOR } from '../../../../util/colors' +import { TILE_PLUS_MARGIN_IN_PIXELS, TILE_PLUS_WIDTH_IN_PIXELS, TILE_SIZE_IN_PIXELS } from '../MapConstants' + +function TilePlusIcon({ x, y, scale = 1 }) { + const linePoints = [ + [ + x + 0.5 * TILE_SIZE_IN_PIXELS * scale, + y + TILE_PLUS_MARGIN_IN_PIXELS * scale, + x + 0.5 * TILE_SIZE_IN_PIXELS * scale, + y + TILE_SIZE_IN_PIXELS * scale - TILE_PLUS_MARGIN_IN_PIXELS * scale, + ], + [ + x + TILE_PLUS_MARGIN_IN_PIXELS * scale, + y + 0.5 * TILE_SIZE_IN_PIXELS * scale, + x + TILE_SIZE_IN_PIXELS * scale - TILE_PLUS_MARGIN_IN_PIXELS * scale, + y + 0.5 * TILE_SIZE_IN_PIXELS * scale, + ], + ] + return ( + <Group> + {linePoints.map((points, index) => ( + <Line + key={index} + points={points} + lineCap="round" + stroke={TILE_PLUS_COLOR} + strokeWidth={TILE_PLUS_WIDTH_IN_PIXELS * scale} + listening={false} + /> + ))} + </Group> + ) +} + +TilePlusIcon.propTypes = { + x: PropTypes.number, + y: PropTypes.number, + scale: PropTypes.number, +} + +export default TilePlusIcon diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/WallSegment.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/WallSegment.js new file mode 100644 index 00000000..4f18813e --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/elements/WallSegment.js @@ -0,0 +1,32 @@ +import React from 'react' +import { Line } from 'react-konva' +import { WallSegment as WallSegmentShape } from '../../../../shapes' +import { WALL_COLOR } from '../../../../util/colors' +import { TILE_SIZE_IN_PIXELS, WALL_WIDTH_IN_PIXELS } from '../MapConstants' + +function WallSegment({ wallSegment }) { + let points + if (wallSegment.isHorizontal) { + points = [ + wallSegment.startPosX * TILE_SIZE_IN_PIXELS, + wallSegment.startPosY * TILE_SIZE_IN_PIXELS, + (wallSegment.startPosX + wallSegment.length) * TILE_SIZE_IN_PIXELS, + wallSegment.startPosY * TILE_SIZE_IN_PIXELS, + ] + } else { + points = [ + wallSegment.startPosX * TILE_SIZE_IN_PIXELS, + wallSegment.startPosY * TILE_SIZE_IN_PIXELS, + wallSegment.startPosX * TILE_SIZE_IN_PIXELS, + (wallSegment.startPosY + wallSegment.length) * TILE_SIZE_IN_PIXELS, + ] + } + + return <Line points={points} lineCap="round" stroke={WALL_COLOR} strokeWidth={WALL_WIDTH_IN_PIXELS} /> +} + +WallSegment.propTypes = { + wallSegment: WallSegmentShape, +} + +export default WallSegment diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/GridGroup.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/GridGroup.js new file mode 100644 index 00000000..d66a18de --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/GridGroup.js @@ -0,0 +1,36 @@ +import React from 'react' +import { Group, Line } from 'react-konva' +import { GRID_COLOR } from '../../../../util/colors' +import { GRID_LINE_WIDTH_IN_PIXELS, MAP_SIZE, MAP_SIZE_IN_PIXELS, TILE_SIZE_IN_PIXELS } from '../MapConstants' + +const MAP_COORDINATE_ENTRIES = Array.from(new Array(MAP_SIZE), (x, i) => i) +const HORIZONTAL_POINT_PAIRS = MAP_COORDINATE_ENTRIES.map((index) => [ + 0, + index * TILE_SIZE_IN_PIXELS, + MAP_SIZE_IN_PIXELS, + index * TILE_SIZE_IN_PIXELS, +]) +const VERTICAL_POINT_PAIRS = MAP_COORDINATE_ENTRIES.map((index) => [ + index * TILE_SIZE_IN_PIXELS, + 0, + index * TILE_SIZE_IN_PIXELS, + MAP_SIZE_IN_PIXELS, +]) + +function GridGroup() { + return ( + <Group> + {HORIZONTAL_POINT_PAIRS.concat(VERTICAL_POINT_PAIRS).map((points, index) => ( + <Line + key={index} + points={points} + stroke={GRID_COLOR} + strokeWidth={GRID_LINE_WIDTH_IN_PIXELS} + listening={false} + /> + ))} + </Group> + ) +} + +export default GridGroup diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/RackGroup.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/RackGroup.js new file mode 100644 index 00000000..ed942661 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/RackGroup.js @@ -0,0 +1,25 @@ +import React from 'react' +import { Group } from 'react-konva' +import { Tile } from '../../../../shapes' +import { RACK_BACKGROUND_COLOR } from '../../../../util/colors' +import TileObject from '../elements/TileObject' +import RackSpaceFillContainer from '../RackSpaceFillContainer' +import RackEnergyFillContainer from '../RackEnergyFillContainer' + +function RackGroup({ tile }) { + return ( + <Group> + <TileObject positionX={tile.positionX} positionY={tile.positionY} color={RACK_BACKGROUND_COLOR} /> + <Group> + <RackSpaceFillContainer rackId={tile.rack} positionX={tile.positionX} positionY={tile.positionY} /> + <RackEnergyFillContainer rackId={tile.rack} positionX={tile.positionX} positionY={tile.positionY} /> + </Group> + </Group> + ) +} + +RackGroup.propTypes = { + tile: Tile, +} + +export default RackGroup diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/RoomGroup.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/RoomGroup.js new file mode 100644 index 00000000..3f8b3089 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/RoomGroup.js @@ -0,0 +1,52 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Group } from 'react-konva' +import { InteractionLevel, Room } from '../../../../shapes' +import GrayContainer from '../GrayContainer' +import TileContainer from '../TileContainer' +import WallContainer from '../WallContainer' + +function RoomGroup({ room, interactionLevel, currentRoomInConstruction, onClick }) { + if (currentRoomInConstruction === room.id) { + return ( + <Group onClick={onClick}> + {room.tiles.map((tileId) => ( + <TileContainer key={tileId} tileId={tileId} newTile={true} /> + ))} + </Group> + ) + } + + return ( + <Group onClick={onClick}> + {(() => { + if ( + (interactionLevel.mode === 'RACK' || interactionLevel.mode === 'MACHINE') && + interactionLevel.roomId === room.id + ) { + return [ + room.tiles + .filter((tileId) => tileId !== interactionLevel.tileId) + .map((tileId) => <TileContainer key={tileId} tileId={tileId} />), + <GrayContainer key={-1} />, + room.tiles + .filter((tileId) => tileId === interactionLevel.tileId) + .map((tileId) => <TileContainer key={tileId} tileId={tileId} />), + ] + } else { + return room.tiles.map((tileId) => <TileContainer key={tileId} tileId={tileId} />) + } + })()} + <WallContainer roomId={room.id} /> + </Group> + ) +} + +RoomGroup.propTypes = { + room: Room, + interactionLevel: InteractionLevel, + currentRoomInConstruction: PropTypes.string, + onClick: PropTypes.func, +} + +export default RoomGroup diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/TileGroup.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/TileGroup.js new file mode 100644 index 00000000..f2084017 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/TileGroup.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Group } from 'react-konva' +import { Tile } from '../../../../shapes' +import { ROOM_DEFAULT_COLOR, ROOM_IN_CONSTRUCTION_COLOR } from '../../../../util/colors' +import RoomTile from '../elements/RoomTile' +import RackContainer from '../RackContainer' + +function TileGroup({ tile, newTile, onClick }) { + let tileObject + if (tile.rack) { + tileObject = <RackContainer tile={tile} /> + } else { + tileObject = null + } + + let color = ROOM_DEFAULT_COLOR + if (newTile) { + color = ROOM_IN_CONSTRUCTION_COLOR + } + + return ( + <Group onClick={() => onClick(tile)}> + <RoomTile tile={tile} color={color} /> + {tileObject} + </Group> + ) +} + +TileGroup.propTypes = { + tile: Tile, + newTile: PropTypes.bool, + onClick: PropTypes.func, +} + +export default TileGroup diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/TopologyGroup.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/TopologyGroup.js new file mode 100644 index 00000000..011dcf34 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/TopologyGroup.js @@ -0,0 +1,44 @@ +import React from 'react' +import { Group } from 'react-konva' +import { InteractionLevel, Topology } from '../../../../shapes' +import RoomContainer from '../RoomContainer' +import GrayContainer from '../GrayContainer' + +function TopologyGroup({ topology, interactionLevel }) { + if (!topology) { + return <Group /> + } + + if (interactionLevel.mode === 'BUILDING') { + return ( + <Group> + {topology.rooms.map((roomId) => ( + <RoomContainer key={roomId} roomId={roomId} /> + ))} + </Group> + ) + } + + return ( + <Group> + {topology.rooms + .filter((roomId) => roomId !== interactionLevel.roomId) + .map((roomId) => ( + <RoomContainer key={roomId} roomId={roomId} /> + ))} + {interactionLevel.mode === 'ROOM' ? <GrayContainer /> : null} + {topology.rooms + .filter((roomId) => roomId === interactionLevel.roomId) + .map((roomId) => ( + <RoomContainer key={roomId} roomId={roomId} /> + ))} + </Group> + ) +} + +TopologyGroup.propTypes = { + topology: Topology, + interactionLevel: InteractionLevel, +} + +export default TopologyGroup diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/WallGroup.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/WallGroup.js new file mode 100644 index 00000000..6cbd1cd0 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/groups/WallGroup.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Group } from 'react-konva' +import { Tile } from '../../../../shapes' +import { deriveWallLocations } from '../../../../util/tile-calculations' +import WallSegment from '../elements/WallSegment' + +function WallGroup({ tiles }) { + return ( + <Group> + {deriveWallLocations(tiles).map((wallSegment, index) => ( + <WallSegment key={index} wallSegment={wallSegment} /> + ))} + </Group> + ) +} + +WallGroup.propTypes = { + tiles: PropTypes.arrayOf(Tile).isRequired, +} + +export default WallGroup diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/HoverLayerComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/HoverLayerComponent.js new file mode 100644 index 00000000..d7e0c56a --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/HoverLayerComponent.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types' +import React, { useMemo, useState } from 'react' +import { Layer } from 'react-konva/lib/ReactKonva' +import HoverTile from '../elements/HoverTile' +import { TILE_SIZE_IN_PIXELS } from '../MapConstants' +import { useEffectRef } from '../../../../util/effect-ref' + +function HoverLayerComponent({ isEnabled, isValid, onClick, children }) { + const [[mouseWorldX, mouseWorldY], setPos] = useState([0, 0]) + + const layerRef = useEffectRef((layer) => { + if (!layer) { + return + } + + const stage = layer.getStage() + + stage.on('mousemove.hover', () => { + // Transform used to convert mouse coordinates to world coordinates + const transform = stage.getAbsoluteTransform().copy() + transform.invert() + + const { x, y } = transform.point(stage.getPointerPosition()) + setPos([x, y]) + }) + return () => stage.off('mousemove.hover') + }) + + const gridX = Math.floor(mouseWorldX / TILE_SIZE_IN_PIXELS) + const gridY = Math.floor(mouseWorldY / TILE_SIZE_IN_PIXELS) + const valid = useMemo(() => isEnabled && isValid(gridX, gridY), [isEnabled, isValid, gridX, gridY]) + + if (!isEnabled) { + return <Layer /> + } + + const x = gridX * TILE_SIZE_IN_PIXELS + const y = gridY * TILE_SIZE_IN_PIXELS + + return ( + <Layer opacity={0.2} ref={layerRef}> + <HoverTile x={x} y={y} isValid={valid} onClick={() => (valid ? onClick(gridX, gridY) : undefined)} /> + {children ? React.cloneElement(children, { x, y, scale: 1 }) : undefined} + </Layer> + ) +} + +HoverLayerComponent.propTypes = { + isEnabled: PropTypes.bool.isRequired, + isValid: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, + children: PropTypes.node, +} + +export default HoverLayerComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/MapLayer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/MapLayer.js new file mode 100644 index 00000000..c902532b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/MapLayer.js @@ -0,0 +1,41 @@ +/* + * 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 React from 'react' +import { Group, Layer } from 'react-konva' +import Backdrop from '../elements/Backdrop' +import TopologyContainer from '../TopologyContainer' +import GridGroup from '../groups/GridGroup' + +function MapLayer() { + return ( + <Layer> + <Group> + <Backdrop /> + <TopologyContainer /> + <GridGroup /> + </Group> + </Layer> + ) +} + +export default MapLayer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/ObjectHoverLayer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/ObjectHoverLayer.js new file mode 100644 index 00000000..5e741a3b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/ObjectHoverLayer.js @@ -0,0 +1,51 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { addRackToTile } from '../../../../redux/actions/topology/room' +import { findTileWithPosition } from '../../../../util/tile-calculations' +import HoverLayerComponent from './HoverLayerComponent' +import TilePlusIcon from '../elements/TilePlusIcon' + +export default function ObjectHoverLayer() { + const isEnabled = useSelector((state) => state.construction.inRackConstructionMode) + const isValid = useSelector((state) => (x, y) => { + if (state.interactionLevel.mode !== 'ROOM') { + return false + } + + const currentRoom = state.topology.rooms[state.interactionLevel.roomId] + const tiles = currentRoom.tiles.map((tileId) => state.topology.tiles[tileId]) + const tile = findTileWithPosition(tiles, x, y) + + return !(tile === null || tile.rack) + }) + + const dispatch = useDispatch() + const onClick = (x, y) => dispatch(addRackToTile(x, y)) + return ( + <HoverLayerComponent onClick={onClick} isEnabled={isEnabled} isValid={isValid}> + <TilePlusIcon /> + </HoverLayerComponent> + ) +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/RoomHoverLayer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/RoomHoverLayer.js new file mode 100644 index 00000000..b9cfcaf4 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/map/layers/RoomHoverLayer.js @@ -0,0 +1,59 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { toggleTileAtLocation } from '../../../../redux/actions/topology/building' +import { + deriveValidNextTilePositions, + findPositionInPositions, + findPositionInRooms, +} from '../../../../util/tile-calculations' +import HoverLayerComponent from './HoverLayerComponent' + +export default function RoomHoverLayer() { + const dispatch = useDispatch() + const onClick = (x, y) => dispatch(toggleTileAtLocation(x, y)) + const isEnabled = useSelector((state) => state.construction.currentRoomInConstruction !== '-1') + const isValid = useSelector((state) => (x, y) => { + const newRoom = { ...state.topology.rooms[state.construction.currentRoomInConstruction] } + const oldRooms = Object.keys(state.topology.rooms) + .map((id) => ({ ...state.topology.rooms[id] })) + .filter( + (room) => + state.topology.root.rooms.indexOf(room.id) !== -1 && + room.id !== state.construction.currentRoomInConstruction + ) + + ;[...oldRooms, newRoom].forEach((room) => { + room.tiles = room.tiles.map((tileId) => state.topology.tiles[tileId]) + }) + if (newRoom.tiles.length === 0) { + return findPositionInRooms(oldRooms, x, y) === -1 + } + + const validNextPositions = deriveValidNextTilePositions(oldRooms, newRoom.tiles) + return findPositionInPositions(validNextPositions, x, y) !== -1 + }) + + return <HoverLayerComponent onClick={onClick} isEnabled={isEnabled} isValid={isValid} /> +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/NameComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/NameComponent.js new file mode 100644 index 00000000..ececd07b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/NameComponent.js @@ -0,0 +1,69 @@ +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import { Button, TextInput } from '@patternfly/react-core' +import { PencilAltIcon, CheckIcon, TimesIcon } from '@patternfly/react-icons' + +function NameComponent({ name, onEdit }) { + const [isEditing, setEditing] = useState(false) + const nameInput = useRef(null) + + const onCancel = () => { + nameInput.current.value = name + setEditing(false) + } + + const onSubmit = (event) => { + if (event) { + event.preventDefault() + } + + const name = nameInput.current.value + if (name) { + onEdit(name) + } + + setEditing(false) + } + + return ( + <form + className={`pf-c-inline-edit ${isEditing ? 'pf-m-inline-editable' : ''} pf-u-display-inline-block`} + onSubmit={onSubmit} + > + <div className="pf-c-inline-edit__group"> + <div className="pf-c-inline-edit__value" id="single-inline-edit-example-label"> + {name} + </div> + <div className="pf-c-inline-edit__action pf-m-enable-editable"> + <Button className="pf-u-py-0" variant="plain" aria-label="Edit" onClick={() => setEditing(true)}> + <PencilAltIcon /> + </Button> + </div> + </div> + <div className="pf-c-inline-edit__group"> + <div className="pf-c-inline-edit__input"> + <TextInput type="text" defaultValue={name} ref={nameInput} aria-label="Editable text input" /> + </div> + <div className="pf-c-inline-edit__group pf-m-action-group pf-m-icon-group"> + <div className="pf-c-inline-edit__action pf-m-valid"> + <Button className="pf-u-py-0" variant="plain" aria-label="Save edits" onClick={onSubmit}> + <CheckIcon /> + </Button> + </div> + <div className="pf-c-inline-edit__action"> + <Button className="pf-u-py-0" variant="plain" aria-label="Cancel edits" onClick={onCancel}> + <TimesIcon /> + </Button> + </div> + </div> + </div> + </form> + ) +} + +NameComponent.propTypes = { + name: PropTypes.string, + onEdit: PropTypes.func, +} + +export default NameComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/TopologySidebar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/TopologySidebar.js new file mode 100644 index 00000000..5aaa7834 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/TopologySidebar.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { InteractionLevel } from '../../../shapes' +import BuildingSidebar from './building/BuildingSidebar' +import { + Button, + DrawerActions, + DrawerCloseButton, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + Flex, + Title, +} from '@patternfly/react-core' +import { AngleLeftIcon } from '@patternfly/react-icons' +import { useDispatch } from 'react-redux' +import { backButton } from './TopologySidebar.module.css' +import RoomSidebar from './room/RoomSidebar' +import RackSidebar from './rack/RackSidebar' +import MachineSidebar from './machine/MachineSidebar' +import { goDownOneInteractionLevel } from '../../../redux/actions/interaction-level' + +const name = { + BUILDING: 'Building', + ROOM: 'Room', + RACK: 'Rack', + MACHINE: 'Machine', +} + +function TopologySidebar({ interactionLevel, onClose }) { + let sidebarContent + + switch (interactionLevel.mode) { + case 'BUILDING': + sidebarContent = <BuildingSidebar /> + break + case 'ROOM': + sidebarContent = <RoomSidebar roomId={interactionLevel.roomId} /> + break + case 'RACK': + sidebarContent = <RackSidebar tileId={interactionLevel.tileId} /> + break + case 'MACHINE': + sidebarContent = <MachineSidebar tileId={interactionLevel.tileId} position={interactionLevel.position} /> + break + default: + sidebarContent = 'Missing Content' + } + + const dispatch = useDispatch() + const onClick = () => dispatch(goDownOneInteractionLevel()) + + return ( + <DrawerPanelContent isResizable defaultSize="450px" minSize="400px"> + <DrawerHead> + <Flex> + <Button + variant="tertiary" + isSmall + className={backButton} + onClick={interactionLevel.mode === 'BUILDING' ? onClose : onClick} + > + <AngleLeftIcon /> + </Button> + <Title className="pf-u-align-self-center" headingLevel="h1"> + {name[interactionLevel.mode]} + </Title> + </Flex> + <DrawerActions> + <DrawerCloseButton onClose={onClose} /> + </DrawerActions> + </DrawerHead> + <DrawerPanelBody>{sidebarContent}</DrawerPanelBody> + </DrawerPanelContent> + ) +} + +TopologySidebar.propTypes = { + interactionLevel: InteractionLevel, + onClose: PropTypes.func, +} + +export default TopologySidebar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/TopologySidebar.module.css b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/TopologySidebar.module.css new file mode 100644 index 00000000..3853c625 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/TopologySidebar.module.css @@ -0,0 +1,35 @@ +/*! + * 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. + */ + +.backButton:global(.pf-c-button) { + align-self: center; + --pf-c-button--after--BorderColor: var(--pf-global--BorderColor--light-100); + color: var(--pf-global--Color--400); + + --pf-c-button--PaddingRight: var(--pf-global--spacer--sm); + --pf-c-button--PaddingLeft: var(--pf-global--spacer--sm); +} + +.backButton:hover, +.backButton:global(.pf-c-button):focus { + --pf-c-button--after--BorderColor: var(--pf-global--BorderColor--100); +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/BuildingSidebar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/BuildingSidebar.js new file mode 100644 index 00000000..5fcd46be --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/BuildingSidebar.js @@ -0,0 +1,8 @@ +import React from 'react' +import NewRoomConstructionContainer from './NewRoomConstructionContainer' + +function BuildingSidebar() { + return <NewRoomConstructionContainer /> +} + +export default BuildingSidebar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/NewRoomConstructionComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/NewRoomConstructionComponent.js new file mode 100644 index 00000000..9fc85d0c --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/NewRoomConstructionComponent.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Button, Toolbar, ToolbarContent, ToolbarGroup, ToolbarItem } from '@patternfly/react-core' +import PlusIcon from '@patternfly/react-icons/dist/js/icons/plus-icon' +import CheckIcon from '@patternfly/react-icons/dist/js/icons/check-icon' + +function NewRoomConstructionComponent({ onStart, onFinish, onCancel, currentRoomInConstruction }) { + if (currentRoomInConstruction === '-1') { + return ( + <Button isBlock icon={<PlusIcon />} onClick={onStart}> + Construct a new room + </Button> + ) + } + return ( + <Toolbar + inset={{ + default: 'insetNone', + }} + > + <ToolbarContent> + <ToolbarGroup> + <ToolbarItem> + <Button icon={<CheckIcon />} onClick={onFinish}> + Finalize new room + </Button> + </ToolbarItem> + <ToolbarItem widths={{ default: '100%' }}> + <Button isBlock variant="secondary" onClick={onCancel}> + Cancel + </Button> + </ToolbarItem> + </ToolbarGroup> + </ToolbarContent> + </Toolbar> + ) +} + +NewRoomConstructionComponent.propTypes = { + onStart: PropTypes.func, + onFinish: PropTypes.func, + onCancel: PropTypes.func, + currentRoomInConstruction: PropTypes.string, +} + +export default NewRoomConstructionComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/NewRoomConstructionContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/NewRoomConstructionContainer.js new file mode 100644 index 00000000..c149b224 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/building/NewRoomConstructionContainer.js @@ -0,0 +1,46 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { + cancelNewRoomConstruction, + finishNewRoomConstruction, + startNewRoomConstruction, +} from '../../../../redux/actions/topology/building' +import NewRoomConstructionComponent from './NewRoomConstructionComponent' + +function NewRoomConstructionButton() { + const currentRoomInConstruction = useSelector((state) => state.construction.currentRoomInConstruction) + const dispatch = useDispatch() + + return ( + <NewRoomConstructionComponent + onStart={() => dispatch(startNewRoomConstruction())} + onFinish={() => dispatch(finishNewRoomConstruction())} + onCancel={() => dispatch(cancelNewRoomConstruction())} + currentRoomInConstruction={currentRoomInConstruction} + /> + ) +} + +export default NewRoomConstructionButton diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/DeleteMachine.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/DeleteMachine.js new file mode 100644 index 00000000..a4b9457b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/DeleteMachine.js @@ -0,0 +1,59 @@ +/* + * 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 React, { useState } from 'react' +import { useDispatch } from 'react-redux' +import { Button } from '@patternfly/react-core' +import { TrashIcon } from '@patternfly/react-icons' +import ConfirmationModal from '../../../util/modals/ConfirmationModal' +import { deleteMachine } from '../../../../redux/actions/topology/machine' + +function DeleteMachine({ machineId }) { + const dispatch = useDispatch() + const [isVisible, setVisible] = useState(false) + const callback = (isConfirmed) => { + if (isConfirmed) { + dispatch(deleteMachine(machineId)) + } + setVisible(false) + } + return ( + <> + <Button variant="danger" icon={<TrashIcon />} isBlock onClick={() => setVisible(true)}> + Delete this machine + </Button> + <ConfirmationModal + title="Delete this machine" + message="Are you sure you want to delete this machine?" + isOpen={isVisible} + callback={callback} + /> + </> + ) +} + +DeleteMachine.propTypes = { + machineId: PropTypes.string.isRequired, +} + +export default DeleteMachine diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/MachineSidebar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/MachineSidebar.js new file mode 100644 index 00000000..8a4c33dc --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/MachineSidebar.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types' +import React from 'react' +import UnitTabsComponent from './UnitTabsComponent' +import DeleteMachine from './DeleteMachine' +import { + TextContent, + TextList, + TextListItem, + TextListItemVariants, + TextListVariants, + Title, +} from '@patternfly/react-core' +import { useSelector } from 'react-redux' + +function MachineSidebar({ tileId, position }) { + const machine = useSelector(({ topology }) => { + const rack = topology.racks[topology.tiles[tileId].rack] + + for (const machineId of rack.machines) { + const machine = topology.machines[machineId] + if (machine.position === position) { + return machine + } + } + }) + const machineId = machine.id + return ( + <div> + <TextContent> + <Title headingLevel="h2">Details</Title> + <TextList component={TextListVariants.dl}> + <TextListItem component={TextListItemVariants.dt}>Name</TextListItem> + <TextListItem component={TextListItemVariants.dd}> + Machine at position {machine.position} + </TextListItem> + </TextList> + + <Title headingLevel="h2">Actions</Title> + <DeleteMachine machineId={machineId} /> + + <Title headingLevel="h2">Units</Title> + </TextContent> + <div className="pf-u-h-100"> + <UnitTabsComponent machineId={machineId} /> + </div> + </div> + ) +} + +MachineSidebar.propTypes = { + tileId: PropTypes.string.isRequired, + position: PropTypes.number.isRequired, +} + +export default MachineSidebar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitAddComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitAddComponent.js new file mode 100644 index 00000000..18cba23a --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitAddComponent.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { Button, InputGroup, Select, SelectOption, SelectVariant } from '@patternfly/react-core' +import PlusIcon from '@patternfly/react-icons/dist/js/icons/plus-icon' + +function UnitAddComponent({ units, onAdd }) { + const [isOpen, setOpen] = useState(false) + const [selected, setSelected] = useState(null) + + return ( + <InputGroup> + <Select + variant={SelectVariant.single} + placeholderText="Select a unit" + aria-label="Select Unit" + onToggle={() => setOpen(!isOpen)} + isOpen={isOpen} + onSelect={(_, selection) => { + setSelected(selection) + setOpen(false) + }} + selections={selected} + > + {units.map((unit) => ( + <SelectOption value={unit.id} key={unit.id}> + {unit.name} + </SelectOption> + ))} + </Select> + <Button icon={<PlusIcon />} variant="control" onClick={() => onAdd(selected)} isDisabled={!selected}> + Add + </Button> + </InputGroup> + ) +} + +UnitAddComponent.propTypes = { + units: PropTypes.array.isRequired, + onAdd: PropTypes.func.isRequired, +} + +export default UnitAddComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitAddContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitAddContainer.js new file mode 100644 index 00000000..a0054ef6 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitAddContainer.js @@ -0,0 +1,44 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import UnitAddComponent from './UnitAddComponent' +import { addUnit } from '../../../../redux/actions/topology/machine' +import UnitType from './UnitType' + +function UnitAddContainer({ machineId, unitType }) { + const units = useSelector((state) => Object.values(state.topology[unitType])) + const dispatch = useDispatch() + + const onAdd = (id) => dispatch(addUnit(machineId, unitType, id)) + + return <UnitAddComponent onAdd={onAdd} units={units} /> +} + +UnitAddContainer.propTypes = { + machineId: PropTypes.string.isRequired, + unitType: UnitType.isRequired, +} + +export default UnitAddContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitListComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitListComponent.js new file mode 100644 index 00000000..75ab0ad7 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitListComponent.js @@ -0,0 +1,113 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { + Button, + DataList, + DataListAction, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, + DescriptionList, + DescriptionListDescription, + DescriptionListGroup, + DescriptionListTerm, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Popover, + Title, +} from '@patternfly/react-core' +import { CubesIcon, InfoIcon, TrashIcon } from '@patternfly/react-icons' +import { ProcessingUnit, StorageUnit } from '../../../../shapes' +import UnitType from './UnitType' + +function UnitInfo({ unit, unitType }) { + if (unitType === 'cpus' || unitType === 'gpus') { + return ( + <DescriptionList> + <DescriptionListGroup> + <DescriptionListTerm>Clock Frequency</DescriptionListTerm> + <DescriptionListDescription>{unit.clockRateMhz} MHz</DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Number of Cores</DescriptionListTerm> + <DescriptionListDescription>{unit.numberOfCores}</DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Energy Consumption</DescriptionListTerm> + <DescriptionListDescription>{unit.energyConsumptionW} W</DescriptionListDescription> + </DescriptionListGroup> + </DescriptionList> + ) + } + + return ( + <DescriptionList> + <DescriptionListGroup> + <DescriptionListTerm>Speed</DescriptionListTerm> + <DescriptionListDescription>{unit.speedMbPerS} Mb/s</DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Capacity</DescriptionListTerm> + <DescriptionListDescription>{unit.sizeMb} MB</DescriptionListDescription> + </DescriptionListGroup> + <DescriptionListGroup> + <DescriptionListTerm>Energy Consumption</DescriptionListTerm> + <DescriptionListDescription>{unit.energyConsumptionW} W</DescriptionListDescription> + </DescriptionListGroup> + </DescriptionList> + ) +} + +UnitInfo.propTypes = { + unitType: UnitType.isRequired, + unit: PropTypes.oneOfType([ProcessingUnit, StorageUnit]).isRequired, +} + +function UnitListComponent({ unitType, units, onDelete }) { + if (units.length === 0) { + return ( + <EmptyState> + <EmptyStateIcon icon={CubesIcon} /> + <Title headingLevel="h5" size="lg"> + No units found + </Title> + <EmptyStateBody>You have not configured any units yet. Add some with the menu above!</EmptyStateBody> + </EmptyState> + ) + } + + return ( + <DataList aria-label="Machine Units" isCompact> + {units.map((unit, index) => ( + <DataListItem key={index}> + <DataListItemRow> + <DataListItemCells dataListCells={[<DataListCell key="unit">{unit.name}</DataListCell>]} /> + <DataListAction id="goto" aria-label="Goto Machine" aria-labelledby="goto"> + <Popover + headerContent="Unit Information" + bodyContent={<UnitInfo unitType={unitType} unit={unit} />} + > + <Button isSmall variant="plain" className="pf-u-p-0"> + <InfoIcon /> + </Button> + </Popover> + <Button isSmall variant="plain" className="pf-u-p-0" onClick={() => onDelete(units[index])}> + <TrashIcon /> + </Button> + </DataListAction> + </DataListItemRow> + </DataListItem> + ))} + </DataList> + ) +} + +UnitListComponent.propTypes = { + unitType: UnitType.isRequired, + units: PropTypes.arrayOf(PropTypes.oneOfType([ProcessingUnit, StorageUnit])).isRequired, + onDelete: PropTypes.func, +} + +export default UnitListComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitListContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitListContainer.js new file mode 100644 index 00000000..bcd4bdcc --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitListContainer.js @@ -0,0 +1,47 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import UnitListComponent from './UnitListComponent' +import { deleteUnit } from '../../../../redux/actions/topology/machine' +import UnitType from './UnitType' + +function UnitListContainer({ machineId, unitType }) { + const dispatch = useDispatch() + const units = useSelector((state) => { + const machine = state.topology.machines[machineId] + return machine[unitType].map((id) => state.topology[unitType][id]) + }) + + const onDelete = (unit) => dispatch(deleteUnit(machineId, unitType, unit.id)) + + return <UnitListComponent units={units} unitType={unitType} onDelete={onDelete} /> +} + +UnitListContainer.propTypes = { + machineId: PropTypes.string.isRequired, + unitType: UnitType.isRequired, +} + +export default UnitListContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitTabsComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitTabsComponent.js new file mode 100644 index 00000000..4032d607 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitTabsComponent.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types' +import React, { useState } from 'react' +import { Tab, Tabs, TabTitleText } from '@patternfly/react-core' +import UnitAddContainer from './UnitAddContainer' +import UnitListContainer from './UnitListContainer' + +function UnitTabsComponent({ machineId }) { + const [activeTab, setActiveTab] = useState('cpuModel-units') + + return ( + <Tabs activeKey={activeTab} onSelect={(_, tab) => setActiveTab(tab)}> + <Tab eventKey="cpuModel-units" title={<TabTitleText>CPU</TabTitleText>}> + <UnitAddContainer machineId={machineId} unitType="cpus" /> + <UnitListContainer machineId={machineId} unitType="cpus" /> + </Tab> + <Tab eventKey="gpu-units" title={<TabTitleText>GPU</TabTitleText>}> + <UnitAddContainer machineId={machineId} unitType="gpus" /> + <UnitListContainer machineId={machineId} unitType="gpus" /> + </Tab> + <Tab eventKey="memory-units" title={<TabTitleText>Memory</TabTitleText>}> + <UnitAddContainer machineId={machineId} unitType="memories" /> + <UnitListContainer machineId={machineId} unitType="memories" /> + </Tab> + <Tab eventKey="storage-units" title={<TabTitleText>Storage</TabTitleText>}> + <UnitAddContainer machineId={machineId} unitType="storages" /> + <UnitListContainer machineId={machineId} unitType="storages" /> + </Tab> + </Tabs> + ) +} + +UnitTabsComponent.propTypes = { + machineId: PropTypes.string.isRequired, +} + +export default UnitTabsComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitType.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitType.js new file mode 100644 index 00000000..b6d7bf8b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/machine/UnitType.js @@ -0,0 +1,25 @@ +/* + * 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 PropTypes from 'prop-types' + +export default PropTypes.oneOf(['cpus', 'gpus', 'memories', 'storages']) diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js new file mode 100644 index 00000000..6a0c3ff3 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/AddPrefab.js @@ -0,0 +1,41 @@ +/* + * 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 React from 'react' +import { Button } from '@patternfly/react-core' +import { SaveIcon } from '@patternfly/react-icons' + +function AddPrefab() { + const onClick = () => {} // TODO + return ( + <Button variant="primary" icon={<SaveIcon />} isBlock onClick={onClick} className="pf-u-mb-sm"> + Save this rack to a prefab + </Button> + ) +} + +AddPrefab.propTypes = { + tileId: PropTypes.string.isRequired, +} + +export default AddPrefab diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/DeleteRackContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/DeleteRackContainer.js new file mode 100644 index 00000000..0583a7a4 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/DeleteRackContainer.js @@ -0,0 +1,60 @@ +/* + * 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 React, { useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import TrashIcon from '@patternfly/react-icons/dist/js/icons/trash-icon' +import { Button } from '@patternfly/react-core' +import ConfirmationModal from '../../../util/modals/ConfirmationModal' +import { deleteRack } from '../../../../redux/actions/topology/rack' + +function DeleteRackContainer({ tileId }) { + const dispatch = useDispatch() + const [isVisible, setVisible] = useState(false) + const rackId = useSelector((state) => state.topology.tiles[tileId].rack) + const callback = (isConfirmed) => { + if (isConfirmed) { + dispatch(deleteRack(tileId, rackId)) + } + setVisible(false) + } + return ( + <> + <Button variant="danger" icon={<TrashIcon />} isBlock onClick={() => setVisible(true)}> + Delete this rack + </Button> + <ConfirmationModal + title="Delete this rack" + message="Are you sure you want to delete this rack?" + isOpen={isVisible} + callback={callback} + /> + </> + ) +} + +DeleteRackContainer.propTypes = { + tileId: PropTypes.string.isRequired, +} + +export default DeleteRackContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineComponent.js new file mode 100644 index 00000000..b0a96a9f --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineComponent.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Flex, Label } from '@patternfly/react-core' +import { Machine } from '../../../../shapes' + +const UnitIcon = ({ id, type }) => ( + // eslint-disable-next-line @next/next/no-img-element + <img src={'/img/topology/' + id + '-icon.png'} alt={'Machine contains ' + type + ' units'} height={24} width={24} /> +) + +UnitIcon.propTypes = { + id: PropTypes.string, + type: PropTypes.string, +} + +function MachineComponent({ machine, onClick }) { + const hasNoUnits = + machine.cpus.length + machine.gpus.length + machine.memories.length + machine.storages.length === 0 + + return ( + <Flex onClick={() => onClick()}> + {machine.cpus.length > 0 ? <UnitIcon id="cpuModel" type="CPU" /> : undefined} + {machine.gpus.length > 0 ? <UnitIcon id="gpu" type="GPU" /> : undefined} + {machine.memories.length > 0 ? <UnitIcon id="memory" type="memory" /> : undefined} + {machine.storages.length > 0 ? <UnitIcon id="storage" type="storage" /> : undefined} + {hasNoUnits ? ( + <Label variant="outline" color="orange"> + Machine with no units + </Label> + ) : undefined} + </Flex> + ) +} + +MachineComponent.propTypes = { + machine: Machine.isRequired, + onClick: PropTypes.func, +} + +export default MachineComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineListComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineListComponent.js new file mode 100644 index 00000000..02c97730 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineListComponent.js @@ -0,0 +1,80 @@ +import PropTypes from 'prop-types' +import React from 'react' +import MachineComponent from './MachineComponent' +import { + Badge, + Button, + DataList, + DataListAction, + DataListCell, + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core' +import { AngleRightIcon, PlusIcon } from '@patternfly/react-icons' +import { Machine } from '../../../../shapes' + +function MachineListComponent({ machines = [], onSelect, onAdd }) { + return ( + <DataList aria-label="Rack Units"> + {machines + .map((machine, index) => + machine ? ( + <DataListItem key={index} onClick={() => onSelect(index + 1)}> + <DataListItemRow> + <DataListItemCells + dataListCells={[ + <DataListCell isIcon key="icon"> + <Badge isRead>{index + 1}U</Badge> + </DataListCell>, + <DataListCell key="primary content"> + <MachineComponent onClick={() => onSelect(index + 1)} machine={machine} /> + </DataListCell>, + ]} + /> + <DataListAction id="goto" aria-label="Goto Machine" aria-labelledby="goto"> + <Button isSmall variant="plain" className="pf-u-p-0"> + <AngleRightIcon /> + </Button> + </DataListAction> + </DataListItemRow> + </DataListItem> + ) : ( + <DataListItem key={index}> + <DataListItemRow> + <DataListItemCells + dataListCells={[ + <DataListCell isIcon key="icon"> + <Badge isRead>{index + 1}U</Badge> + </DataListCell>, + <DataListCell key="add" className="text-secondary"> + Empty Slot + </DataListCell>, + ]} + /> + <DataListAction id="add" aria-label="Add Machine" aria-labelledby="add"> + <Button + isSmall + variant="plain" + className="pf-u-p-0" + onClick={() => onAdd(index + 1)} + > + <PlusIcon /> + </Button> + </DataListAction> + </DataListItemRow> + </DataListItem> + ) + ) + .reverse()} + </DataList> + ) +} + +MachineListComponent.propTypes = { + machines: PropTypes.arrayOf(Machine), + onSelect: PropTypes.func.isRequired, + onAdd: PropTypes.func.isRequired, +} + +export default MachineListComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineListContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineListContainer.js new file mode 100644 index 00000000..e1914730 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/MachineListContainer.js @@ -0,0 +1,56 @@ +/* + * 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 React, { useMemo } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import MachineListComponent from './MachineListComponent' +import { goFromRackToMachine } from '../../../../redux/actions/interaction-level' +import { addMachine } from '../../../../redux/actions/topology/rack' + +function MachineListContainer({ tileId, ...props }) { + const rack = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack]) + const machines = useSelector((state) => rack.machines.map((id) => state.topology.machines[id])) + const machinesNull = useMemo(() => { + const res = Array(rack.capacity).fill(null) + for (const machine of machines) { + res[machine.position - 1] = machine + } + return res + }, [rack, machines]) + const dispatch = useDispatch() + + return ( + <MachineListComponent + {...props} + machines={machinesNull} + onAdd={(index) => dispatch(addMachine(rack.id, index))} + onSelect={(index) => dispatch(goFromRackToMachine(index))} + /> + ) +} + +MachineListContainer.propTypes = { + tileId: PropTypes.string.isRequired, +} + +export default MachineListContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackNameContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackNameContainer.js new file mode 100644 index 00000000..c3422318 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackNameContainer.js @@ -0,0 +1,22 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import NameComponent from '../NameComponent' +import { editRackName } from '../../../../redux/actions/topology/rack' + +const RackNameContainer = ({ tileId }) => { + const { name: rackName, id } = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack]) + const dispatch = useDispatch() + const callback = (name) => { + if (name) { + dispatch(editRackName(id, name)) + } + } + return <NameComponent name={rackName} onEdit={callback} /> +} + +RackNameContainer.propTypes = { + tileId: PropTypes.string.isRequired, +} + +export default RackNameContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackSidebar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackSidebar.js new file mode 100644 index 00000000..cb7d3b68 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackSidebar.js @@ -0,0 +1,58 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { machineListContainer, sidebarContainer } from './RackSidebar.module.css' +import RackNameContainer from './RackNameContainer' +import AddPrefab from './AddPrefab' +import DeleteRackContainer from './DeleteRackContainer' +import MachineListContainer from './MachineListContainer' +import { + Skeleton, + TextContent, + TextList, + TextListItem, + TextListItemVariants, + TextListVariants, + Title, +} from '@patternfly/react-core' +import { useSelector } from 'react-redux' + +function RackSidebar({ tileId }) { + const rack = useSelector((state) => state.topology.racks[state.topology.tiles[tileId].rack]) + + return ( + <div className={sidebarContainer}> + <TextContent> + <Title headingLevel="h2">Details</Title> + <TextList component={TextListVariants.dl}> + <TextListItem + component={TextListItemVariants.dt} + className="pf-u-display-inline-flex pf-u-align-items-center" + > + Name + </TextListItem> + <TextListItem component={TextListItemVariants.dd}> + <RackNameContainer tileId={tileId} /> + </TextListItem> + <TextListItem component={TextListItemVariants.dt}>Capacity</TextListItem> + <TextListItem component={TextListItemVariants.dd}> + {rack?.capacity ?? <Skeleton screenreaderText="Loading rack" />} + </TextListItem> + </TextList> + <Title headingLevel="h2">Actions</Title> + <AddPrefab tileId={tileId} /> + <DeleteRackContainer tileId={tileId} /> + + <Title headingLevel="h2">Slots</Title> + </TextContent> + <div className={machineListContainer}> + <MachineListContainer tileId={tileId} /> + </div> + </div> + ) +} + +RackSidebar.propTypes = { + tileId: PropTypes.string.isRequired, +} + +export default RackSidebar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackSidebar.module.css b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackSidebar.module.css new file mode 100644 index 00000000..f4c8829f --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/rack/RackSidebar.module.css @@ -0,0 +1,14 @@ +.sidebarContainer { + display: flex; + flex-direction: column; + + height: 100%; +} + +.machineListContainer { + overflow-y: auto; + + flex: 1 0 300px; + + margin-top: 10px; +} diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/DeleteRoomContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/DeleteRoomContainer.js new file mode 100644 index 00000000..29b8f78a --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/DeleteRoomContainer.js @@ -0,0 +1,59 @@ +/* + * 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 React, { useState } from 'react' +import { useDispatch } from 'react-redux' +import ConfirmationModal from '../../../util/modals/ConfirmationModal' +import { deleteRoom } from '../../../../redux/actions/topology/room' +import TrashIcon from '@patternfly/react-icons/dist/js/icons/trash-icon' +import { Button } from '@patternfly/react-core' + +function DeleteRoomContainer({ roomId }) { + const dispatch = useDispatch() + const [isVisible, setVisible] = useState(false) + const callback = (isConfirmed) => { + if (isConfirmed) { + dispatch(deleteRoom(roomId)) + } + setVisible(false) + } + return ( + <> + <Button variant="danger" icon={<TrashIcon />} isBlock onClick={() => setVisible(true)}> + Delete this room + </Button> + <ConfirmationModal + title="Delete this room" + message="Are you sure you want to delete this room?" + isOpen={isVisible} + callback={callback} + /> + </> + ) +} + +DeleteRoomContainer.propTypes = { + roomId: PropTypes.string.isRequired, +} + +export default DeleteRoomContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/EditRoomContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/EditRoomContainer.js new file mode 100644 index 00000000..7a278cd6 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/EditRoomContainer.js @@ -0,0 +1,61 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { finishRoomEdit, startRoomEdit } from '../../../../redux/actions/topology/building' +import CheckIcon from '@patternfly/react-icons/dist/js/icons/check-icon' +import PencilAltIcon from '@patternfly/react-icons/dist/js/icons/pencil-alt-icon' +import { Button } from '@patternfly/react-core' + +function EditRoomContainer({ roomId }) { + const isEditing = useSelector((state) => state.construction.currentRoomInConstruction !== '-1') + const isInRackConstructionMode = useSelector((state) => state.construction.inRackConstructionMode) + + const dispatch = useDispatch() + const onEdit = () => dispatch(startRoomEdit(roomId)) + const onFinish = () => dispatch(finishRoomEdit()) + + return isEditing ? ( + <Button variant="tertiary" icon={<CheckIcon />} isBlock onClick={onFinish} className="pf-u-mb-sm"> + Finish editing room + </Button> + ) : ( + <Button + variant="tertiary" + icon={<PencilAltIcon />} + isBlock + disabled={isInRackConstructionMode} + onClick={() => (isInRackConstructionMode ? undefined : onEdit())} + className="pf-u-mb-sm" + > + Edit the tiles of this room + </Button> + ) +} + +EditRoomContainer.propTypes = { + roomId: PropTypes.string.isRequired, +} + +export default EditRoomContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js new file mode 100644 index 00000000..a384d5d5 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionComponent.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types' +import React from 'react' +import { Button } from '@patternfly/react-core' +import { PlusIcon, TimesIcon } from '@patternfly/react-icons' + +const RackConstructionComponent = ({ onStart, onStop, inRackConstructionMode, isEditingRoom }) => { + if (inRackConstructionMode) { + return ( + <Button isBlock={true} icon={<TimesIcon />} onClick={onStop} className="pf-u-mb-sm"> + Stop rack construction + </Button> + ) + } + + return ( + <Button + icon={<PlusIcon />} + isBlock + isDisabled={isEditingRoom} + onClick={() => (isEditingRoom ? undefined : onStart())} + className="pf-u-mb-sm" + > + Start rack construction + </Button> + ) +} + +RackConstructionComponent.propTypes = { + onStart: PropTypes.func, + onStop: PropTypes.func, + inRackConstructionMode: PropTypes.bool, + isEditingRoom: PropTypes.bool, +} + +export default RackConstructionComponent diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js new file mode 100644 index 00000000..e04287a5 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RackConstructionContainer.js @@ -0,0 +1,46 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { startRackConstruction, stopRackConstruction } from '../../../../redux/actions/topology/room' +import RackConstructionComponent from './RackConstructionComponent' + +function RackConstructionContainer(props) { + const isRackConstructionMode = useSelector((state) => state.construction.inRackConstructionMode) + const isEditingRoom = useSelector((state) => state.construction.currentRoomInConstruction !== '-1') + + const dispatch = useDispatch() + const onStart = () => dispatch(startRackConstruction()) + const onStop = () => dispatch(stopRackConstruction()) + return ( + <RackConstructionComponent + {...props} + inRackConstructionMode={isRackConstructionMode} + isEditingRoom={isEditingRoom} + onStart={onStart} + onStop={onStop} + /> + ) +} + +export default RackConstructionContainer diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RoomName.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RoomName.js new file mode 100644 index 00000000..72d45bea --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RoomName.js @@ -0,0 +1,44 @@ +/* + * 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 React from 'react' +import { useDispatch, useSelector } from 'react-redux' +import NameComponent from '../NameComponent' +import { editRoomName } from '../../../../redux/actions/topology/room' + +function RoomName({ roomId }) { + const { name: roomName, id } = useSelector((state) => state.topology.rooms[roomId]) + const dispatch = useDispatch() + const callback = (name) => { + if (name) { + dispatch(editRoomName(id, name)) + } + } + return <NameComponent name={roomName} onEdit={callback} /> +} + +RoomName.propTypes = { + roomId: PropTypes.string.isRequired, +} + +export default RoomName diff --git a/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RoomSidebar.js b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RoomSidebar.js new file mode 100644 index 00000000..6ad489e0 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/topologies/sidebar/room/RoomSidebar.js @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types' +import React from 'react' +import RoomName from './RoomName' +import RackConstructionContainer from './RackConstructionContainer' +import EditRoomContainer from './EditRoomContainer' +import DeleteRoomContainer from './DeleteRoomContainer' +import { + TextContent, + TextList, + TextListItem, + TextListItemVariants, + TextListVariants, + Title, +} from '@patternfly/react-core' + +const RoomSidebar = ({ roomId }) => { + return ( + <TextContent> + <Title headingLevel="h2">Details</Title> + <TextList component={TextListVariants.dl}> + <TextListItem + component={TextListItemVariants.dt} + className="pf-u-display-inline-flex pf-u-align-items-center" + > + Name + </TextListItem> + <TextListItem component={TextListItemVariants.dd}> + <RoomName roomId={roomId} /> + </TextListItem> + </TextList> + <Title headingLevel="h2">Construction</Title> + <RackConstructionContainer /> + <EditRoomContainer roomId={roomId} /> + <DeleteRoomContainer roomId={roomId} /> + </TextContent> + ) +} + +RoomSidebar.propTypes = { + roomId: PropTypes.string.isRequired, +} + +export default RoomSidebar diff --git a/opendc-web/opendc-web-server/src/main/webui/components/util/TableEmptyState.js b/opendc-web/opendc-web-server/src/main/webui/components/util/TableEmptyState.js new file mode 100644 index 00000000..9d16ffbb --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/util/TableEmptyState.js @@ -0,0 +1,103 @@ +/* + * 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 { Bullseye, EmptyState, EmptyStateBody, EmptyStateIcon, Spinner, Title } from '@patternfly/react-core' +import { SearchIcon, CubesIcon } from '@patternfly/react-icons' +import { Status } from '../../shapes' + +function TableEmptyState({ + status, + isFiltering, + loadingTitle = 'Loading', + emptyTitle = 'No results found', + emptyText = 'No results found of this type.', + emptyAction = '', +}) { + if (status === 'loading') { + return ( + <Bullseye> + <EmptyState variant="xs"> + <EmptyStateIcon variant="container" component={Spinner} /> + <Title headingLevel="h4" size="md"> + {loadingTitle} + </Title> + </EmptyState> + </Bullseye> + ) + } else if (status === 'error') { + return ( + <EmptyState variant="xs"> + <Title headingLevel="h4" size="md"> + Unable to connect + </Title> + <EmptyStateBody> + There was an error retrieving data. Check your connection and try again. + </EmptyStateBody> + </EmptyState> + ) + } else if (status === 'idle') { + return ( + <EmptyState variant="xs"> + <EmptyStateIcon icon={CubesIcon} /> + <Title headingLevel="h4" size="md"> + {emptyTitle} + </Title> + <EmptyStateBody>No results available at this moment.</EmptyStateBody> + </EmptyState> + ) + } else if (isFiltering) { + return ( + <EmptyState variant="xs"> + <EmptyStateIcon icon={SearchIcon} /> + <Title headingLevel="h4" size="md"> + No results found + </Title> + <EmptyStateBody> + No results match this filter criteria. Remove all filters or clear all filters to show results. + </EmptyStateBody> + </EmptyState> + ) + } + + return ( + <EmptyState variant="xs"> + <EmptyStateIcon icon={CubesIcon} /> + <Title headingLevel="h4" size="md"> + {emptyTitle} + </Title> + <EmptyStateBody>{emptyText}</EmptyStateBody> + {emptyAction} + </EmptyState> + ) +} + +TableEmptyState.propTypes = { + status: Status.isRequired, + isFiltering: PropTypes.bool, + loadingTitle: PropTypes.string, + emptyTitle: PropTypes.string, + emptyText: PropTypes.string, + emptyAction: PropTypes.node, +} + +export default TableEmptyState diff --git a/opendc-web/opendc-web-server/src/main/webui/components/util/modals/ConfirmationModal.js b/opendc-web/opendc-web-server/src/main/webui/components/util/modals/ConfirmationModal.js new file mode 100644 index 00000000..f6e1c98b --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/util/modals/ConfirmationModal.js @@ -0,0 +1,27 @@ +import PropTypes from 'prop-types' +import React from 'react' +import Modal from './Modal' + +function ConfirmationModal({ title, message, isOpen, callback }) { + return ( + <Modal + title={title} + isOpen={isOpen} + onSubmit={() => callback(true)} + onCancel={() => callback(false)} + submitButtonType="danger" + submitButtonText="Confirm" + > + {message} + </Modal> + ) +} + +ConfirmationModal.propTypes = { + title: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + callback: PropTypes.func.isRequired, +} + +export default ConfirmationModal diff --git a/opendc-web/opendc-web-server/src/main/webui/components/util/modals/Modal.js b/opendc-web/opendc-web-server/src/main/webui/components/util/modals/Modal.js new file mode 100644 index 00000000..d4577062 --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/util/modals/Modal.js @@ -0,0 +1,38 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { Button, Modal as PModal, ModalVariant } from '@patternfly/react-core' + +function Modal({ children, title, isOpen, onSubmit, onCancel, submitButtonType, submitButtonText }) { + const actions = [ + <Button variant={submitButtonType} onClick={onSubmit} key="confirm"> + {submitButtonText} + </Button>, + <Button variant="link" onClick={onCancel} key="cancel"> + Cancel + </Button>, + ] + + return ( + <PModal variant={ModalVariant.small} isOpen={isOpen} onClose={onCancel} title={title} actions={actions}> + {children} + </PModal> + ) +} + +Modal.propTypes = { + title: PropTypes.string.isRequired, + isOpen: PropTypes.bool, + onSubmit: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + submitButtonType: PropTypes.string, + submitButtonText: PropTypes.string, + children: PropTypes.node, +} + +Modal.defaultProps = { + submitButtonType: 'primary', + submitButtonText: 'Save', + isOpen: false, +} + +export default Modal diff --git a/opendc-web/opendc-web-server/src/main/webui/components/util/modals/TextInputModal.js b/opendc-web/opendc-web-server/src/main/webui/components/util/modals/TextInputModal.js new file mode 100644 index 00000000..392a729e --- /dev/null +++ b/opendc-web/opendc-web-server/src/main/webui/components/util/modals/TextInputModal.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types' +import React, { useRef, useState } from 'react' +import Modal from './Modal' +import { Form, FormGroup, TextInput } from '@patternfly/react-core' + +function TextInputModal({ title, label, isOpen, callback, initialValue }) { + const textInput = useRef(null) + const [isSubmitted, setSubmitted] = useState(false) + const [isValid, setValid] = useState(true) + + const resetState = () => { + textInput.current.value = '' + setSubmitted(false) + setValid(false) + } + const onSubmit = (event) => { + const value = textInput.current.value + setSubmitted(true) + + if (event) { + event.preventDefault() + } + + if (!value) { + setValid(false) + return false + } + + callback(value) + resetState() + return true + } + const onCancel = () => { + callback(undefined) + resetState() + } + + return ( + <Modal title={title} isOpen={isOpen} onSubmit={onSubmit} onCancel={onCancel}> + <Form onSubmit={onSubmit}> + <FormGroup + label={label} + fieldId="text-input" + isRequired + validated={isSubmitted && !isValid ? 'error' : 'default'} + helperTextInvalid="This field cannot be empty" + > + <TextInput + id="text-input" + name="text-input" + isRequired + type="text" + ref={textInput} + defaultValue={initialValue} + /> + </FormGroup> + </Form> + </Modal> + ) +} + +TextInputModal.propTypes = { + title: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isOpen: PropTypes.bool.isRequired, + callback: PropTypes.func.isRequired, + initialValue: PropTypes.string, +} + +export default TextInputModal |
