From a5f3cf8fd694ffbde736c493b76e688f03b77561 Mon Sep 17 00:00:00 2001 From: Juntao Wang Date: Thu, 12 Dec 2024 15:57:51 -0500 Subject: [PATCH] Add hardware profile table --- .../cypress/cypress/pages/hardwareProfile.ts | 123 ++++++++++ .../cypress/cypress/support/commands/k8s.ts | 4 +- .../hardwareProfiles/hardwareProfiles.cy.ts | 212 ++++++++++++++++++ frontend/src/api/k8s/hardwareProfiles.ts | 24 ++ frontend/src/components/FilterToolbar.tsx | 2 +- frontend/src/k8sTypes.ts | 1 + .../DeleteHardwareProfileModal.tsx | 50 +++++ .../DisableHardwareProfileModal.tsx | 40 ++++ .../HardwareProfileEnableToggle.tsx | 79 +++++++ .../hardwareProfiles/HardwareProfiles.tsx | 8 +- .../HardwareProfilesTable.tsx | 101 +++++++++ .../HardwareProfilesTableRow.tsx | 143 ++++++++++++ .../HardwareProfilesToolbar.tsx | 62 +++++ frontend/src/pages/hardwareProfiles/const.ts | 150 +++++++++++++ .../nodeResource/NodeResourceTable.tsx | 30 +++ .../nodeSelector/NodeSelectorsTable.tsx | 27 +++ .../toleration/TolerationsTable.tsx | 32 +++ frontend/src/pages/hardwareProfiles/utils.ts | 4 + 18 files changed, 1087 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts create mode 100644 frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/hardwareProfiles.cy.ts create mode 100644 frontend/src/pages/hardwareProfiles/DeleteHardwareProfileModal.tsx create mode 100644 frontend/src/pages/hardwareProfiles/DisableHardwareProfileModal.tsx create mode 100644 frontend/src/pages/hardwareProfiles/HardwareProfileEnableToggle.tsx create mode 100644 frontend/src/pages/hardwareProfiles/HardwareProfilesTable.tsx create mode 100644 frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx create mode 100644 frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx create mode 100644 frontend/src/pages/hardwareProfiles/const.ts create mode 100644 frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx create mode 100644 frontend/src/pages/hardwareProfiles/nodeSelector/NodeSelectorsTable.tsx create mode 100644 frontend/src/pages/hardwareProfiles/toleration/TolerationsTable.tsx create mode 100644 frontend/src/pages/hardwareProfiles/utils.ts diff --git a/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts b/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts new file mode 100644 index 0000000000..f0e9d6dfef --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts @@ -0,0 +1,123 @@ +import { Contextual } from '~/__tests__/cypress/cypress/pages/components/Contextual'; +import { Modal } from './components/Modal'; +import { TableRow } from './components/table'; + +class HardwareProfileTableToolbar extends Contextual { + findToggleButton(id: string) { + return this.find().pfSwitch(id).click(); + } + + findFilterMenuOption(id: string, name: string): Cypress.Chainable> { + return this.findToggleButton(id).parents().findByRole('menuitem', { name }); + } + + findFilterInput(name: string): Cypress.Chainable> { + return this.find().findByLabelText(`Filter by ${name}`); + } + + findSearchInput(): Cypress.Chainable> { + return this.find().findByTestId('filter-toolbar-text-field'); + } + + selectEnableFilter(name: string) { + this.find() + .findByTestId('hardware-profile-filter-enable-select') + .findSelectOption(name) + .click(); + } +} + +class HardwareProfileRow extends TableRow { + findDescription() { + return this.find().findByTestId('table-row-title-description'); + } + + findEnabled() { + return this.find().pfSwitchValue('enable-switch'); + } + + findEnableSwitch() { + return this.find().pfSwitch('enable-switch'); + } + + findExpandableSection() { + return this.find().parent().find('[data-label="Other information"]'); + } + + findNodeResourceTable() { + return this.findExpandableSection().findByTestId('hardware-profile-node-resources-table'); + } + + findNodeSelectorTable() { + return this.findExpandableSection().findByTestId('hardware-profile-node-selectors-table'); + } + + findTolerationTable() { + return this.findExpandableSection().findByTestId('hardware-profile-tolerations-table'); + } +} + +class HardwareProfile { + visit() { + cy.visitWithLogin('/hardwareProfiles'); + this.wait(); + } + + private wait() { + this.findAppPage(); + cy.testA11y(); + } + + private findAppPage() { + return cy.findByTestId('app-page-title'); + } + + findTableHeaderButton(name: string) { + return this.findTable().find('thead').findByRole('button', { name }); + } + + private findTable() { + return cy.findByTestId('hardware-profile-table'); + } + + getRow(name: string) { + return new HardwareProfileRow(() => + this.findTable().find(`[data-label=Name]`).contains(name).parents('tr'), + ); + } + + findRows() { + return this.findTable().find(`[data-label=Name]`); + } + + getTableToolbar() { + return new HardwareProfileTableToolbar(() => + cy.findByTestId('hardware-profiles-table-toolbar'), + ); + } + + findCreateButton() { + return cy.findByTestId('create-hardware-profile'); + } + + findClearFiltersButton() { + return cy.findByTestId('clear-filters-button'); + } +} + +class DisableHardwareProfileModal extends Modal { + constructor() { + super('Disable hardware profile'); + } + + findDisableButton() { + return this.findFooter().findByRole('button', { name: 'Disable' }); + } + + findCancelButton() { + return this.findFooter().findByRole('button', { name: 'Cancel' }); + } +} + +export const hardwareProfile = new HardwareProfile(); +export const disableHardwareProfileModal = new DisableHardwareProfileModal(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/k8s.ts b/frontend/src/__tests__/cypress/cypress/support/commands/k8s.ts index b1c7788ad0..ab18ab0feb 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/k8s.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/k8s.ts @@ -46,7 +46,7 @@ declare global { */ interceptK8s: (( modelOrOptions: K8sModelCommon | K8sOptions, - response?: + response: | K | K8sStatus | Patch[] @@ -67,7 +67,7 @@ declare global { (( method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT', modelOrOptions: K8sModelCommon | K8sOptions, - response?: + response: | K | K8sStatus | Patch[] diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/hardwareProfiles.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/hardwareProfiles.cy.ts new file mode 100644 index 0000000000..7b0c56a5cd --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/hardwareProfiles.cy.ts @@ -0,0 +1,212 @@ +import { + hardwareProfile, + disableHardwareProfileModal, +} from '~/__tests__/cypress/cypress/pages/hardwareProfile'; +import { mockHardwareProfile } from '~/__mocks__/mockHardwareProfile'; +import { deleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; +import { HardwareProfileModel } from '~/__tests__/cypress/cypress/utils/models'; +import { mock200Status, mockK8sResourceList } from '~/__mocks__'; +import { be } from '~/__tests__/cypress/cypress/utils/should'; +import { asProductAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { testPagination } from '~/__tests__/cypress/cypress/utils/pagination'; + +const initIntercepts = () => { + cy.interceptK8sList( + { model: HardwareProfileModel, ns: 'opendatahub' }, + mockK8sResourceList([ + mockHardwareProfile({ displayName: 'Test Hardware Profile' }), + mockHardwareProfile({ + name: 'test-hardware-profile-delete', + displayName: 'Test Hardware Profile Delete', + enabled: false, + }), + ]), + ); +}; + +describe('Hardware Profile', () => { + beforeEach(() => { + asProductAdminUser(); + }); + + describe('main table', () => { + it('table sorting and pagination', () => { + const totalItems = 50; + cy.interceptK8sList( + { model: HardwareProfileModel, ns: 'opendatahub' }, + mockK8sResourceList( + Array.from({ length: totalItems }, (_, i) => + mockHardwareProfile({ + displayName: `Test Hardware Profile - ${i}`, + description: `hardware profile ${i}`, + }), + ), + ), + ); + hardwareProfile.visit(); + const tableRow = hardwareProfile.getRow('Test Hardware Profile - 0'); + tableRow.findDescription().contains('hardware profile 0'); + + // top pagination + testPagination({ + totalItems, + firstElement: 'Test Hardware Profile - 0', + paginationVariant: 'top', + }); + + // bottom pagination + testPagination({ + totalItems, + firstElement: 'Test Hardware Profile - 0', + paginationVariant: 'bottom', + }); + + //sort by Name + hardwareProfile.findTableHeaderButton('Name').click(); + hardwareProfile.findTableHeaderButton('Name').should(be.sortDescending); + hardwareProfile.findTableHeaderButton('Name').click(); + hardwareProfile.findTableHeaderButton('Name').should(be.sortAscending); + + // sort by last modified + hardwareProfile.findTableHeaderButton('Last modified').click(); + hardwareProfile.findTableHeaderButton('Last modified').should(be.sortAscending); + hardwareProfile.findTableHeaderButton('Last modified').click(); + hardwareProfile.findTableHeaderButton('Last modified').should(be.sortDescending); + + hardwareProfile.findCreateButton().should('be.enabled'); + }); + + it('table filtering and searching ', () => { + initIntercepts(); + hardwareProfile.visit(); + + const hardwareProfileTableToolbar = hardwareProfile.getTableToolbar(); + hardwareProfile.findRows().should('have.length', 2); + + hardwareProfileTableToolbar.findSearchInput().type('Test Hardware Profile Delete'); + hardwareProfile.findRows().should('have.length', 1); + hardwareProfile.getRow('Test Hardware Profile Delete').find().should('exist'); + + hardwareProfileTableToolbar.findFilterInput('name').clear(); + hardwareProfile.findRows().should('have.length', 2); + + hardwareProfileTableToolbar.findFilterMenuOption('filter-toolbar-dropdown', 'Enable').click(); + hardwareProfileTableToolbar.selectEnableFilter('Enabled'); + hardwareProfile.findRows().should('have.length', 1); + hardwareProfile.getRow('Test Hardware Profile').find().should('exist'); + + hardwareProfileTableToolbar.selectEnableFilter('Disabled'); + hardwareProfile.findRows().should('have.length', 1); + hardwareProfile.getRow('Test Hardware Profile Delete').find().should('exist'); + hardwareProfileTableToolbar.findFilterMenuOption('filter-toolbar-dropdown', 'Name').click(); + hardwareProfileTableToolbar.findFilterInput('name').type('No match'); + hardwareProfile.findRows().should('have.length', 0); + hardwareProfile.findClearFiltersButton().click(); + hardwareProfile.findRows().should('have.length', 2); + }); + + it('delete hardware profile', () => { + initIntercepts(); + cy.interceptK8s( + 'DELETE', + { + model: HardwareProfileModel, + ns: 'test-project', + name: 'test-hardware-profile-delete', + }, + mock200Status({}), + ).as('delete'); + hardwareProfile.visit(); + hardwareProfile.getRow('Test Hardware Profile Delete').findKebabAction('Delete').click(); + deleteModal.findSubmitButton().should('be.disabled'); + deleteModal.findInput().fill('Test Hardware Profile Delete'); + deleteModal.findSubmitButton().should('be.enabled').click(); + cy.wait('@delete'); + }); + + it('toggle hardware profile enablement', () => { + initIntercepts(); + cy.interceptK8s('PATCH', HardwareProfileModel, mockHardwareProfile({})).as( + 'toggleHardwareProfile', + ); + hardwareProfile.visit(); + hardwareProfile.getRow('Test Hardware Profile Delete').findEnabled().should('not.be.checked'); + hardwareProfile.getRow('Test Hardware Profile').findEnabled().should('be.checked'); + hardwareProfile.getRow('Test Hardware Profile').findEnableSwitch().click(); + disableHardwareProfileModal.findDisableButton().click(); + + cy.wait('@toggleHardwareProfile').then((interception) => { + expect(interception.request.body).to.eql([ + { op: 'replace', path: '/spec/enabled', value: false }, + ]); + }); + hardwareProfile.getRow('Test Hardware Profile').findEnabled().should('not.be.checked'); + hardwareProfile.getRow('Test Hardware Profile').findEnableSwitch().click(); + cy.wait('@toggleHardwareProfile').then((interception) => { + expect(interception.request.body).to.eql([ + { op: 'replace', path: '/spec/enabled', value: true }, + ]); + }); + hardwareProfile.getRow('Test Hardware Profile').findEnabled().should('be.checked'); + }); + }); + + describe('expandable section', () => { + it('should hide nested tables that do not have data', () => { + cy.interceptK8sList( + { model: HardwareProfileModel, ns: 'opendatahub' }, + mockK8sResourceList([ + mockHardwareProfile({ + displayName: 'Test Hardware Profile', + }), + mockHardwareProfile({ + displayName: 'Test Hardware Profile Empty', + nodeSelectors: [], + identifiers: [], + tolerations: [], + }), + ]), + ); + hardwareProfile.visit(); + const row1 = hardwareProfile.getRow('Test Hardware Profile'); + row1.findExpandButton().click(); + row1.findNodeSelectorTable().should('exist'); + row1.findNodeResourceTable().should('exist'); + row1.findTolerationTable().should('exist'); + const row2 = hardwareProfile.getRow('Test Hardware Profile Empty'); + row2.findExpandButton().click(); + row2.findNodeSelectorTable().should('not.exist'); + row2.findNodeResourceTable().should('not.exist'); + row2.findTolerationTable().should('not.exist'); + }); + + it('should show dash when there is no value on the tolerations table', () => { + cy.interceptK8sList( + { model: HardwareProfileModel, ns: 'opendatahub' }, + mockK8sResourceList([ + mockHardwareProfile({ + displayName: 'Test Hardware Profile Empty', + nodeSelectors: [], + identifiers: [], + tolerations: [{ key: 'test-key' }], + }), + ]), + ); + hardwareProfile.visit(); + const row = hardwareProfile.getRow('Test Hardware Profile Empty'); + row.findExpandButton().click(); + row.findNodeSelectorTable().should('not.exist'); + row.findNodeResourceTable().should('not.exist'); + row.findTolerationTable().should('exist'); + + row.findTolerationTable().find(`[data-label=Operator]`).should('contain.text', '-'); + row.findTolerationTable().find(`[data-label=Key]`).should('contain.text', 'test-key'); + row.findTolerationTable().find(`[data-label=Value]`).should('contain.text', '-'); + row.findTolerationTable().find(`[data-label=Effect]`).should('contain.text', '-'); + row + .findTolerationTable() + .find(`[data-label="Toleration seconds"]`) + .should('contain.text', '-'); + }); + }); +}); diff --git a/frontend/src/api/k8s/hardwareProfiles.ts b/frontend/src/api/k8s/hardwareProfiles.ts index 4b87bace99..b091adb6b9 100644 --- a/frontend/src/api/k8s/hardwareProfiles.ts +++ b/frontend/src/api/k8s/hardwareProfiles.ts @@ -4,6 +4,7 @@ import { k8sDeleteResource, k8sGetResource, k8sListResource, + k8sPatchResource, K8sStatus, k8sUpdateResource, } from '@openshift/dynamic-plugin-sdk-utils'; @@ -72,6 +73,29 @@ export const updateHardwareProfile = ( ); }; +export const toggleHardwareProfileEnablement = ( + name: string, + namespace: string, + enabled: boolean, + opts?: K8sAPIOptions, +): Promise => + k8sPatchResource( + applyK8sAPIOptions( + { + model: HardwareProfileModel, + queryOptions: { name, ns: namespace }, + patches: [ + { + op: 'replace', + path: '/spec/enabled', + value: enabled, + }, + ], + }, + opts, + ), + ); + export const deleteHardwareProfile = ( hardwareProfileName: string, namespace: string, diff --git a/frontend/src/components/FilterToolbar.tsx b/frontend/src/components/FilterToolbar.tsx index 8fda0153d1..4685024f4c 100644 --- a/frontend/src/components/FilterToolbar.tsx +++ b/frontend/src/components/FilterToolbar.tsx @@ -54,7 +54,7 @@ function FilterToolbar({ data-testid={`${testId}-dropdown`} id={`${testId}-toggle-button`} ref={toggleRef} - aria-label="Pipeline Filter toggle" + aria-label="Filter toggle" onClick={() => setOpen(!open)} isExpanded={open} icon={} diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index e6e4cd51a8..a3cdadf676 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -1239,6 +1239,7 @@ export type AcceleratorProfileKind = K8sResourceCommon & { export type HardwareProfileKind = K8sResourceCommon & { metadata: { name: string; + namespace: string; }; spec: { displayName: string; diff --git a/frontend/src/pages/hardwareProfiles/DeleteHardwareProfileModal.tsx b/frontend/src/pages/hardwareProfiles/DeleteHardwareProfileModal.tsx new file mode 100644 index 0000000000..46f6808ffc --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/DeleteHardwareProfileModal.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { deleteHardwareProfile } from '~/api'; +import { HardwareProfileKind } from '~/k8sTypes'; +import DeleteModal from '~/pages/projects/components/DeleteModal'; + +type DeleteHardwareProfileModalProps = { + hardwareProfile: HardwareProfileKind; + onClose: (deleted: boolean) => void; +}; + +const DeleteHardwareProfileModal: React.FC = ({ + hardwareProfile, + onClose, +}) => { + const [isDeleting, setIsDeleting] = React.useState(false); + const [error, setError] = React.useState(); + + const onBeforeClose = (deleted: boolean) => { + onClose(deleted); + setIsDeleting(false); + setError(undefined); + }; + + return ( + onBeforeClose(false)} + submitButtonLabel="Delete" + onDelete={() => { + setIsDeleting(true); + deleteHardwareProfile(hardwareProfile.metadata.name, hardwareProfile.metadata.namespace) + .then(() => { + onBeforeClose(true); + }) + .catch((e) => { + setError(e); + setIsDeleting(false); + }); + }} + deleting={isDeleting} + error={error} + deleteName={hardwareProfile.spec.displayName} + > + This action cannot be undone. Workloads already deployed using this profile will not be + affected by this action. + + ); +}; + +export default DeleteHardwareProfileModal; diff --git a/frontend/src/pages/hardwareProfiles/DisableHardwareProfileModal.tsx b/frontend/src/pages/hardwareProfiles/DisableHardwareProfileModal.tsx new file mode 100644 index 0000000000..694dad4edc --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/DisableHardwareProfileModal.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; +import { Modal } from '@patternfly/react-core/deprecated'; + +type DisableHardwareProfileModalProps = { + onClose: (confirmStatus: boolean) => void; +}; + +const DisableHardwareProfileModal: React.FC = ({ onClose }) => ( + onClose(false)} + actions={[ + , + , + ]} + > + This will disable the hardware profile and it will no longer be available for use with new + workbenches and runtimes. Existing resources using this profile will retain it unless a new + profile is selected. + +); + +export default DisableHardwareProfileModal; diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfileEnableToggle.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfileEnableToggle.tsx new file mode 100644 index 0000000000..b4d0233a6b --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/HardwareProfileEnableToggle.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import { Switch } from '@patternfly/react-core'; +import useNotification from '~/utilities/useNotification'; +import { toggleHardwareProfileEnablement } from '~/api'; +import { HardwareProfileKind } from '~/k8sTypes'; + +import DisableHardwareProfileModal from '~/pages/hardwareProfiles/DisableHardwareProfileModal'; + +type HardwareProfileEnableToggleProps = { + hardwareProfile: HardwareProfileKind; + refreshHardwareProfiles: () => void; +}; + +const HardwareProfileEnableToggle: React.FC = ({ + hardwareProfile, + refreshHardwareProfiles, +}) => { + const { enabled } = hardwareProfile.spec; + const label = enabled ? 'enabled' : 'stopped'; + const [isModalOpen, setIsModalOpen] = React.useState(false); + const [isEnabled, setEnabled] = React.useState(enabled); + const [isLoading, setLoading] = React.useState(false); + const notification = useNotification(); + + const handleChange = (checked: boolean) => { + setLoading(true); + toggleHardwareProfileEnablement( + hardwareProfile.metadata.name, + hardwareProfile.metadata.namespace, + checked, + ) + .then(() => { + setEnabled(checked); + refreshHardwareProfiles(); + }) + .catch((e) => { + notification.error( + `Error ${checked ? 'enable' : 'disable'} the hardware profile`, + e.message, + ); + setEnabled(!checked); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + <> + { + if (isEnabled) { + setIsModalOpen(true); + } else { + handleChange(true); + } + }} + /> + {isModalOpen ? ( + { + if (confirmStatus) { + handleChange(false); + } + setIsModalOpen(false); + }} + /> + ) : null} + + ); +}; + +export default HardwareProfileEnableToggle; diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx index 99c1a105e1..e834bb9f05 100644 --- a/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx +++ b/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx @@ -14,13 +14,14 @@ import { PlusCircleIcon } from '@patternfly/react-icons'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { useDashboardNamespace } from '~/redux/selectors'; import { ODH_PRODUCT_NAME } from '~/utilities/const'; +import HardwareProfilesTable from '~/pages/hardwareProfiles/HardwareProfilesTable'; import useHardwareProfiles from './useHardwareProfiles'; const description = `Manage hardware profile settings for users in your organization.`; const HardwareProfiles: React.FC = () => { const { dashboardNamespace } = useDashboardNamespace(); - const [hardwareProfiles, loaded, loadError] = useHardwareProfiles(dashboardNamespace); + const [hardwareProfiles, loaded, loadError, refresh] = useHardwareProfiles(dashboardNamespace); const isEmpty = hardwareProfiles.length === 0; @@ -67,7 +68,10 @@ const HardwareProfiles: React.FC = () => { emptyStatePage={noHardwareProfilePageSection} provideChildrenPadding > - {/* Todo: Create hardware table */} + ); }; diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfilesTable.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfilesTable.tsx new file mode 100644 index 0000000000..a57e6fd70c --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/HardwareProfilesTable.tsx @@ -0,0 +1,101 @@ +import * as React from 'react'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import { Table } from '~/components/table'; +import { HardwareProfileKind } from '~/k8sTypes'; +import { + hardwareProfileColumns, + HardwareProfileEnableType, + HardwareProfileFilterDataType, + initialHardwareProfileFilterData, +} from '~/pages/hardwareProfiles/const'; +import HardwareProfilesTableRow from '~/pages/hardwareProfiles/HardwareProfilesTableRow'; +import DeleteHardwareProfileModal from '~/pages/hardwareProfiles/DeleteHardwareProfileModal'; +import HardwareProfilesToolbar from '~/pages/hardwareProfiles/HardwareProfilesToolbar'; + +type HardwareProfilesTableProps = { + hardwareProfiles: HardwareProfileKind[]; + refreshHardwareProfiles: () => void; +}; + +const HardwareProfilesTable: React.FC = ({ + hardwareProfiles, + refreshHardwareProfiles, +}) => { + const [deleteHardwareProfile, setDeleteHardwareProfile] = React.useState(); + const [filterData, setFilterData] = React.useState( + initialHardwareProfileFilterData, + ); + const onClearFilters = React.useCallback( + () => setFilterData(initialHardwareProfileFilterData), + [setFilterData], + ); + const filteredHardwareProfiles = React.useMemo( + () => + hardwareProfiles.filter((cr) => { + const nameFilter = filterData.Name?.toLowerCase(); + const enableFilter = filterData.Enable; + + if (nameFilter && !cr.spec.displayName.toLowerCase().includes(nameFilter)) { + return false; + } + + return ( + !enableFilter || + (enableFilter === HardwareProfileEnableType.enabled && cr.spec.enabled) || + (enableFilter === HardwareProfileEnableType.disabled && !cr.spec.enabled) + ); + }), + [hardwareProfiles, filterData], + ); + + const resetFilters = () => { + setFilterData(initialHardwareProfileFilterData); + }; + + const onFilterUpdate = React.useCallback( + (key: string, value: string | { label: string; value: string } | undefined) => + setFilterData((prevValues) => ({ ...prevValues, [key]: value })), + [setFilterData], + ); + + return ( + <> + } + disableRowRenderSupport + rowRenderer={(cr, index) => ( + setDeleteHardwareProfile(hardwareProfile)} + refreshHardwareProfiles={refreshHardwareProfiles} + /> + )} + toolbarContent={ + + } + /> + {deleteHardwareProfile ? ( + { + if (deleted) { + refreshHardwareProfiles(); + } + setDeleteHardwareProfile(undefined); + }} + /> + ) : null} + + ); +}; + +export default HardwareProfilesTable; diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx new file mode 100644 index 0000000000..44d379518e --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx @@ -0,0 +1,143 @@ +import * as React from 'react'; +import { + Divider, + Stack, + StackItem, + Timestamp, + TimestampTooltipVariant, + Title, +} from '@patternfly/react-core'; +import { ActionsColumn, ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; +import { relativeTime } from '~/utilities/time'; +import { TableRowTitleDescription } from '~/components/table'; +import HardwareProfileEnableToggle from '~/pages/hardwareProfiles/HardwareProfileEnableToggle'; +import { HardwareProfileKind } from '~/k8sTypes'; +import NodeResourceTable from '~/pages/hardwareProfiles/nodeResource/NodeResourceTable'; +import NodeSelectorTable from '~/pages/hardwareProfiles/nodeSelector/NodeSelectorsTable'; +import TolerationTable from '~/pages/hardwareProfiles/toleration/TolerationsTable'; +import { isHardwareProfileOOTB } from '~/pages/hardwareProfiles/utils'; + +type HardwareProfilesTableRowProps = { + rowIndex: number; + hardwareProfile: HardwareProfileKind; + handleDelete: (cr: HardwareProfileKind) => void; + refreshHardwareProfiles: () => void; +}; + +const HardwareProfilesTableRow: React.FC = ({ + hardwareProfile, + rowIndex, + handleDelete, + refreshHardwareProfiles, +}) => { + const modifiedDate = hardwareProfile.metadata.annotations?.['opendatahub.io/modified-date']; + const [isExpanded, setExpanded] = React.useState(false); + + return ( + + + + + + + + + + + + ); +}; + +export default HardwareProfilesTableRow; diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx new file mode 100644 index 0000000000..55a49fc42b --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { Button, SearchInput, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; +import FilterToolbar from '~/components/FilterToolbar'; +import { + HardwareProfileEnableType, + HardwareProfileFilterDataType, + HardwareProfileFilterOptions, + hardwareProfileFilterOptions, +} from '~/pages/hardwareProfiles/const'; +import SimpleSelect from '~/components/SimpleSelect'; + +type HardwareProfilesToolbarProps = { + filterData: HardwareProfileFilterDataType; + onFilterUpdate: (key: string, value?: string | { label: string; value: string }) => void; +}; + +const HardwareProfilesToolbar: React.FC = ({ + filterData, + onFilterUpdate, +}) => ( + + data-testid="hardware-profiles-table-toolbar" + filterOptions={hardwareProfileFilterOptions} + filterOptionRenders={{ + [HardwareProfileFilterOptions.name]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + [HardwareProfileFilterOptions.enable]: ({ value, onChange, ...props }) => ( + ({ + key: v, + label: v, + }))} + onChange={(v) => onChange(v)} + popperProps={{ maxWidth: undefined }} + /> + ), + }} + filterData={filterData} + onFilterUpdate={onFilterUpdate} + > + + + {/* TODO: navigate to creation page */} + + + + +); + +export default HardwareProfilesToolbar; diff --git a/frontend/src/pages/hardwareProfiles/const.ts b/frontend/src/pages/hardwareProfiles/const.ts new file mode 100644 index 0000000000..c3d7d9ce1c --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/const.ts @@ -0,0 +1,150 @@ +import { SortableData } from '~/components/table'; +import { HardwareProfileKind } from '~/k8sTypes'; +import { Identifier, NodeSelector, Toleration } from '~/types'; + +export const hardwareProfileColumns: SortableData[] = [ + { + field: 'expand', + label: '', + sortable: false, + }, + { + field: 'name', + label: 'Name', + sortable: (a, b) => a.spec.displayName.localeCompare(b.spec.displayName), + width: 40, + }, + { + field: 'enablement', + label: 'Enable', + sortable: false, + info: { + popover: 'Indicates whether the hardware profile is available for new resources.', + popoverProps: { + showClose: false, + }, + }, + width: 15, + }, + { + field: 'last_modified', + label: 'Last modified', + sortable: (a: HardwareProfileKind, b: HardwareProfileKind): number => { + const first = a.metadata.annotations?.['opendatahub.io/modified-date']; + const second = b.metadata.annotations?.['opendatahub.io/modified-date']; + return new Date(first ?? 0).getTime() - new Date(second ?? 0).getTime(); + }, + width: 30, + }, + { + field: 'kebab', + label: '', + sortable: false, + }, +]; + +export const nodeResourceColumns: SortableData[] = [ + { + field: 'name', + label: 'Resource label', + sortable: false, + width: 20, + }, + { + field: 'identifier', + label: 'Resource identifier', + sortable: false, + width: 20, + }, + { + field: 'default', + label: 'Default', + sortable: false, + width: 20, + }, + { + field: 'min_allowed', + label: 'Minimum allowed', + sortable: false, + width: 20, + }, + { + field: 'max_allowed', + label: 'Maximum allowed', + sortable: false, + width: 20, + }, +]; + +export const nodeSelectorColumns: SortableData[] = [ + { + field: 'key', + label: 'Key', + sortable: false, + width: 50, + }, + { + field: 'value', + label: 'Value', + sortable: false, + width: 50, + }, +]; + +export const tolerationColumns: SortableData[] = [ + { + field: 'operator', + label: 'Operator', + sortable: false, + width: 20, + }, + { + field: 'key', + label: 'Key', + sortable: false, + width: 20, + }, + { + field: 'value', + label: 'Value', + sortable: false, + width: 20, + }, + { + field: 'effect', + label: 'Effect', + sortable: false, + width: 20, + }, + { + field: 'toleration_seconds', + label: 'Toleration seconds', + sortable: false, + width: 20, + }, +]; + +export enum HardwareProfileEnableType { + enabled = 'Enabled', + disabled = 'Disabled', +} + +export enum HardwareProfileFilterOptions { + name = 'Name', + enable = 'Enable', +} + +export const hardwareProfileFilterOptions = { + [HardwareProfileFilterOptions.name]: 'Name', + [HardwareProfileFilterOptions.enable]: 'Enable', +}; + +export type HardwareProfileFilterDataType = Record< + HardwareProfileFilterOptions, + string | undefined +>; + +export const initialHardwareProfileFilterData: HardwareProfileFilterDataType = { + [HardwareProfileFilterOptions.name]: '', + [HardwareProfileFilterOptions.enable]: undefined, +}; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx new file mode 100644 index 0000000000..5d9c2aa9a8 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; +import { Table } from '~/components/table'; +import { Identifier } from '~/types'; +import { nodeResourceColumns } from '~/pages/hardwareProfiles/const'; + +type NodeResourceTableProps = { + nodeResources: Identifier[]; +}; + +const NodeResourceTable: React.FC = ({ nodeResources }) => ( +
setExpanded(!isExpanded), + }} + /> + + + + + + {modifiedDate && !Number.isNaN(new Date(modifiedDate).getTime()) ? ( + + {relativeTime(Date.now(), new Date(modifiedDate).getTime())} + + ) : ( + '--' + )} + + undefined, + }, + { + title: 'Duplicate', + // TODO: add duplicate + onClick: () => undefined, + }, + ...(isHardwareProfileOOTB(hardwareProfile) + ? [] + : [ + { isSeparator: true }, + { + title: 'Delete', + onClick: () => handleDelete(hardwareProfile), + }, + ]), + ]} + /> +
+ + + + {hardwareProfile.spec.identifiers && + hardwareProfile.spec.identifiers.length !== 0 && ( + + + Node resources + + + + + )} + {hardwareProfile.spec.nodeSelectors && + hardwareProfile.spec.nodeSelectors.length !== 0 && ( + + + Node selectors + + + + + )} + {hardwareProfile.spec.tolerations && + hardwareProfile.spec.tolerations.length !== 0 && ( + + + Tolerations + + + + + )} + + +
( + + + + + + + + )} + /> +); + +export default NodeResourceTable; diff --git a/frontend/src/pages/hardwareProfiles/nodeSelector/NodeSelectorsTable.tsx b/frontend/src/pages/hardwareProfiles/nodeSelector/NodeSelectorsTable.tsx new file mode 100644 index 0000000000..7b153c70f7 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/nodeSelector/NodeSelectorsTable.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; +import { Table } from '~/components/table'; +import { NodeSelector } from '~/types'; +import { nodeSelectorColumns } from '~/pages/hardwareProfiles/const'; + +type NodeSelectorTableProps = { + nodeSelectors: NodeSelector[]; +}; + +const NodeSelectorTable: React.FC = ({ nodeSelectors }) => ( +
{cr.displayName}{cr.identifier}{cr.defaultCount}{cr.minCount}{cr.maxCount}
( + + + + + )} + /> +); + +export default NodeSelectorTable; diff --git a/frontend/src/pages/hardwareProfiles/toleration/TolerationsTable.tsx b/frontend/src/pages/hardwareProfiles/toleration/TolerationsTable.tsx new file mode 100644 index 0000000000..f53718be3b --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/toleration/TolerationsTable.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Td, Tr } from '@patternfly/react-table'; +import { Table } from '~/components/table'; +import { Toleration } from '~/types'; +import { tolerationColumns } from '~/pages/hardwareProfiles/const'; + +type TolerationTableProps = { + tolerations: Toleration[]; +}; + +const TolerationTable: React.FC = ({ tolerations }) => ( +
{cr.key}{cr.value}
( + + + + + + + + )} + /> +); + +export default TolerationTable; diff --git a/frontend/src/pages/hardwareProfiles/utils.ts b/frontend/src/pages/hardwareProfiles/utils.ts new file mode 100644 index 0000000000..8d0b14da77 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/utils.ts @@ -0,0 +1,4 @@ +import { HardwareProfileKind } from '~/k8sTypes'; + +export const isHardwareProfileOOTB = (hardwareProfile: HardwareProfileKind): boolean => + hardwareProfile.metadata.labels?.['opendatahub.io/ootb'] === 'true';
{cr.operator ?? '-'}{cr.key}{cr.value ?? '-'}{cr.effect ?? '-'} + {cr.tolerationSeconds === undefined ? '-' : `${cr.tolerationSeconds} second(s)`} +