diff --git a/client/src/components/Allocations/Allocations.scss b/client/src/components/Allocations/Allocations.scss index efb5629646..aa4bd34845 100644 --- a/client/src/components/Allocations/Allocations.scss +++ b/client/src/components/Allocations/Allocations.scss @@ -135,7 +135,7 @@ } } .alloc-badge { - font-size: 11px; + font-size: 14px; } } #pending { @@ -153,65 +153,11 @@ .allocations-request-body { color: #484848; } -.team-view-modal-wrapper { - .table-hover { - tbody { - /* FP-457: Will fix this *//* stylelint-disable no-descending-specificity */ - tr { - height: 35px; - cursor: pointer; - color: #484848; - td { - font-weight: 500; - font-size: 14px; - vertical-align: middle; - } - } - /* stylelint-enable no-descending-specificity */ - tr:hover { - background-color: #dfdaf5; - } - } - } - .modal-body { - overflow-y: initial !important; - } - .modal-left { - height: 50vh; - max-width: 230px; - overflow-y: scroll; - padding-left: 0; - padding-right: 0; - border-right: 1px solid rgba(112, 112, 112, 0.25); - } - .modal-right { - display: flex; - padding: 1em; - max-width: 500px; - overflow: auto; - .contact-card { - display: flex; - flex-direction: column; - font: 16px Roboto, sans-serif; - height: 100%; - overflow: auto; - div { - padding: 0.25rem; - text-overflow: ellipsis; - overflow: auto; - } - } - } - .user-name { - margin-left: 1.5rem; - } - .active-user { - background-color: #dfdaf5; - } -} - /* HACK: The loading icon should not need height set, but it fixes Safari here */ /* FP-426: Do not use `height: 100%` or isolate usage pending solution to bigger problem */ #allocations-wrapper .loading-icon { height: auto; } +.system-cell { + width: 120px; +} diff --git a/client/src/components/Allocations/AllocationsCells.js b/client/src/components/Allocations/AllocationsCells.js index ed22acdddd..35c08cef00 100644 --- a/client/src/components/Allocations/AllocationsCells.js +++ b/client/src/components/Allocations/AllocationsCells.js @@ -3,7 +3,7 @@ import { object, shape, array, number, string } from 'prop-types'; import { Button, Badge } from 'reactstrap'; import { useDispatch } from 'react-redux'; import { v4 as uuidv4 } from 'uuid'; -import { TeamView } from './AllocationsModals'; +import { AllocationsTeamViewModal } from './AllocationsModals'; const CELL_PROPTYPES = { cell: shape({ @@ -31,7 +31,7 @@ export const Team = ({ cell: { value } }) => { > View Team - setOpenModal(!openModal)} diff --git a/client/src/components/Allocations/AllocationsLayout.js b/client/src/components/Allocations/AllocationsLayout.js index 50b8a82f0c..f20a455d1c 100644 --- a/client/src/components/Allocations/AllocationsLayout.js +++ b/client/src/components/Allocations/AllocationsLayout.js @@ -7,7 +7,7 @@ import { faClipboard, faDesktop } from '@fortawesome/free-solid-svg-icons'; import { string } from 'prop-types'; import { LoadingSpinner } from '_common'; import { AllocationsTable } from './AllocationsTables'; -import { NewAllocReq } from './AllocationsModals'; +import { AllocationsRequestModal } from './AllocationsModals'; import * as ROUTES from '../../constants/routes'; export const Header = ({ page }) => { @@ -23,7 +23,7 @@ export const Header = ({ page }) => { Manage Allocations {openModal && ( - setOpenModal(!openModal)} /> diff --git a/client/src/components/Allocations/AllocationsModals.js b/client/src/components/Allocations/AllocationsModals.js deleted file mode 100644 index 56ee0496b8..0000000000 --- a/client/src/components/Allocations/AllocationsModals.js +++ /dev/null @@ -1,198 +0,0 @@ -import React, { useState } from 'react'; -import { number, string, func, bool, shape, arrayOf, object } from 'prop-types'; -import { - Modal, - ModalHeader, - ModalBody, - Table, - Container, - Col, - Row -} from 'reactstrap'; -import { useSelector } from 'react-redux'; -import { useTable } from 'react-table'; -import { LoadingSpinner } from '_common'; -import { capitalize, has } from 'lodash'; - -const MODAL_PROPTYPES = { - isOpen: bool.isRequired, - toggle: func.isRequired -}; -const USER_LISTING_PROPTYPES = shape({ - firstName: string.isRequired, - lastName: string.isRequired, - email: string.isRequired, - username: string.isRequired -}); - -export const NewAllocReq = ({ isOpen, toggle }) => ( - toggle()}> - - Manage Allocations - - -

- You can manage your allocation, your team members, or request more time - on a machine by using your TACC user account credentials to access the - Resource Allocation System at{' '} - - https://tacc-submit.xras.xsede.org/ - - . -

-
-
-); -NewAllocReq.propTypes = MODAL_PROPTYPES; - -const UserCell = ({ cell: { value } }) => { - const { firstName, lastName } = value; - return ( - - {`${capitalize(firstName)} ${capitalize(lastName)}`} - - ); -}; -UserCell.propTypes = { - cell: shape({ - value: shape({ - firstName: string.isRequired, - lastName: string.isRequired - }).isRequired - }).isRequired -}; - -const TeamTable = ({ rawData, clickHandler, visible }) => { - const data = React.useMemo(() => rawData, [rawData]); - const columns = React.useMemo( - () => [ - { - Header: 'name', - accessor: row => row, - UserCell - } - ], - [rawData] - ); - const { getTableProps, getTableBodyProps, rows, prepareRow } = useTable({ - columns, - data - }); - return ( - - - {rows.map(row => { - prepareRow(row); - return ( - { - clickHandler(row.values.name); - } - })} - > - {row.cells.map(cell => ( - - ))} - - ); - })} - -
{cell.render('UserCell')}
- ); -}; -TeamTable.propTypes = { - rawData: arrayOf(object), - clickHandler: func.isRequired, - visible: USER_LISTING_PROPTYPES -}; -TeamTable.defaultProps = { visible: {}, rawData: [] }; - -export const TeamView = ({ isOpen, toggle, pid }) => { - const { teams, loadingUsernames, errors } = useSelector( - state => state.allocations - ); - const error = has(errors.teams, pid); - const [card, setCard] = useState(null); - const isLoading = loadingUsernames[pid] && loadingUsernames[pid].loading; - return ( - - - View Team - - - - {error ? ( - - - Unable to retrieve team data. - - - ) : ( - - - {isLoading ? ( - - ) : ( - - )} - - - {isLoading ? ( - Loading user list. This may take a moment. - ) : ( - - )} - - - )} - - - - ); -}; -TeamView.propTypes = { ...MODAL_PROPTYPES, pid: number.isRequired }; - -export const ContactCard = ({ listing }) => { - if (!listing) - return ( - Click on a user’s name to view their contact information. - ); - const { firstName, lastName, email, username } = listing; - return ( -
-
- - {capitalize(firstName)} {capitalize(lastName)}
-
-
-
Username: {username}
-
Email: {email}
-
- ); -}; - -ContactCard.propTypes = { listing: USER_LISTING_PROPTYPES }; -ContactCard.defaultProps = { listing: {} }; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsModals.test.js b/client/src/components/Allocations/AllocationsModals/AllocationsModals.test.js new file mode 100644 index 0000000000..335a635945 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsModals.test.js @@ -0,0 +1,152 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react"; +import { BrowserRouter } from "react-router-dom"; +import configureStore from "redux-mock-store"; +import { Provider } from "react-redux"; +import { AllocationsRequestModal, AllocationsTeamViewModal } from './index' + +const mockStore = configureStore(); + +describe("New Allocations Request Modal", () => { + test("Allocations Request UI", () => { + const { getByText } = render( + + null} /> + + ); + const xrasLink = 'https://tacc-submit.xras.xsede.org/' + expect(getByText(/Manage Allocations/)).toBeDefined(); + expect(getByText(/You can manage your allocation/)).toBeDefined(); + expect(getByText(xrasLink)).toBeDefined(); + expect(getByText(xrasLink).href).toBe(xrasLink); + }); +}); + + +describe("View Team Modal", () => { + const testProps = { + isOpen: true, + toggle: () => null, + pid: 1234, + }; + + test("View Team Modal Loading", () => { + const testStore = mockStore({ + allocations: { + teams: { + 1234: [], + }, + loadingUsernames: { + 1234: { + loading: true, + }, + }, + errors: {}, + }, + }); + const { getByText } = render( + + + + ); + + expect(getByText(/Loading user list./)).toBeDefined(); + }); + + test("View Team Modal Listing", () => { + const testStore = mockStore({ + allocations: { + teams: { + "1234": [ + { + id: "123456", + username: "testuser1", + role: "Standard", + firstName: "Test", + lastName: "User1", + email: "user1@gmail.com", + usageData: [], + }, + { + id: "012345", + username: "testuser2", + role: "Standard", + firstName: "Test", + lastName: "User2", + email: "user2@gmail.com", + usageData: [ + { + usage: '0.5 SU', + resource: "stampede2.tacc.utexas.edu", + allocationId: 1, + percentUsed: 0.005, + }, + { + usage: '10 SU', + resource: "frontera.tacc.utexas.edu", + allocationId: 2, + percentUsed: 10, + }, + ], + }, + ], + }, + loadingUsernames: { + "1234": { + loading: false, + }, + }, + errors: {}, + }, + }); + + // Render Modal + const { getByText, queryByText, getByRole } = render( + + + + ); + + // Check for the list of users + expect(getByText(/View Team/)).toBeDefined(); + expect(getByText(/Test User1/)).toBeDefined(); + expect(getByText(/Test User2/)).toBeDefined(); + + // View Information for the user without usage + fireEvent.click(getByText(/Test User1/)); + expect(getByText(/Username/)).toBeDefined(); + expect(getByText(/Email/)).toBeDefined(); + expect(queryByText(/Usage/)).toBeDefined(); + + // View information for the user with usage + fireEvent.click(getByText(/Test User2/)); + expect(getByText(/Frontera/)).toBeDefined(); + expect(getByText(/Stampede 2/)).toBeDefined(); + }); + + test("View Team Modal Errors", () => { + const testStore = mockStore({ + allocations: { + teams: { + 1234: [], + }, + loadingUsernames: { + 1234: { + loading: true, + }, + }, + errors: { + teams: { 1234: new Error('Unable to fetch') } + }, + }, + }); + + const { getByText } = render( + + + + ); + + expect(getByText(/Unable to retrieve team data./)).toBeDefined(); + }); +}); diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsRequestModal/AllocationsRequestModal.js b/client/src/components/Allocations/AllocationsModals/AllocationsRequestModal/AllocationsRequestModal.js new file mode 100644 index 0000000000..bbdec48e77 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsRequestModal/AllocationsRequestModal.js @@ -0,0 +1,36 @@ +import React from 'react'; +import { func, bool } from 'prop-types'; +import { Modal, ModalHeader, ModalBody } from 'reactstrap'; + +export const AllocationsRequestModal = ({ isOpen, toggle }) => ( + toggle()}> + + Manage Allocations + + +

+ You can manage your allocation, your team members, or request more time + on a machine by using your TACC user account credentials to access the + Resource Allocation System at{' '} + + https://tacc-submit.xras.xsede.org/ + + . +

+
+
+); +AllocationsRequestModal.propTypes = { + isOpen: bool.isRequired, + toggle: func.isRequired +}; + +export default AllocationsRequestModal; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsRequestModal/index.js b/client/src/components/Allocations/AllocationsModals/AllocationsRequestModal/index.js new file mode 100644 index 0000000000..c909c2f156 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsRequestModal/index.js @@ -0,0 +1 @@ +export { default } from './AllocationsRequestModal'; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/AllocationsContactCard.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/AllocationsContactCard.js new file mode 100644 index 0000000000..e708ac3855 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/AllocationsContactCard.js @@ -0,0 +1,43 @@ +import React from 'react'; +import { string, shape, arrayOf, object } from 'prop-types'; +import { capitalize } from 'lodash'; +import { DescriptionList } from '_common'; +import AllocationsUsageTable from '../AllocationsUsageTable'; +import './AllocationsContactCard.module.scss'; + +const AllocationsContactCard = ({ listing }) => { + if (!listing) + return Click on a user’s name to view their allocation usage.; + const { firstName, lastName, email, username } = listing; + + return ( +
+
+ {capitalize(firstName)} {capitalize(lastName)} +
+ + +
+ ); +}; + +AllocationsContactCard.propTypes = { + listing: shape({ + firstName: string.isRequired, + lastName: string.isRequired, + email: string.isRequired, + username: string.isRequired, + usageData: arrayOf(object).isRequired + }) +}; +AllocationsContactCard.defaultProps = { listing: {} }; + +export default AllocationsContactCard; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/AllocationsContactCard.module.scss b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/AllocationsContactCard.module.scss new file mode 100644 index 0000000000..a264f5957f --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/AllocationsContactCard.module.scss @@ -0,0 +1,15 @@ +.root { + flex-grow: 1; + display: flex; + flex-direction: column; + font: 16px Roboto, sans-serif; + height: 100%; + overflow: auto; +} +.title { + padding: 0.25rem 0; + font-weight: bold; +} +.details { + padding: 0.25rem 0; +} diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/index.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/index.js new file mode 100644 index 0000000000..8b27cc2b7c --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsContactCard/index.js @@ -0,0 +1 @@ +export { default } from './AllocationsContactCard'; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/AllocationsTeamTable.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/AllocationsTeamTable.js new file mode 100644 index 0000000000..b8287b7aaf --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/AllocationsTeamTable.js @@ -0,0 +1,70 @@ +import React from 'react'; +import { string, func, shape, arrayOf, object } from 'prop-types'; +import { Table } from 'reactstrap'; +import { useTable } from 'react-table'; +import { capitalize } from 'lodash'; +import './AllocationsTeamTable.module.scss'; + +const AllocationsTeamTable = ({ rawData, clickHandler, visible }) => { + const data = React.useMemo(() => rawData, [rawData]); + const columns = React.useMemo( + () => [ + { + Header: 'listing', + accessor: el => el, + Cell: el => { + const { firstName, lastName } = el.value; + return ( + + {capitalize(firstName)} {capitalize(lastName)} + + ); + } + } + ], + [rawData] + ); + const getStyleName = listing => { + if (visible && listing.username === visible.username) return 'active-user'; + return 'row'; + }; + const { getTableProps, getTableBodyProps, rows, prepareRow } = useTable({ + columns, + data + }); + return ( + + + {rows.map(row => { + prepareRow(row); + return ( + { + clickHandler(row.values.listing); + } + })} + styleName={getStyleName(row.values.listing)} + > + {row.cells.map(cell => ( + + ))} + + ); + })} + +
{cell.render('Cell')}
+ ); +}; +AllocationsTeamTable.propTypes = { + rawData: arrayOf(object), + clickHandler: func.isRequired, + visible: shape({ + firstName: string.isRequired, + lastName: string.isRequired, + email: string.isRequired, + username: string.isRequired + }) +}; +AllocationsTeamTable.defaultProps = { visible: {}, rawData: [] }; +export default AllocationsTeamTable; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/AllocationsTeamTable.module.scss b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/AllocationsTeamTable.module.scss new file mode 100644 index 0000000000..45b13ec7f0 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/AllocationsTeamTable.module.scss @@ -0,0 +1,20 @@ +.row { + height: 35px; + cursor: pointer; + color: #484848; + td { + font-weight: 500; + font-size: 14px; + vertical-align: middle; + } +} +.row:hover { + background-color: #dfdaf5; +} +.content { + margin-left: 1.5rem; +} +.active-user { + @extend .row; + background-color: #dfdaf5; +} diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/index.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/index.js new file mode 100644 index 0000000000..278b022460 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamTable/index.js @@ -0,0 +1 @@ +export { default } from './AllocationsTeamTable'; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamViewModal.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamViewModal.js new file mode 100644 index 0000000000..39fd0e6f36 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamViewModal.js @@ -0,0 +1,77 @@ +import React, { useState } from 'react'; +import { number, bool, func } from 'prop-types'; +import { Modal, ModalHeader, ModalBody, Container, Col, Row } from 'reactstrap'; +import { useSelector } from 'react-redux'; +import { LoadingSpinner } from '_common'; +import { has } from 'lodash'; +import AllocationsTeamTable from './AllocationsTeamTable'; +import AllocationsContactCard from './AllocationsContactCard'; +import './AllocationsTeamViewModal.module.scss'; + +const AllocationsTeamViewModal = ({ isOpen, toggle, pid }) => { + const { teams, loadingUsernames, errors } = useSelector( + state => state.allocations + ); + const error = has(errors.teams, pid); + const [card, setCard] = useState(null); + const isLoading = loadingUsernames[pid] && loadingUsernames[pid].loading; + const resetCard = () => { + setCard(null); + }; + return ( + + + View Team + + + + {error ? ( + + + Unable to retrieve team data. + + + ) : ( + + + {isLoading ? ( + + ) : ( + + )} + + + {isLoading ? ( + Loading user list. This may take a moment. + ) : ( + + )} + + + )} + + + + ); +}; +AllocationsTeamViewModal.propTypes = { + isOpen: bool.isRequired, + toggle: func.isRequired, + pid: number.isRequired +}; + +export default AllocationsTeamViewModal; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamViewModal.module.scss b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamViewModal.module.scss new file mode 100644 index 0000000000..6d9107afea --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsTeamViewModal.module.scss @@ -0,0 +1,18 @@ +.root { + .modal-body { + overflow-y: initial !important; + } +} +.listing-wrapper { + height: 50vh; + max-width: 230px; + overflow-y: scroll; + padding-left: 0; + padding-right: 0; + border-right: 1px solid rgba(112, 112, 112, 0.25); +} +.information-wrapper { + display: flex; + padding: 1em; + overflow: auto; +} diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/AllocationsUsageTable.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/AllocationsUsageTable.js new file mode 100644 index 0000000000..494e7c1f91 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/AllocationsUsageTable.js @@ -0,0 +1,87 @@ +import React from 'react'; +import { useTable } from 'react-table'; +import { capitalize } from 'lodash'; +import { arrayOf, shape, string } from 'prop-types'; +import './AllocationsUsageTable.module.scss'; + +const AllocationsUsageTable = ({ rawData }) => { + const data = React.useMemo(() => rawData, [rawData]); + const columns = React.useMemo( + () => [ + { + Header: 'System', + accessor: entry => { + const system = entry.resource.split('.')[0]; + const sysNum = system.match(/\d+$/); + const sysName = capitalize(system.replace(/[0-9]/g, '')); + if (sysNum) { + return `${sysName} ${sysNum[0]}`; + } + return sysName; + } + }, + { Header: 'Individual Usage', accessor: 'usage' }, + { + Header: '% of Allocation', + accessor: entry => { + if (entry.percentUsed >= 1) { + return `${entry.percentUsed}%`; + } + if (entry.percentUsed === 0) return '0%'; + return `< 1%`; + } + } + ], + [] + ); + const { + getTableBodyProps, + rows, + prepareRow, + headerGroups, + getTableProps + } = useTable({ + columns, + data + }); + return ( +
+ + + {headerGroups.map(headerGroup => ( + + {headerGroup.headers.map(column => ( + + ))} + + ))} + + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + ))} + + ); + })} + +
{column.render('Header')}
+ {cell.render('Cell')} +
+
+ ); +}; +AllocationsUsageTable.propTypes = { + rawData: arrayOf( + shape({ + resource: string, + usage: string + }) + ) +}; +AllocationsUsageTable.defaultProps = { rawData: [] }; + +export default AllocationsUsageTable; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/AllocationsUsageTable.module.scss b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/AllocationsUsageTable.module.scss new file mode 100644 index 0000000000..059a063529 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/AllocationsUsageTable.module.scss @@ -0,0 +1,31 @@ +.container { + display: flex; + flex-direction: row; + width: 100%; +} +.root { + width: 100%; + border: 0; + font-size: 14px; +} +.header { + tr { + border-bottom: 1px solid #707070; + height: 2.25rem; + } +} +.body { + tr:nth-child(odd) { + background-color: rgba(112, 112, 112, 0.1); + } +} +.row { + height: 2.25rem; +} +.cell { + vertical-align: middle; +} +.content { + padding: 0.25rem; +} + diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/index.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/index.js new file mode 100644 index 0000000000..3a07db8642 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/AllocationsUsageTable/index.js @@ -0,0 +1 @@ +export { default } from './AllocationsUsageTable'; diff --git a/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/index.js b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/index.js new file mode 100644 index 0000000000..308b3f0136 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/AllocationsTeamViewModal/index.js @@ -0,0 +1 @@ +export { default } from './AllocationsTeamViewModal'; diff --git a/client/src/components/Allocations/AllocationsModals/index.js b/client/src/components/Allocations/AllocationsModals/index.js new file mode 100644 index 0000000000..3ac6e1b8c2 --- /dev/null +++ b/client/src/components/Allocations/AllocationsModals/index.js @@ -0,0 +1,2 @@ +export { default as AllocationsRequestModal } from './AllocationsRequestModal'; +export { default as AllocationsTeamViewModal } from './AllocationsTeamViewModal'; diff --git a/client/src/components/Allocations/AllocationsTables.js b/client/src/components/Allocations/AllocationsTables.js index 037ea6fe11..1b2eb07015 100644 --- a/client/src/components/Allocations/AllocationsTables.js +++ b/client/src/components/Allocations/AllocationsTables.js @@ -7,7 +7,6 @@ import { string } from 'prop-types'; import { Team, Systems, Awarded, Remaining, Expires } from './AllocationsCells'; import systemAccessor from './AllocationsUtils'; -/** Custom hook to get columns and data for table */ export const useAllocations = page => { const allocations = useSelector(state => { if (page === 'expired') return state.allocations.inactive; @@ -26,9 +25,15 @@ export const useAllocations = page => { }, { Header: 'Team', - accessor: ({ projectName, projectId }) => ({ + // TODO: Refactor to Util + accessor: ({ projectName, projectId, systems }) => ({ name: projectName.toLowerCase(), - projectId + projectId, + allocationIds: systems.map(sys => { + // Each system has an allocation object + const { id } = sys.allocation; + return { system: sys, id }; + }) }), Cell: Team }, @@ -36,25 +41,29 @@ export const useAllocations = page => { Header: 'Systems', accessor: ({ systems }) => systemAccessor(systems, 'Systems'), id: 'name', - Cell: Systems + Cell: Systems, + className: 'system-cell' }, { Header: 'Awarded', accessor: ({ systems }) => systemAccessor(systems, 'Awarded'), id: 'awarded', - Cell: Awarded + Cell: Awarded, + className: 'system-cell' }, { Header: 'Remaining', accessor: ({ systems }) => systemAccessor(systems, 'Remaining'), id: 'remaining', - Cell: Remaining + Cell: Remaining, + className: 'system-cell' }, { Header: 'Expires', accessor: ({ systems }) => systemAccessor(systems, 'Expires'), id: 'expires', - Cell: Expires + Cell: Expires, + className: 'system-cell' } ], [allocations] @@ -127,7 +136,13 @@ export const AllocationsTable = ({ page }) => { return ( {row.cells.map(cell => ( - {cell.render('Cell')} + + {cell.render('Cell')} + ))} ); diff --git a/client/src/components/Allocations/AllocationsUtils.js b/client/src/components/Allocations/AllocationsUtils.js index 08222d2325..a1ab2feba6 100644 --- a/client/src/components/Allocations/AllocationsUtils.js +++ b/client/src/components/Allocations/AllocationsUtils.js @@ -1,24 +1,29 @@ export default function systemAccessor(arr, header) { switch (header) { case 'Awarded': - return arr.map(({ allocation: { computeAllocated, id }, type }) => ({ - awarded: Math.round(computeAllocated), + return arr.map(({ allocation, type }) => ({ + awarded: + type === 'HPC' + ? Math.round(allocation.computeAllocated) + : Math.round(allocation.storageAllocated), type, - id + id: allocation.id })); case 'Remaining': - return arr.map( - ({ allocation: { id, computeAllocated, computeUsed }, type }) => { - const remaining = Math.round(computeAllocated - computeUsed); - const ratio = remaining / computeAllocated || 0; - return { - id, - remaining, - ratio, - type - }; - } - ); + return arr.map(({ allocation, type }) => { + const remaining = + type === 'HPC' + ? Math.round(allocation.computeAllocated - allocation.computeUsed) + : Math.round(allocation.storageAllocated); + const ratio = + type === 'HPC' ? remaining / allocation.computeAllocated || 0 : 1; + return { + id: allocation.id, + remaining, + ratio, + type + }; + }); case 'Expires': return arr.map(({ allocation: { end, id } }) => ({ id, diff --git a/client/src/components/Allocations/tests/AllocationsModals.test.js b/client/src/components/Allocations/tests/AllocationsModals.test.js deleted file mode 100644 index 91cbbe7cfd..0000000000 --- a/client/src/components/Allocations/tests/AllocationsModals.test.js +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { BrowserRouter as Wrapper } from 'react-router-dom'; -import { NewAllocReq } from '../AllocationsModals'; - -describe('Allocations Request Form', () => { - const isOpen = true; - it('should tell the user what form they are viewing', () => { - const { getByText } = render( - - !isOpen} /> - - ); - expect(getByText('Manage Allocations')).toBeDefined(); - }); - it('have a body with content for the user', () => { - const { getByTestId } = render( - - !isOpen} /> - - ); - expect(getByTestId('request-body')).toBeDefined(); - }); -}); - -describe('View Team Modal', () => { - it.todo( - 'assert that the components of view team modal are showing relevant information for the user' - ); -}); diff --git a/client/src/components/_common/DescriptionList/DescriptionList.module.scss b/client/src/components/_common/DescriptionList/DescriptionList.module.scss index 5cc6dcb99a..bf60cf086a 100644 --- a/client/src/components/_common/DescriptionList/DescriptionList.module.scss +++ b/client/src/components/_common/DescriptionList/DescriptionList.module.scss @@ -1,7 +1,10 @@ @import '../../../styles/tools/mixins.scss'; -.container { - /* … */ +.container.is-horz { + margin-bottom: 0; /* overwrite Bootstrap's `_reboot.scss` */ + & dd { + margin-bottom: 0; /* overwrite Bootstrap's `_reboot.scss` */ + } } /* Children */ diff --git a/client/src/redux/sagas/allocations.sagas.js b/client/src/redux/sagas/allocations.sagas.js index e944e447a2..096ddffc50 100644 --- a/client/src/redux/sagas/allocations.sagas.js +++ b/client/src/redux/sagas/allocations.sagas.js @@ -1,9 +1,60 @@ -import { put, takeEvery, takeLatest, call } from 'redux-saga/effects'; -import { flatten } from 'lodash'; +import { + put, + takeEvery, + takeLatest, + call, + all, + select +} from 'redux-saga/effects'; +import { chain, flatten, isEmpty } from 'lodash'; import { fetchUtil } from 'utils/fetchUtil'; import 'cross-fetch'; -const getTeams = allocations => { +export function* getAllocations(action) { + yield put({ type: 'START_ADD_ALLOCATIONS' }); + try { + const json = yield call(getAllocationsUtil); + yield put({ type: 'ADD_ALLOCATIONS', payload: json }); + yield put({ + type: 'POPULATE_TEAMS', + payload: populateTeamsUtil(json) + }); + } catch (error) { + yield put({ type: 'ADD_ALLOCATIONS_ERROR', payload: error }); + } +} +/** + * Fetch allocations data + * @async + * @returns {{portal_alloc: String, active: Array, inactive: Array, hosts: Object}} + */ +const getAllocationsUtil = async () => { + const res = await fetchUtil({ + url: '/api/users/allocations/' + }); + const json = res.response; + return json; +}; + +/** + * Fetch user data for a project + * @param {String} projectId - project id + */ +const getTeamsUtil = async projectId => { + const res = await fetchUtil({ url: `/api/users/team/${projectId}` }); + const json = res.response; + return json; +}; + +/** + * Generate an empty dictionary to look up users from project ID and map loading state + * to each project + * @param {{portal_alloc: String, active: Array, inactive: Array, hosts: Object}} data - + * Allocations data + * @returns {{teams: Object, loadingTeams: {}}} + */ +const populateTeamsUtil = data => { + const allocations = { active: data.active, inactive: data.inactive }; const teams = flatten(Object.values(allocations)).reduce( (obj, item) => ({ ...obj, [item.projectId]: {} }), {} @@ -17,27 +68,75 @@ const getTeams = allocations => { return { teams, loadingTeams }; }; -export function* getAllocations(action) { - yield put({ type: 'START_ADD_ALLOCATIONS' }); +function* getUsernames(action) { try { - const { response } = yield call(fetchUtil, { - url: '/api/users/allocations/' - }); - const { active, inactive } = response; - yield put({ type: 'ADD_ALLOCATIONS', payload: response }); + const json = yield call(getTeamsUtil, action.payload.name); + const usage = yield all( + action.payload.allocationIds.map(params => getUsageUtil(params)) + ); + const allocations = yield select(state => [ + ...state.allocations.active, + ...state.allocations.inactive + ]); + const payload = teamPayloadUtil( + action.payload.projectId, + json, + false, + flatten(usage), + allocations + ); yield put({ - type: 'POPULATE_TEAMS', - payload: getTeams({ - active, - inactive - }) + type: 'ADD_USERNAMES_TO_TEAM', + payload }); } catch (error) { - yield put({ type: 'ADD_ALLOCATIONS_ERROR', payload: error }); + yield put({ + type: 'POPULATE_TEAMS_ERROR', + payload: teamPayloadUtil(action.payload.projectId, error, true) + }); } } -const getTeamPayload = (id, obj, error = false) => { +/** + * Fetch Usage For an Allocation and Return an Array of Users with their data, + * resource used, and allocation id. + * @async + * @param {{id: Number, system: Object}} params + * @returns {{user: Object, resource: String, allocationId: Number}[]} data + */ +const getUsageUtil = async params => { + const res = await fetchUtil({ + url: `/api/users/team/usage/${params.id}` + }); + const data = res.response + .map(user => ({ + ...user, + resource: params.system.host, + allocationId: params.id + })) + .filter(Boolean); + return data; +}; + +/** + * Generate a payload for the User Data saga. + * When there is not an error, this function maps team data to Projects. + * Each user has an entry for the resources in the allocation and if they have + * usage data, it is added to their entry + * @param {Number} id - Project Id + * @param {Object} obj - User Data + * @param {Boolean} error - Error present + * @param {Object} usageData - Usage Data + * @param {Array} allocations - All allocations + * @returns {{data: Object, loading: Boolean}} + */ +const teamPayloadUtil = ( + id, + obj, + error = false, + usageData = {}, + allocations = [] +) => { const loading = { [id]: false }; if (error) { return { @@ -45,29 +144,69 @@ const getTeamPayload = (id, obj, error = false) => { loading }; } + + // Add usage entries for a project const data = { - [id]: obj.usernames.sort((a, b) => a.firstName.localeCompare(b.firstName)) + [id]: obj + .sort((a, b) => a.firstName.localeCompare(b.firstName)) + .map(user => { + const { username } = user; + const individualUsage = usageData.filter( + val => val.username === username + ); + const currentProject = allocations.find( + allocation => allocation.projectId === id + ); + const userData = { + ...user, + usageData: currentProject.systems.map(system => { + // Create empty entry for each resource + return { + type: system.type, + usage: `0 ${system.type === 'HPC' ? 'SU' : 'GB'}`, + resource: system.host, + percentUsed: 0 + }; + }) + }; + if (isEmpty(individualUsage)) return userData; + return { + ...userData, + usageData: userData.usageData.map(entry => { + const current = individualUsage.filter( + d => d.resource === entry.resource + ); + if (!isEmpty(current)) { + // Add usage data to empty entries + const totalAllocated = chain(allocations) + .map('systems') + .flatten() + .filter({ host: entry.resource }) + .map('allocation') + .filter({ projectId: id }) + .reduce( + (sum, { computeAllocated }) => sum + computeAllocated, + 0 + ) + .value(); + const totalUsed = current.reduce( + (sum, { usage }) => sum + usage, + 0 + ); + return { + usage: `${totalUsed} ${entry.type === 'HPC' ? 'SU' : 'GB'}`, + resource: entry.resource, + percentUsed: totalUsed / totalAllocated + }; + } + return entry; + }) + }; + }) }; - return { data, loading }; }; -function* getUsernames(action) { - try { - const json = yield call(fetchUtil, { - url: `/api/users/team/${action.payload.name}` - }); - const payload = getTeamPayload(action.payload.projectId, json); - yield put({ type: 'ADD_USERNAMES_TO_TEAM', payload }); - } catch (error) { - const payload = getTeamPayload(action.payload.projectId, error, true); - yield put({ - type: 'POPULATE_TEAMS_ERROR', - payload - }); - } -} - export function* watchAllocationData() { yield takeEvery('GET_ALLOCATIONS', getAllocations); } diff --git a/server/portal/apps/users/urls.py b/server/portal/apps/users/urls.py index ce50fc5081..8e7771564e 100644 --- a/server/portal/apps/users/urls.py +++ b/server/portal/apps/users/urls.py @@ -1,6 +1,6 @@ from django.conf.urls import url from django.urls import path -from portal.apps.users.views import SearchView, AuthenticatedView, UsageView, AllocationsView, TeamView, UserDataView +from portal.apps.users.views import SearchView, AuthenticatedView, UsageView, AllocationsView, TeamView, UserDataView, AllocationUsageView app_name = 'users' urlpatterns = [ @@ -9,5 +9,6 @@ url(r'^usage/$', UsageView.as_view(), name='user_usage'), url(r'^allocations/$', AllocationsView.as_view(), name='user_allocations'), path('team/', TeamView.as_view(), name='user_team'), - path('team/user/', UserDataView.as_view(), name='user_data') + path('team/user/', UserDataView.as_view(), name='user_data'), + path('team/usage/', AllocationUsageView.as_view(), name='allocation_usage') ] diff --git a/server/portal/apps/users/utils.py b/server/portal/apps/users/utils.py index 5a0202de26..dfe6299394 100644 --- a/server/portal/apps/users/utils.py +++ b/server/portal/apps/users/utils.py @@ -7,6 +7,8 @@ import logging import requests +from portal.exceptions.api import ApiException + logger = logging.getLogger(__name__) @@ -201,10 +203,11 @@ def get_usernames(project_name): auth = requests.auth.HTTPBasicAuth(settings.TAS_CLIENT_KEY, settings.TAS_CLIENT_SECRET) r = requests.get('{0}/v1/projects/name/{1}/users'.format(settings.TAS_URL, project_name), auth=auth) resp = r.json() + logger.debug(resp) if resp['status'] == 'success': return resp['result'] else: - raise Exception('Failed to get project users', resp['message']) + raise ApiException('Failed to get project users', resp['message']) def get_user_data(username): @@ -218,7 +221,18 @@ def get_user_data(username): credentials={ 'username': settings.TAS_CLIENT_KEY, 'password': settings.TAS_CLIENT_SECRET - } + } ) user_data = tas_client.get_user(username=username) return user_data + + +def get_per_user_allocation_usage(allocation_id): + auth = requests.auth.HTTPBasicAuth(settings.TAS_CLIENT_KEY, settings.TAS_CLIENT_SECRET) + r = requests.get('{0}/v1/allocations/{1}/usage'.format(settings.TAS_URL, allocation_id), auth=auth) + resp = r.json() + logger.debug(resp) + if resp['status'] == 'success': + return resp['result'] + else: + raise ApiException('Failed to get project users', resp['message']) diff --git a/server/portal/apps/users/views.py b/server/portal/apps/users/views.py index 90613b590d..18c0cb1162 100644 --- a/server/portal/apps/users/views.py +++ b/server/portal/apps/users/views.py @@ -11,7 +11,7 @@ from elasticsearch_dsl import Q from portal.libs.elasticsearch.docs.base import IndexedFile from pytas.http import TASClient -from portal.apps.users.utils import get_allocations, get_usernames, get_user_data +from portal.apps.users.utils import get_allocations, get_usernames, get_user_data, get_per_user_allocation_usage logger = logging.getLogger(__name__) @@ -129,7 +129,7 @@ def get(self, request, project_name): : rtype: dict """ usernames = get_usernames(project_name) - return JsonResponse({'usernames': usernames}, safe=False) + return JsonResponse({'response': usernames}, safe=False) @method_decorator(login_required, name='dispatch') @@ -138,3 +138,11 @@ class UserDataView(BaseApiView): def get(self, request, username): user_data = get_user_data(username) return JsonResponse({username: user_data}) + + +@method_decorator(login_required, name='dispatch') +class AllocationUsageView(BaseApiView): + + def get(self, request, allocation_id): + usage = get_per_user_allocation_usage(allocation_id) + return JsonResponse({'response': usage})