diff --git a/frontend/src/__mocks__/mockHardwareProfile.ts b/frontend/src/__mocks__/mockHardwareProfile.ts index 01039a1ed7..1fff33fa4b 100644 --- a/frontend/src/__mocks__/mockHardwareProfile.ts +++ b/frontend/src/__mocks__/mockHardwareProfile.ts @@ -18,6 +18,7 @@ type MockResourceConfigType = { enabled?: boolean; nodeSelectors?: NodeSelector[]; tolerations?: Toleration[]; + annotations?: Record; }; export const mockHardwareProfile = ({ @@ -29,10 +30,17 @@ export const mockHardwareProfile = ({ { displayName: 'Memory', identifier: 'memory', - minCount: '5Gi', - maxCount: '2Gi', + minCount: '2Gi', + maxCount: '5Gi', defaultCount: '2Gi', }, + { + displayName: 'CPU', + identifier: 'cpu', + minCount: '1', + maxCount: '2', + defaultCount: '1', + }, ], description = '', enabled = true, @@ -49,6 +57,7 @@ export const mockHardwareProfile = ({ value: 'va;ue', }, ], + annotations, }: MockResourceConfigType): HardwareProfileKind => ({ apiVersion: 'dashboard.opendatahub.io/v1alpha1', kind: 'HardwareProfile', @@ -59,6 +68,7 @@ export const mockHardwareProfile = ({ namespace, resourceVersion: '1309350', uid, + annotations, }, spec: { identifiers, diff --git a/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts b/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts index 03d07b8aa5..dea94bfa4b 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/hardwareProfile.ts @@ -1,4 +1,6 @@ import { Contextual } from '~/__tests__/cypress/cypress/pages/components/Contextual'; +import { K8sNameDescriptionField } from '~/__tests__/cypress/cypress/pages/components/subComponents/K8sNameDescriptionField'; +import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; import { TableRow } from './components/table'; class HardwareProfileTableToolbar extends Contextual { @@ -104,4 +106,309 @@ class HardwareProfile { } } +class NodeResourceRow extends TableRow { + shouldHaveResourceLabel(name: string) { + this.find().find(`[data-label="Resource label"]`).should('have.text', name); + return this; + } + + shouldHaveResourceIdentifier(name: string) { + this.find().find(`[data-label="Resource identifier"]`).should('have.text', name); + return this; + } + + findEditAction() { + return this.find().findByRole('button', { name: 'Edit node resource' }); + } + + findDeleteAction() { + return this.find().findByRole('button', { name: 'Remove node resource' }); + } +} + +class NodeSelectorRow extends TableRow { + shouldHaveKey(name: string) { + this.find().find(`[data-label=Key]`).should('have.text', name); + return this; + } + + shouldHaveValue(name: string) { + this.find().find(`[data-label=Value]`).should('have.text', name); + return this; + } + + findEditAction() { + return this.find().findByRole('button', { name: 'Edit node selector' }); + } + + findDeleteAction() { + return this.find().findByRole('button', { name: 'Remove node selector' }); + } +} + +class TolerationRow extends TableRow { + shouldHaveOperator(name: string) { + this.find().find(`[data-label=Operator]`).should('have.text', name); + return this; + } + + shouldHaveEffect(name: string) { + this.find().find(`[data-label=Effect]`).should('have.text', name); + return this; + } + + shouldHaveTolerationSeconds(toleration: string) { + this.find().find(`[data-label="Toleration seconds"]`).should('have.text', toleration); + return this; + } + + findEditAction() { + return this.find().findByRole('button', { name: 'Edit toleration' }); + } + + findDeleteAction() { + return this.find().findByRole('button', { name: 'Remove toleration' }); + } +} + +class ManageHardwareProfile { + k8sNameDescription = new K8sNameDescriptionField('hardware-profile-name-desc'); + + findAddTolerationButton() { + return cy.findByTestId('add-toleration-button'); + } + + findAddNodeSelectorButton() { + return cy.findByTestId('add-node-selector-button'); + } + + findAddNodeResourceButton() { + return cy.findByTestId('add-node-resource-button'); + } + + findSubmitButton() { + return cy.findByTestId('hardware-profile-create-button'); + } + + findTolerationTable() { + return cy.findByTestId('hardware-profile-tolerations-table'); + } + + findNodeSelectorTable() { + return cy.findByTestId('hardware-profile-node-selectors-table'); + } + + findNodeResourceTable() { + return cy.findByTestId('hardware-profile-node-resources-table'); + } + + findNodeResourceTableAlert() { + return cy.findByTestId('node-resource-table-alert'); + } + + getTolerationTableRow(name: string) { + return new TolerationRow(() => + this.findTolerationTable().find(`[data-label=Key]`).contains(name).parents('tr'), + ); + } + + getNodeSelectorTableRow(name: string) { + return new NodeSelectorRow(() => + this.findNodeSelectorTable().find(`[data-label=Key]`).contains(name).parents('tr'), + ); + } + + getNodeResourceTableRow(name: string) { + return new NodeResourceRow(() => + this.findNodeResourceTable() + .find(`[data-label="Resource identifier"]`) + .contains(name) + .parents('tr'), + ); + } +} + +class CreateHardwareProfile extends ManageHardwareProfile { + visit() { + cy.visitWithLogin('/hardwareProfiles/Create'); + this.wait(); + } + + private wait() { + this.findSubmitButton().contains('Create hardware profile'); + cy.testA11y(); + } +} + +class EditHardwareProfile extends ManageHardwareProfile { + visit(name: string) { + cy.visitWithLogin(`/hardwareProfiles/edit/${name}`); + cy.testA11y(); + } + + findErrorText() { + return cy.findByTestId('problem-loading-hardware-profile'); + } + + findViewAllHardwareProfilesButton() { + return cy.findByTestId('view-all-hardware-profiles'); + } +} + +class DuplicateHardwareProfile extends ManageHardwareProfile { + visit(name: string) { + cy.visitWithLogin(`/hardwareProfiles/duplicate/${name}`); + cy.testA11y(); + } + + findErrorText() { + return cy.findByTestId('problem-loading-hardware-profile'); + } + + findViewAllHardwareProfilesButton() { + return cy.findByTestId('view-all-hardware-profiles'); + } +} + +class TolerationModal extends Modal { + constructor(edit = false) { + super(edit ? 'Edit toleration' : 'Add toleration'); + } + + findTolerationKeyInput() { + return this.find().findByTestId('toleration-key-input'); + } + + findTolerationValueAlert() { + return this.find().findByTestId('toleration-value-alert'); + } + + findTolerationSecondRadioCustom() { + return this.find().findByTestId('toleration-seconds-radio-custom'); + } + + findTolerationSecondAlert() { + return this.find().findByTestId('toleration-seconds-alert'); + } + + findTolerationValueInput() { + return this.find().findByTestId('toleration-value-input'); + } + + private findTolerationOperatorSelect() { + return this.find().findByTestId('toleration-operator-select'); + } + + private findTolerationEffectSelect() { + return this.find().findByTestId('toleration-effect-select'); + } + + findOperatorOptionExist() { + return this.findTolerationOperatorSelect().findSelectOption( + 'Exists A toleration "matches" a taint if the keys are the same and the effects are the same. No value should be specified.', + ); + } + + findOperatorOptionEqual() { + return this.findTolerationOperatorSelect().findSelectOption( + 'Equal A toleration "matches" a taint if the keys are the same, the effects are the same, and the values are equal.', + ); + } + + findEffectOptionNoExecute() { + return this.findTolerationEffectSelect().findSelectOption( + 'NoExecute Pods will be evicted from the node if they do not tolerate the taint.', + ); + } + + findPlusButton() { + return this.find().findByRole('button', { name: 'Plus' }); + } + + findTolerationSubmitButton() { + return this.find().findByTestId('modal-submit-button'); + } +} + +class NodeSelectorModal extends Modal { + constructor(edit = false) { + super(edit ? 'Edit node selector' : 'Add node selector'); + } + + findNodeSelectorKeyInput() { + return this.find().findByTestId('node-selector-key-input'); + } + + findNodeSelectorValueInput() { + return this.find().findByTestId('node-selector-value-input'); + } + + findNodeSelectorSubmitButton() { + return this.find().findByTestId('modal-submit-button'); + } +} + +class NodeResourceModal extends Modal { + constructor(edit = false) { + super(edit ? 'Edit node resource' : 'Add node resource'); + } + + findNodeResourceLabelInput() { + return this.find().findByTestId('node-resource-label-input'); + } + + findNodeResourceIdentifierInput() { + return this.find().findByTestId('node-resource-identifier-input'); + } + + findNodeResourceTypeSelect() { + return this.find().findByTestId('node-resource-type-select'); + } + + findNodeResourceExistingErrorMessage() { + return this.find().findByTestId('resource-identifier-error'); + } + + findNodeResourceDefaultInput() { + return this.find().findByTestId('node-resource-size-default').findByLabelText('Input'); + } + + selectNodeResourceDefaultUnit(name: string) { + this.find() + .findByTestId('node-resource-size-default') + .findByTestId('value-unit-select') + .findDropdownItem(name) + .click(); + } + + findNodeResourceDefaultErrorMessage() { + return this.find().findByTestId('node-resource-size-default-error'); + } + + findNodeResourceMinInput() { + return this.find().findByTestId('node-resource-size-minimum-allowed').findByLabelText('Input'); + } + + findNodeResourceMinErrorMessage() { + return this.find().findByTestId('node-resource-size-minimum-allowed-error'); + } + + findNodeResourceMaxInput() { + return this.find().findByTestId('node-resource-size-maximum-allowed').findByLabelText('Input'); + } + + findNodeResourceSubmitButton() { + return this.find().findByTestId('modal-submit-button'); + } +} + export const hardwareProfile = new HardwareProfile(); +export const createHardwareProfile = new CreateHardwareProfile(); +export const createTolerationModal = new TolerationModal(false); +export const editTolerationModal = new TolerationModal(true); +export const createNodeSelectorModal = new NodeSelectorModal(false); +export const editNodeSelectorModal = new NodeSelectorModal(true); +export const createNodeResourceModal = new NodeResourceModal(false); +export const editNodeResourceModal = new NodeResourceModal(true); +export const editHardwareProfile = new EditHardwareProfile(); +export const duplicateHardwareProfile = new DuplicateHardwareProfile(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/manageHardwareProfiles.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/manageHardwareProfiles.cy.ts new file mode 100644 index 0000000000..eeee29b89e --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/hardwareProfiles/manageHardwareProfiles.cy.ts @@ -0,0 +1,573 @@ +import { TolerationEffect, TolerationOperator } from '~/types'; +import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; +import { HardwareProfileModel } from '~/__tests__/cypress/cypress/utils/models'; +import { asProductAdminUser } from '~/__tests__/cypress/cypress/utils/mockUsers'; +import { mockHardwareProfile } from '~/__mocks__/mockHardwareProfile'; +import { + createHardwareProfile, + createNodeResourceModal, + createNodeSelectorModal, + createTolerationModal, + duplicateHardwareProfile, + editHardwareProfile, + editNodeResourceModal, + editNodeSelectorModal, + editTolerationModal, +} from '~/__tests__/cypress/cypress/pages/hardwareProfile'; + +type HandlersProps = { + isPresent?: boolean; +}; + +const initIntercepts = ({ isPresent = true }: HandlersProps) => { + cy.interceptK8s( + { model: HardwareProfileModel, ns: 'opendatahub', name: 'test-hardware-profile' }, + isPresent + ? mockHardwareProfile({ + namespace: 'opendatahub', + name: 'test-hardware-profile', + displayName: 'Test Hardware Profile', + description: 'Test description', + identifiers: [ + { + displayName: 'Memory', + identifier: 'memory', + minCount: '2Gi', + maxCount: '5Gi', + defaultCount: '2Gi', + }, + { + displayName: 'CPU', + identifier: 'cpu', + minCount: '1', + maxCount: '2', + defaultCount: '1', + }, + { + identifier: 'nvidia.com/gpu', + displayName: 'GPU', + maxCount: 2, + minCount: 1, + defaultCount: 1, + }, + ], + tolerations: [ + { + key: 'nvidia.com/gpu', + operator: TolerationOperator.EXISTS, + effect: TolerationEffect.NO_SCHEDULE, + }, + ], + nodeSelectors: [{ key: 'test-key', value: 'test-value' }], + }) + : { + statusCode: 404, + }, + ); +}; + +describe('Manage Hardware Profile', () => { + beforeEach(() => { + asProductAdminUser(); + }); + + it('create hardware profile', () => { + initIntercepts({}); + createHardwareProfile.visit(); + createHardwareProfile.findSubmitButton().should('be.disabled'); + + // test required fields + createHardwareProfile.k8sNameDescription.findDisplayNameInput().fill('Test hardware profile'); + createHardwareProfile.findSubmitButton().should('be.enabled'); + + // test resource name validation + createHardwareProfile.k8sNameDescription.findResourceEditLink().click(); + createHardwareProfile.k8sNameDescription + .findResourceNameInput() + .should('have.attr', 'aria-invalid', 'false'); + createHardwareProfile.k8sNameDescription + .findResourceNameInput() + .should('have.value', 'test-hardware-profile'); + // Invalid character k8s names fail + createHardwareProfile.k8sNameDescription.findResourceNameInput().clear().type('InVaLiD vAlUe!'); + createHardwareProfile.k8sNameDescription + .findResourceNameInput() + .should('have.attr', 'aria-invalid', 'true'); + createHardwareProfile.findSubmitButton().should('be.disabled'); + createHardwareProfile.k8sNameDescription + .findResourceNameInput() + .clear() + .type('test-hardware-profile-name'); + createHardwareProfile.findSubmitButton().should('be.enabled'); + createHardwareProfile.k8sNameDescription.findDescriptionInput().fill('Test description'); + + cy.interceptK8s( + 'POST', + { + model: HardwareProfileModel, + ns: 'opendatahub', + name: 'test-hardware-profile', + }, + mockHardwareProfile({ name: 'test-hardware-profile', namespace: 'opendatahub' }), + ).as('createHardwareProfile'); + createHardwareProfile.findSubmitButton().click(); + + cy.wait('@createHardwareProfile').then((interception) => { + expect(interception.request.body.spec.displayName).to.be.eql('Test hardware profile'); + expect(interception.request.body.spec.description).to.be.eql('Test description'); + }); + }); + + it('test node resources section', () => { + initIntercepts({}); + createHardwareProfile.visit(); + createHardwareProfile.k8sNameDescription.findDisplayNameInput().fill('test-hardware-profile'); + + // test node resource table + createHardwareProfile.findNodeResourceTable().should('exist'); + // open node resource modal + createHardwareProfile.findAddNodeResourceButton().click(); + // fill in form required fields + createNodeResourceModal.findNodeResourceSubmitButton().should('be.disabled'); + createNodeResourceModal.findNodeResourceLabelInput().fill('Test GPU'); + // test duplicated identifier + createNodeResourceModal.findNodeResourceIdentifierInput().fill('cpu'); + createNodeResourceModal.findNodeResourceExistingErrorMessage().should('exist'); + createNodeResourceModal.findNodeResourceSubmitButton().should('be.disabled'); + createNodeResourceModal.findNodeResourceIdentifierInput().fill('test-gpu'); + createNodeResourceModal.findNodeResourceTypeSelect().should('contain.text', 'Other'); + createNodeResourceModal.findNodeResourceSubmitButton().should('be.enabled'); + createNodeResourceModal.findNodeResourceSubmitButton().click(); + // test that values were added correctly + createHardwareProfile.getNodeResourceTableRow('test-gpu').shouldHaveResourceLabel('Test GPU'); + + // test edit node resource + createHardwareProfile.getNodeResourceTableRow('cpu').findEditAction().click(); + editNodeResourceModal.findNodeResourceTypeSelect().should('contain.text', 'CPU'); + // test default value should be within min and max value + editNodeResourceModal.selectNodeResourceDefaultUnit('Milicores'); + editNodeResourceModal.findNodeResourceDefaultErrorMessage().should('exist'); + editNodeResourceModal.selectNodeResourceDefaultUnit('Cores'); + editNodeResourceModal.findNodeResourceDefaultErrorMessage().should('not.exist'); + // test min value should not exceed max value + editNodeResourceModal.findNodeResourceMinInput().type('3'); + editNodeResourceModal.findNodeResourceMinErrorMessage().should('exist'); + editNodeResourceModal.findNodeResourceMinInput().clear(); + editNodeResourceModal.findNodeResourceMinErrorMessage().should('not.exist'); + editNodeResourceModal.findCancelButton().click(); + + createHardwareProfile.getNodeResourceTableRow('memory').findEditAction().click(); + editNodeResourceModal.findNodeResourceTypeSelect().should('contain.text', 'Memory'); + // test default value should be within min and max value + editNodeResourceModal.selectNodeResourceDefaultUnit('MiB'); + editNodeResourceModal.findNodeResourceDefaultErrorMessage().should('exist'); + editNodeResourceModal.selectNodeResourceDefaultUnit('GiB'); + editNodeResourceModal.findNodeResourceDefaultErrorMessage().should('not.exist'); + // test min value should not exceed max value + editNodeResourceModal.findNodeResourceMinInput().type('3'); + editNodeResourceModal.findNodeResourceMinErrorMessage().should('exist'); + editNodeResourceModal.findNodeResourceMinInput().clear(); + editNodeResourceModal.findNodeResourceMinErrorMessage().should('not.exist'); + editNodeResourceModal.findCancelButton().click(); + + createHardwareProfile.getNodeResourceTableRow('test-gpu').findEditAction().click(); + editNodeResourceModal.findNodeResourceLabelInput().fill('Test GPU Edited'); + editNodeResourceModal.findNodeResourceIdentifierInput().fill('test-gpu-edited'); + // test default value should be within min and max value + editNodeResourceModal.findNodeResourceDefaultInput().type('3'); + editNodeResourceModal.findNodeResourceDefaultErrorMessage().should('exist'); + editNodeResourceModal.findNodeResourceSubmitButton().should('be.disabled'); + editNodeResourceModal.findNodeResourceDefaultInput().type('{backspace}'); + editNodeResourceModal.findNodeResourceDefaultErrorMessage().should('not.exist'); + editNodeResourceModal.findNodeResourceSubmitButton().should('be.enabled'); + // test min value should not exceed max value + editNodeResourceModal.findNodeResourceMinInput().type('3'); + editNodeResourceModal.findNodeResourceMinErrorMessage().should('exist'); + editNodeResourceModal.findNodeResourceSubmitButton().should('be.disabled'); + editNodeResourceModal.findNodeResourceMinInput().type('{backspace}'); + editNodeResourceModal.findNodeResourceMinErrorMessage().should('not.exist'); + editNodeResourceModal.findNodeResourceSubmitButton().should('be.enabled'); + editNodeResourceModal.findNodeResourceMaxInput().type('3'); + editNodeResourceModal.findNodeResourceSubmitButton().click(); + createHardwareProfile + .getNodeResourceTableRow('test-gpu-edited') + .shouldHaveResourceLabel('Test GPU Edited') + .shouldHaveResourceIdentifier('test-gpu-edited'); + + // test deleting the last CPU trigger the alert shown + createHardwareProfile.getNodeResourceTableRow('cpu').findDeleteAction().click(); + createHardwareProfile.findNodeResourceTableAlert().should('exist'); + createHardwareProfile.findAddNodeResourceButton().click(); + createNodeResourceModal.findNodeResourceLabelInput().fill('CPU'); + createNodeResourceModal.findNodeResourceIdentifierInput().fill('cpu'); + createNodeResourceModal.findNodeResourceTypeSelect().findSelectOption('CPU').click(); + createNodeResourceModal.findNodeResourceSubmitButton().should('be.enabled'); + createNodeResourceModal.findNodeResourceSubmitButton().click(); + + createHardwareProfile.findNodeResourceTableAlert().should('not.exist'); + + // test deleting the last Memory trigger the alert shown + createHardwareProfile.getNodeResourceTableRow('memory').findDeleteAction().click(); + createHardwareProfile.findNodeResourceTableAlert().should('exist'); + + cy.interceptK8s( + 'POST', + { + model: HardwareProfileModel, + ns: 'opendatahub', + name: 'test-hardware-profile', + }, + mockHardwareProfile({ name: 'test-hardware-profile', namespace: 'opendatahub' }), + ).as('createHardwareProfile'); + createHardwareProfile.findSubmitButton().click(); + + cy.wait('@createHardwareProfile').then((interception) => { + expect(interception.request.body.spec.identifiers).to.be.eql([ + { + displayName: 'Test GPU Edited', + identifier: 'test-gpu-edited', + minCount: 1, + maxCount: 13, + defaultCount: 1, + }, + { + identifier: 'cpu', + displayName: 'CPU', + defaultCount: 2, + maxCount: 4, + minCount: 1, + resourceType: 'CPU', + }, + ]); + }); + }); + + it('test node selectors section', () => { + initIntercepts({}); + createHardwareProfile.visit(); + createHardwareProfile.findSubmitButton().should('be.disabled'); + createHardwareProfile.k8sNameDescription.findDisplayNameInput().fill('test-hardware-profile'); + + // test node selectors empty state + createHardwareProfile.findNodeSelectorTable().should('not.exist'); + // open node selector modal + createHardwareProfile.findAddNodeSelectorButton().click(); + // fill in form required fields + createNodeSelectorModal.findNodeSelectorSubmitButton().should('be.disabled'); + createNodeSelectorModal.findNodeSelectorKeyInput().fill('test-key'); + createNodeSelectorModal.findNodeSelectorSubmitButton().should('be.disabled'); + createNodeSelectorModal.findNodeSelectorValueInput().fill('test-value'); + createNodeSelectorModal.findNodeSelectorSubmitButton().should('be.enabled'); + createNodeSelectorModal.findNodeSelectorSubmitButton().click(); + // test that values were added correctly + createHardwareProfile + .getNodeSelectorTableRow('test-key') + .shouldHaveKey('test-key') + .shouldHaveValue('test-value'); + // test edit node selector + let nodeSelectorTableRow = createHardwareProfile.getNodeSelectorTableRow('test-key'); + nodeSelectorTableRow.findEditAction().click(); + editNodeSelectorModal.findNodeSelectorKeyInput().fill('test-update'); + editNodeSelectorModal.findCancelButton().click(); + nodeSelectorTableRow.findEditAction().click(); + editNodeSelectorModal.findNodeSelectorKeyInput().fill('test-update'); + editNodeSelectorModal.findNodeSelectorSubmitButton().click(); + nodeSelectorTableRow = createHardwareProfile.getNodeSelectorTableRow('test-update'); + nodeSelectorTableRow.shouldHaveValue('test-value'); + // test cancel clears fields + nodeSelectorTableRow.findEditAction().click(); + editNodeSelectorModal.findCancelButton().click(); + createHardwareProfile.findAddNodeSelectorButton().click(); + createNodeSelectorModal.findNodeSelectorSubmitButton().should('be.disabled'); + createNodeSelectorModal.findCancelButton().click(); + // add another field + createHardwareProfile.findAddNodeSelectorButton().click(); + createNodeSelectorModal.findNodeSelectorKeyInput().fill('new-test-node-selector'); + createNodeSelectorModal.findNodeSelectorValueInput().fill('new-test-value'); + createNodeSelectorModal.findNodeSelectorSubmitButton().click(); + // delete the previous one + nodeSelectorTableRow.findDeleteAction().click(); + + cy.interceptK8s( + 'POST', + { + model: HardwareProfileModel, + ns: 'opendatahub', + name: 'test-hardware-profile', + }, + mockHardwareProfile({ name: 'test-hardware-profile', namespace: 'opendatahub' }), + ).as('createHardwareProfile'); + createHardwareProfile.findSubmitButton().click(); + + cy.wait('@createHardwareProfile').then((interception) => { + expect(interception.request.body.spec.nodeSelectors).to.be.eql([ + { key: 'new-test-node-selector', value: 'new-test-value' }, + ]); + }); + }); + + it('test tolerations section', () => { + initIntercepts({}); + createHardwareProfile.visit(); + createHardwareProfile.findSubmitButton().should('be.disabled'); + createHardwareProfile.k8sNameDescription.findDisplayNameInput().fill('test-hardware-profile'); + + // test tolerations empty state + createHardwareProfile.findTolerationTable().should('not.exist'); + // open toleration modal + createHardwareProfile.findAddTolerationButton().click(); + // fill in form required fields + createTolerationModal.findTolerationSubmitButton().should('be.disabled'); + createTolerationModal.findTolerationKeyInput().fill('test-key'); + createTolerationModal.findTolerationSubmitButton().should('be.enabled'); + // test value info warning when operator is Exists + createTolerationModal.findOperatorOptionExist().click(); + createTolerationModal.findTolerationValueInput().fill('test-value'); + createTolerationModal.findTolerationValueAlert().should('exist'); + createTolerationModal.findOperatorOptionEqual().click(); + createTolerationModal.findTolerationValueAlert().should('not.exist'); + // test toleration seconds warning when effect is not NoExecute + createTolerationModal.findTolerationSecondRadioCustom().click(); + createTolerationModal.findTolerationSecondAlert().should('exist'); + createTolerationModal.findEffectOptionNoExecute().click(); + createTolerationModal.findTolerationSecondAlert().should('not.exist'); + createTolerationModal.findPlusButton().click(); + createTolerationModal.findTolerationSubmitButton().click(); + // test that values were added correctly + createHardwareProfile + .getTolerationTableRow('test-key') + .shouldHaveOperator('Equal') + .shouldHaveEffect('NoExecute') + .shouldHaveTolerationSeconds('1 second(s)'); + // test bare minimum fields + createHardwareProfile.findAddTolerationButton().click(); + createTolerationModal.findTolerationKeyInput().fill('toleration-key'); + createTolerationModal.findTolerationSubmitButton().click(); + createHardwareProfile + .getTolerationTableRow('toleration-key') + .shouldHaveOperator('Equal') + .shouldHaveEffect('-') + .shouldHaveTolerationSeconds('-'); + // test edit toleration + let tolerationTableRow = createHardwareProfile.getTolerationTableRow('test-key'); + tolerationTableRow.findEditAction().click(); + editTolerationModal.findTolerationKeyInput().fill('test-update'); + editTolerationModal.findCancelButton().click(); + tolerationTableRow = createHardwareProfile.getTolerationTableRow('test-key'); + tolerationTableRow.findEditAction().click(); + editTolerationModal.findTolerationKeyInput().fill('updated-test'); + editTolerationModal.findTolerationSubmitButton().click(); + tolerationTableRow = createHardwareProfile.getTolerationTableRow('updated-test'); + tolerationTableRow.shouldHaveOperator('Equal'); + // test cancel clears fields + tolerationTableRow.findEditAction().click(); + editTolerationModal.findCancelButton().click(); + createHardwareProfile.findAddTolerationButton().click(); + createTolerationModal.findTolerationSubmitButton().should('be.disabled'); + createTolerationModal.findCancelButton().click(); + // test delete + tolerationTableRow.findDeleteAction().click(); + createHardwareProfile.getTolerationTableRow('toleration-key').findDeleteAction().click(); + createHardwareProfile.findTolerationTable().should('not.exist'); + + cy.interceptK8s( + 'POST', + { + model: HardwareProfileModel, + ns: 'opendatahub', + name: 'test-hardware-profile', + }, + mockHardwareProfile({ name: 'test-hardware-profile', namespace: 'opendatahub' }), + ).as('createHardwareProfile'); + createHardwareProfile.findSubmitButton().click(); + + cy.wait('@createHardwareProfile').then((interception) => { + expect(interception.request.body.spec.tolerations).to.be.eql([]); + }); + }); + + it('edit page has expected values', () => { + initIntercepts({}); + //update the description intercept + cy.interceptK8s( + 'PUT', + { + model: HardwareProfileModel, + ns: 'opendatahub', + name: 'test-hardware-profile', + }, + mockHardwareProfile({ + name: 'test-hardware-profile', + namespace: 'opendatahub', + description: 'Updated description', + }), + ).as('updatedHardwareProfile'); + editHardwareProfile.visit('test-hardware-profile'); + editHardwareProfile.k8sNameDescription + .findDisplayNameInput() + .should('have.value', 'Test Hardware Profile'); + editHardwareProfile.k8sNameDescription + .findDescriptionInput() + .should('have.value', 'Test description'); + + editHardwareProfile.findNodeResourceTable().should('exist'); + editHardwareProfile + .getNodeResourceTableRow('nvidia.com/gpu') + .shouldHaveResourceLabel('GPU') + .findDeleteAction() + .click(); + + editHardwareProfile.findTolerationTable().should('exist'); + editHardwareProfile + .getTolerationTableRow('nvidia.com/gpu') + .shouldHaveEffect('NoSchedule') + .shouldHaveOperator('Exists') + .shouldHaveTolerationSeconds('-'); + + editHardwareProfile.findNodeSelectorTable().should('exist'); + editHardwareProfile + .getNodeSelectorTableRow('test-key') + .shouldHaveValue('test-value') + .findDeleteAction() + .click(); + + editHardwareProfile.k8sNameDescription.findDescriptionInput().fill('Updated description'); + editHardwareProfile.findSubmitButton().click(); + cy.wait('@updatedHardwareProfile').then((interception) => { + expect(interception.request.body.spec).to.eql({ + identifiers: [ + { + displayName: 'Memory', + identifier: 'memory', + minCount: '2Gi', + maxCount: '5Gi', + defaultCount: '2Gi', + }, + { + displayName: 'CPU', + identifier: 'cpu', + minCount: '1', + maxCount: '2', + defaultCount: '1', + }, + ], + displayName: 'Test Hardware Profile', + enabled: true, + tolerations: [ + { + key: 'nvidia.com/gpu', + operator: 'Exists', + effect: 'NoSchedule', + }, + ], + nodeSelectors: [], + description: 'Updated description', + }); + }); + }); + + it('duplicate page has expected values', () => { + initIntercepts({}); + cy.interceptK8s( + 'POST', + { + model: HardwareProfileModel, + ns: 'opendatahub', + name: 'duplicate-hardware-profile', + }, + mockHardwareProfile({ + name: 'duplicate-hardware-profile', + namespace: 'opendatahub', + description: 'Updated description', + }), + ).as('createHardwareProfile'); + duplicateHardwareProfile.visit('test-hardware-profile'); + duplicateHardwareProfile.findSubmitButton().should('be.disabled'); + duplicateHardwareProfile.k8sNameDescription.findDisplayNameInput().should('have.value', ''); + duplicateHardwareProfile.k8sNameDescription.findDescriptionInput().should('have.value', ''); + + editHardwareProfile.findNodeResourceTable().should('exist'); + editHardwareProfile.getNodeResourceTableRow('nvidia.com/gpu').shouldHaveResourceLabel('GPU'); + + duplicateHardwareProfile.findTolerationTable().should('exist'); + duplicateHardwareProfile + .getTolerationTableRow('nvidia.com/gpu') + .shouldHaveEffect('NoSchedule') + .shouldHaveOperator('Exists') + .shouldHaveTolerationSeconds('-'); + + duplicateHardwareProfile.findNodeSelectorTable().should('exist'); + duplicateHardwareProfile + .getNodeSelectorTableRow('test-key') + .shouldHaveValue('test-value') + .findDeleteAction() + .click(); + + duplicateHardwareProfile.k8sNameDescription + .findDisplayNameInput() + .fill('duplicate hardware profile'); + duplicateHardwareProfile.findSubmitButton().should('be.enabled').click(); + cy.wait('@createHardwareProfile').then((interception) => { + expect(interception.request.body.spec).to.eql({ + identifiers: [ + { + displayName: 'Memory', + identifier: 'memory', + minCount: '2Gi', + maxCount: '5Gi', + defaultCount: '2Gi', + }, + { + displayName: 'CPU', + identifier: 'cpu', + minCount: '1', + maxCount: '2', + defaultCount: '1', + }, + { + identifier: 'nvidia.com/gpu', + displayName: 'GPU', + maxCount: 2, + minCount: 1, + defaultCount: 1, + }, + ], + displayName: 'duplicate hardware profile', + enabled: true, + tolerations: [ + { + key: 'nvidia.com/gpu', + operator: 'Exists', + effect: 'NoSchedule', + }, + ], + nodeSelectors: [], + description: '', + }); + }); + }); + + it('invalid id in edit page', () => { + initIntercepts({ isPresent: false }); + editHardwareProfile.visit('test-hardware-profile'); + editHardwareProfile.findErrorText().should('exist'); + cy.interceptK8sList( + HardwareProfileModel, + mockK8sResourceList([mockHardwareProfile({ namespace: 'opendatahub', uid: 'test-12' })]), + ).as('listHardwareProfiles'); + editHardwareProfile.findViewAllHardwareProfilesButton().click(); + cy.wait('@listHardwareProfiles'); + }); + + it('invalid id in duplicate page', () => { + initIntercepts({ isPresent: false }); + duplicateHardwareProfile.visit('test-hardware-profile'); + duplicateHardwareProfile.findErrorText().should('exist'); + cy.interceptK8sList( + HardwareProfileModel, + mockK8sResourceList([mockHardwareProfile({ namespace: 'opendatahub', uid: 'test-12' })]), + ).as('listHardwareProfiles'); + duplicateHardwareProfile.findViewAllHardwareProfilesButton().click(); + cy.wait('@listHardwareProfiles'); + }); +}); diff --git a/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts b/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts index 39b0fa22d7..d93eb9f6da 100644 --- a/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts +++ b/frontend/src/api/k8s/__tests__/hardwareProfiles.spec.ts @@ -34,16 +34,25 @@ const k8sCreateResourceMock = jest.mocked(k8sCreateResource const k8sUpdateResourceMock = jest.mocked(k8sUpdateResource); const k8sDeleteResourceMock = jest.mocked(k8sDeleteResource); +global.structuredClone = (val: unknown) => JSON.parse(JSON.stringify(val)); + const data: HardwareProfileKind['spec'] = { displayName: 'test', identifiers: [ { displayName: 'Memory', identifier: 'memory', - minCount: '5Gi', - maxCount: '2Gi', + minCount: '2Gi', + maxCount: '5Gi', defaultCount: '2Gi', }, + { + displayName: 'CPU', + identifier: 'cpu', + minCount: '1', + maxCount: '2', + defaultCount: '1', + }, ], description: 'test description', enabled: true, @@ -62,6 +71,7 @@ const assembleHardwareProfileResult: HardwareProfileKind = { metadata: { name: 'test-1', namespace: 'namespace', + annotations: expect.anything(), }, spec: data, }; @@ -135,7 +145,7 @@ describe('getHardwareProfile', () => { }); describe('createHardwareProfiles', () => { - it('should create hadware profile', async () => { + it('should create hardware profile', async () => { k8sCreateResourceMock.mockResolvedValue(mockHardwareProfile({ uid: 'test' })); const result = await createHardwareProfile('test-1', data, 'namespace'); expect(k8sCreateResourceMock).toHaveBeenCalledWith({ @@ -184,6 +194,8 @@ describe('updateHardwareProfile', () => { namespace: 'namespace', description: 'test description', displayName: 'test', + nodeSelectors: [], + annotations: expect.anything(), }), }); expect(k8sUpdateResourceMock).toHaveBeenCalledTimes(1); @@ -211,6 +223,8 @@ describe('updateHardwareProfile', () => { namespace: 'namespace', description: 'test description', displayName: 'test', + nodeSelectors: [], + annotations: expect.anything(), }), }); }); diff --git a/frontend/src/api/k8s/hardwareProfiles.ts b/frontend/src/api/k8s/hardwareProfiles.ts index b091adb6b9..11f2ac6524 100644 --- a/frontend/src/api/k8s/hardwareProfiles.ts +++ b/frontend/src/api/k8s/hardwareProfiles.ts @@ -37,6 +37,9 @@ export const assembleHardwareProfile = ( metadata: { name: hardwareProfileName || translateDisplayNameForK8s(data.displayName), namespace, + annotations: { + 'opendatahub.io/modified-date': new Date().toISOString(), + }, }, spec: data, }); @@ -66,7 +69,14 @@ export const updateHardwareProfile = ( opts?: K8sAPIOptions, ): Promise => { const resource = assembleHardwareProfile(existingHardwareProfile.metadata.name, data, namespace); - const hardwareProfileResource = _.merge({}, existingHardwareProfile, resource); + + const oldHardwareProfile = structuredClone(existingHardwareProfile); + // clean up the resources from the old hardware profile + oldHardwareProfile.spec.identifiers = []; + oldHardwareProfile.spec.nodeSelectors = []; + oldHardwareProfile.spec.tolerations = []; + + const hardwareProfileResource = _.merge({}, oldHardwareProfile, resource); return k8sUpdateResource( applyK8sAPIOptions({ model: HardwareProfileModel, resource: hardwareProfileResource }, opts), diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx index e834bb9f05..237ab22616 100644 --- a/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx +++ b/frontend/src/pages/hardwareProfiles/HardwareProfiles.tsx @@ -11,6 +11,7 @@ import { EmptyStateFooter, } from '@patternfly/react-core'; import { PlusCircleIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router-dom'; import ApplicationsPage from '~/pages/ApplicationsPage'; import { useDashboardNamespace } from '~/redux/selectors'; import { ODH_PRODUCT_NAME } from '~/utilities/const'; @@ -22,6 +23,7 @@ const description = `Manage hardware profile settings for users in your organiza const HardwareProfiles: React.FC = () => { const { dashboardNamespace } = useDashboardNamespace(); const [hardwareProfiles, loaded, loadError, refresh] = useHardwareProfiles(dashboardNamespace); + const navigate = useNavigate(); const isEmpty = hardwareProfiles.length === 0; @@ -45,9 +47,7 @@ const HardwareProfiles: React.FC = () => { diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfilesRoutes.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfilesRoutes.tsx index 8e4ea99a3d..c36c2471dd 100644 --- a/frontend/src/pages/hardwareProfiles/HardwareProfilesRoutes.tsx +++ b/frontend/src/pages/hardwareProfiles/HardwareProfilesRoutes.tsx @@ -1,10 +1,18 @@ import * as React from 'react'; import { Navigate, Route, Routes } from 'react-router-dom'; +import ManageHardwareProfile from '~/pages/hardwareProfiles/manage/ManageHardwareProfile'; +import { + DuplicateHardwareProfile, + EditHardwareProfile, +} from '~/pages/hardwareProfiles/manage/ManageHardwareProfileWrapper'; import HardwareProfiles from './HardwareProfiles'; const HardwareProfilesRoutes: React.FC = () => ( } /> + } /> + } /> + } /> } /> ); diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx index cdd0a2c55a..9db3aecd0f 100644 --- a/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx +++ b/frontend/src/pages/hardwareProfiles/HardwareProfilesTableRow.tsx @@ -7,13 +7,14 @@ import { TimestampTooltipVariant, } from '@patternfly/react-core'; import { ActionsColumn, ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; +import { useNavigate } from 'react-router-dom'; 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 NodeSelectorTable from '~/pages/hardwareProfiles/nodeSelector/NodeSelectorTable'; +import TolerationTable from '~/pages/hardwareProfiles/toleration/TolerationTable'; import { isHardwareProfileOOTB } from '~/pages/hardwareProfiles/utils'; type HardwareProfilesTableRowProps = { @@ -31,6 +32,7 @@ const HardwareProfilesTableRow: React.FC = ({ }) => { const modifiedDate = hardwareProfile.metadata.annotations?.['opendatahub.io/modified-date']; const [isExpanded, setExpanded] = React.useState(false); + const navigate = useNavigate(); return ( @@ -73,15 +75,19 @@ const HardwareProfilesTableRow: React.FC = ({ undefined, - }, + ...(isHardwareProfileOOTB(hardwareProfile) + ? [] + : [ + { + title: 'Edit', + onClick: () => + navigate(`/hardwareProfiles/edit/${hardwareProfile.metadata.name}`), + }, + ]), { title: 'Duplicate', - // TODO: add duplicate - onClick: () => undefined, + onClick: () => + navigate(`/hardwareProfiles/duplicate/${hardwareProfile.metadata.name}`), }, ...(isHardwareProfileOOTB(hardwareProfile) ? [] diff --git a/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx b/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx index ffb1c17323..231932920c 100644 --- a/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx +++ b/frontend/src/pages/hardwareProfiles/HardwareProfilesToolbar.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Button, SearchInput, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; +import { useNavigate } from 'react-router-dom'; import FilterToolbar from '~/components/FilterToolbar'; import { HardwareProfileEnableType, @@ -17,46 +18,52 @@ type HardwareProfilesToolbarProps = { const HardwareProfilesToolbar: React.FC = ({ filterData, onFilterUpdate, -}) => ( - - data-testid="hardware-profiles-table-toolbar" - filterOptions={hardwareProfileFilterOptions} - filterOptionRenders={{ - [HardwareProfileFilterOptions.name]: ({ onChange, ...props }) => ( - onChange(value)} - /> - ), - [HardwareProfileFilterOptions.enabled]: ({ value, onChange, ...props }) => ( - ({ - key: v, - label: v, - }))} - onChange={(v) => onChange(v)} - popperProps={{ maxWidth: undefined }} - /> - ), - }} - filterData={filterData} - onFilterUpdate={onFilterUpdate} - > - - - {/* TODO: navigate to creation page */} - - - - -); +}) => { + const navigate = useNavigate(); + + return ( + + data-testid="hardware-profiles-table-toolbar" + filterOptions={hardwareProfileFilterOptions} + filterOptionRenders={{ + [HardwareProfileFilterOptions.name]: ({ onChange, ...props }) => ( + onChange(value)} + /> + ), + [HardwareProfileFilterOptions.enabled]: ({ value, onChange, ...props }) => ( + ({ + key: v, + label: v, + }))} + onChange={(v) => onChange(v)} + popperProps={{ maxWidth: undefined }} + /> + ), + }} + filterData={filterData} + onFilterUpdate={onFilterUpdate} + > + + + + + + + ); +}; export default HardwareProfilesToolbar; diff --git a/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx b/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx deleted file mode 100644 index 0d0933fd0e..0000000000 --- a/frontend/src/pages/hardwareProfiles/ManageNodeResourceSection.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; -import { - FormSection, - Flex, - FlexItem, - Button, - Content, - Stack, - StackItem, -} from '@patternfly/react-core'; -import { Identifier } from '~/types'; -import NodeResourceTable from './nodeResource/NodeResourceTable'; -import ManageNodeResourceModal from './nodeResource/ManageNodeResourceModal'; - -type ManageNodeResourceSectionProps = { - nodeResources: Identifier[]; - setNodeResources: (identifiers: Identifier[]) => void; -}; - -const ManageNodeResourceSection: React.FC = ({ - nodeResources, - setNodeResources, -}) => { - const [isNodeResourceModalOpen, setIsNodeResourceModalOpen] = React.useState(false); - return ( - <> - - Node resources - - - - - - Every hardware profile must include CPU and memory resources. Additional resources, - such as GPUs, can be added here. - - - - } - > - - - setNodeResources(newIdentifiers)} - /> - - - - {isNodeResourceModalOpen ? ( - setIsNodeResourceModalOpen(false)} - onSave={(identifier) => setNodeResources([...nodeResources, identifier])} - nodeResources={nodeResources} - /> - ) : null} - - ); -}; - -export default ManageNodeResourceSection; diff --git a/frontend/src/pages/hardwareProfiles/const.ts b/frontend/src/pages/hardwareProfiles/const.ts index 8aef7ea046..ed003dfb92 100644 --- a/frontend/src/pages/hardwareProfiles/const.ts +++ b/frontend/src/pages/hardwareProfiles/const.ts @@ -1,6 +1,10 @@ import { SortableData } from '~/components/table'; import { HardwareProfileKind } from '~/k8sTypes'; -import { Identifier, NodeSelector, Toleration } from '~/types'; +import { + ManageHardwareProfileSectionID, + ManageHardwareProfileSectionTitlesType, +} from '~/pages/hardwareProfiles/manage/types'; +import { IdentifierResourceType } from '~/types'; export const hardwareProfileColumns: SortableData[] = [ { @@ -43,87 +47,6 @@ export const hardwareProfileColumns: SortableData[] = [ }, ]; -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', @@ -148,3 +71,33 @@ export const initialHardwareProfileFilterData: HardwareProfileFilterDataType = { [HardwareProfileFilterOptions.name]: '', [HardwareProfileFilterOptions.enabled]: undefined, }; + +export const ManageHardwareProfileSectionTitles: ManageHardwareProfileSectionTitlesType = { + [ManageHardwareProfileSectionID.DETAILS]: 'Details', + [ManageHardwareProfileSectionID.IDENTIFIERS]: 'Node resources', + [ManageHardwareProfileSectionID.NODE_SELECTORS]: 'Node selectors', + [ManageHardwareProfileSectionID.TOLERATIONS]: 'Tolerations', +}; + +export const DEFAULT_HARDWARE_PROFILE_SPEC: HardwareProfileKind['spec'] = { + displayName: '', + enabled: true, + identifiers: [ + { + identifier: 'cpu', + displayName: 'CPU', + defaultCount: 2, + maxCount: 4, + minCount: 1, + resourceType: IdentifierResourceType.CPU, + }, + { + identifier: 'memory', + displayName: 'Memory', + defaultCount: '4Gi', + minCount: '2Gi', + maxCount: '8Gi', + resourceType: IdentifierResourceType.MEMORY, + }, + ], +}; diff --git a/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfile.tsx b/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfile.tsx new file mode 100644 index 0000000000..70f3153d88 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfile.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { Breadcrumb, BreadcrumbItem, Form, FormSection, PageSection } from '@patternfly/react-core'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import useGenericObjectState from '~/utilities/useGenericObjectState'; +import { HardwareProfileKind } from '~/k8sTypes'; +import K8sNameDescriptionField, { + useK8sNameDescriptionFieldData, +} from '~/concepts/k8s/K8sNameDescriptionField/K8sNameDescriptionField'; +import { isK8sNameDescriptionDataValid } from '~/concepts/k8s/K8sNameDescriptionField/utils'; +import { + DEFAULT_HARDWARE_PROFILE_SPEC, + ManageHardwareProfileSectionTitles, +} from '~/pages/hardwareProfiles/const'; +import ManageNodeSelectorSection from '~/pages/hardwareProfiles/manage/ManageNodeSelectorSection'; +import ManageTolerationSection from '~/pages/hardwareProfiles/manage/ManageTolerationSection'; +import ManageHardwareProfileFooter from '~/pages/hardwareProfiles/manage/ManageHardwareProfileFooter'; +import ManageNodeResourceSection from '~/pages/hardwareProfiles/manage/ManageNodeResourceSection'; +import { HardwareProfileFormData, ManageHardwareProfileSectionID } from './types'; + +type ManageHardwareProfileProps = { + existingHardwareProfile?: HardwareProfileKind; + duplicatedHardwareProfile?: HardwareProfileKind; +}; + +const ManageHardwareProfile: React.FC = ({ + existingHardwareProfile, + duplicatedHardwareProfile, +}) => { + const [state, setState] = useGenericObjectState( + DEFAULT_HARDWARE_PROFILE_SPEC, + ); + const { data: profileNameDesc, onDataChange: setProfileNameDesc } = + useK8sNameDescriptionFieldData({ + initialData: existingHardwareProfile + ? { + name: existingHardwareProfile.spec.displayName, + k8sName: existingHardwareProfile.metadata.name, + description: existingHardwareProfile.spec.description, + } + : undefined, + }); + + React.useEffect(() => { + if (existingHardwareProfile) { + setState('identifiers', existingHardwareProfile.spec.identifiers); + setState('enabled', existingHardwareProfile.spec.enabled); + setState('nodeSelectors', existingHardwareProfile.spec.nodeSelectors); + setState('tolerations', existingHardwareProfile.spec.tolerations); + } + }, [existingHardwareProfile, setState]); + + React.useEffect(() => { + if (duplicatedHardwareProfile) { + setState('identifiers', duplicatedHardwareProfile.spec.identifiers); + setState('enabled', duplicatedHardwareProfile.spec.enabled); + setState('nodeSelectors', duplicatedHardwareProfile.spec.nodeSelectors); + setState('tolerations', duplicatedHardwareProfile.spec.tolerations); + } + }, [duplicatedHardwareProfile, setState]); + + const formState: HardwareProfileFormData = React.useMemo( + () => ({ + ...state, + name: profileNameDesc.k8sName.value, + displayName: profileNameDesc.name, + description: profileNameDesc.description, + }), + [state, profileNameDesc], + ); + + const validFormData = isK8sNameDescriptionDataValid(profileNameDesc); + + return ( + + Hardware profiles} /> + + {existingHardwareProfile ? 'Edit' : duplicatedHardwareProfile ? 'Duplicate' : 'Create'}{' '} + hardware profile + + + } + loaded + empty={false} + > + +
+ + + + setState('identifiers', identifiers)} + /> + setState('nodeSelectors', nodeSelectors)} + /> + setState('tolerations', tolerations)} + /> + +
+ + + +
+ ); +}; + +export default ManageHardwareProfile; diff --git a/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfileFooter.tsx b/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfileFooter.tsx new file mode 100644 index 0000000000..871f625201 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfileFooter.tsx @@ -0,0 +1,103 @@ +import { + Stack, + StackItem, + Alert, + ActionList, + ActionListItem, + Button, +} from '@patternfly/react-core'; +import React from 'react'; +import { useNavigate } from 'react-router'; +import { HardwareProfileKind } from '~/k8sTypes'; +import { HardwareProfileFormData } from '~/pages/hardwareProfiles/manage/types'; +import { createHardwareProfile, updateHardwareProfile } from '~/api'; +import { useDashboardNamespace } from '~/redux/selectors'; + +type ManageHardwareProfileFooterProps = { + state: HardwareProfileFormData; + existingHardwareProfile?: HardwareProfileKind; + validFormData: boolean; +}; + +const ManageHardwareProfileFooter: React.FC = ({ + state, + existingHardwareProfile, + validFormData, +}) => { + const [errorMessage, setErrorMessage] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(false); + const { dashboardNamespace } = useDashboardNamespace(); + const navigate = useNavigate(); + + const { name, ...spec } = state; + + const onCreateHardwareProfile = async () => { + setIsLoading(true); + createHardwareProfile(name, spec, dashboardNamespace) + .then(() => navigate('/hardwareProfiles')) + .catch((err) => { + setErrorMessage(err.message); + }) + .finally(() => { + setIsLoading(false); + }); + }; + + const onUpdateHardwareProfile = async () => { + if (existingHardwareProfile) { + setIsLoading(true); + updateHardwareProfile(spec, existingHardwareProfile, dashboardNamespace) + .then(() => navigate(`/hardwareProfiles`)) + .catch((err) => { + setErrorMessage(err.message); + }) + .finally(() => { + setIsLoading(false); + }); + } + }; + + return ( + + {errorMessage && ( + + + {errorMessage} + + + )} + + + + + + + + + + + + ); +}; + +export default ManageHardwareProfileFooter; diff --git a/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfileWrapper.tsx b/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfileWrapper.tsx new file mode 100644 index 0000000000..2976ee3c5f --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/ManageHardwareProfileWrapper.tsx @@ -0,0 +1,75 @@ +import * as React from 'react'; +import { useParams } from 'react-router'; +import { + Bullseye, + Button, + EmptyState, + EmptyStateBody, + Spinner, + Title, +} from '@patternfly/react-core'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router-dom'; +import { useDashboardNamespace } from '~/redux/selectors'; +import ManageHardwareProfile from '~/pages/hardwareProfiles/manage/ManageHardwareProfile'; +import useHardwareProfile from '~/pages/hardwareProfiles/useHardwareProfile'; +import { HardwareProfileKind } from '~/k8sTypes'; + +type ManageHardwareProfileWrapperProps = { + children: (data: HardwareProfileKind) => React.ReactNode; +}; + +const ManageHardwareProfileWrapper: React.FC = ({ + children, +}) => { + const navigate = useNavigate(); + const { hardwareProfileName } = useParams(); + const { dashboardNamespace } = useDashboardNamespace(); + const [data, , error] = useHardwareProfile(dashboardNamespace, hardwareProfileName); + + if (error) { + return ( + + + Problem loading hardware profile + + } + icon={ExclamationCircleIcon} + > + {error.message} + + + + ); + } + + if (!data) { + return ( + + + + ); + } + + return children(data); +}; + +export const EditHardwareProfile: React.FC = () => ( + + {(data) => } + +); + +export const DuplicateHardwareProfile: React.FC = () => ( + + {(data) => } + +); diff --git a/frontend/src/pages/hardwareProfiles/manage/ManageNodeResourceSection.tsx b/frontend/src/pages/hardwareProfiles/manage/ManageNodeResourceSection.tsx new file mode 100644 index 0000000000..1b6cec6068 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/ManageNodeResourceSection.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { FormSection, Flex, FlexItem, Button, Alert, AlertVariant } from '@patternfly/react-core'; +import { AddCircleOIcon } from '@patternfly/react-icons'; +import { Identifier, IdentifierResourceType } from '~/types'; +import NodeResourceTable from '~/pages/hardwareProfiles/nodeResource/NodeResourceTable'; +import ManageNodeResourceModal from '~/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal'; +import { ManageHardwareProfileSectionTitles } from '~/pages/hardwareProfiles/const'; +import { ManageHardwareProfileSectionID } from '~/pages/hardwareProfiles/manage/types'; + +type ManageNodeResourceSectionProps = { + nodeResources: Identifier[]; + setNodeResources: (identifiers: Identifier[]) => void; +}; + +const ManageNodeResourceSection: React.FC = ({ + nodeResources, + setNodeResources, +}) => { + const [isNodeResourceModalOpen, setIsNodeResourceModalOpen] = React.useState(false); + const isEmpty = nodeResources.length === 0; + return ( + <> + + + {ManageHardwareProfileSectionTitles[ManageHardwareProfileSectionID.IDENTIFIERS]} + + {!isEmpty && ( + + + + )} + + } + > + Every hardware profile is highly recommended to include CPU and memory resources. Additional + resources, such as GPUs, can be added here, too. + {!( + nodeResources.some( + (identifier) => identifier.resourceType === IdentifierResourceType.CPU, + ) && + nodeResources.some( + (identifier) => identifier.resourceType === IdentifierResourceType.MEMORY, + ) + ) && ( + + It is not recommended to remove the CPU or Memory. The resources that use this hardware + profile will schedule, but will be very unstable due to not having any lower or upper + resource bounds. + + )} + {!isEmpty && ( + setNodeResources(newResources)} + /> + )} + + {isNodeResourceModalOpen && ( + setIsNodeResourceModalOpen(false)} + onSave={(identifier) => setNodeResources([...nodeResources, identifier])} + nodeResources={nodeResources} + /> + )} + {isEmpty && ( + + )} + + ); +}; + +export default ManageNodeResourceSection; diff --git a/frontend/src/pages/hardwareProfiles/manage/ManageNodeSelectorSection.tsx b/frontend/src/pages/hardwareProfiles/manage/ManageNodeSelectorSection.tsx new file mode 100644 index 0000000000..284f0fbaf3 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/ManageNodeSelectorSection.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FormSection, Button, Flex, FlexItem } from '@patternfly/react-core'; +import { AddCircleOIcon } from '@patternfly/react-icons'; +import { NodeSelector } from '~/types'; +import ManageNodeSelectorModal from '~/pages/hardwareProfiles/nodeSelector/ManageNodeSelectorModal'; +import NodeSelectorTable from '~/pages/hardwareProfiles/nodeSelector/NodeSelectorTable'; +import { ManageHardwareProfileSectionTitles } from '~/pages/hardwareProfiles/const'; +import { ManageHardwareProfileSectionID } from '~/pages/hardwareProfiles/manage/types'; + +type ManageNodeSelectorSectionProps = { + nodeSelectors: NodeSelector[]; + setNodeSelectors: (nodeSelectors: NodeSelector[]) => void; +}; + +const ManageNodeSelectorSection: React.FC = ({ + nodeSelectors, + setNodeSelectors, +}) => { + const [isNodeSelectorModalOpen, setIsNodeSelectorModalOpen] = React.useState(false); + const isEmpty = nodeSelectors.length === 0; + return ( + <> + + + {ManageHardwareProfileSectionTitles[ManageHardwareProfileSectionID.NODE_SELECTORS]} + + {!isEmpty && ( + + + + )} + + } + > + Node selectors are added to a pod spec to allow the pod to be scheduled on nodes with + matching labels. + {!isEmpty && ( + setNodeSelectors(newNodeSelectors)} + /> + )} + + {isNodeSelectorModalOpen && ( + setIsNodeSelectorModalOpen(false)} + onSave={(nodeSelector) => setNodeSelectors([...nodeSelectors, nodeSelector])} + /> + )} + {isEmpty && ( + + )} + + ); +}; + +export default ManageNodeSelectorSection; diff --git a/frontend/src/pages/hardwareProfiles/manage/ManageTolerationSection.tsx b/frontend/src/pages/hardwareProfiles/manage/ManageTolerationSection.tsx new file mode 100644 index 0000000000..d574c51396 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/ManageTolerationSection.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import { FormSection, Button, Flex, FlexItem } from '@patternfly/react-core'; +import { AddCircleOIcon } from '@patternfly/react-icons'; +import { Toleration } from '~/types'; +import ManageTolerationModal from '~/pages/hardwareProfiles/toleration/ManageTolerationModal'; +import TolerationTable from '~/pages/hardwareProfiles/toleration/TolerationTable'; +import { ManageHardwareProfileSectionTitles } from '~/pages/hardwareProfiles/const'; +import { ManageHardwareProfileSectionID } from '~/pages/hardwareProfiles/manage/types'; + +type ManageTolerationSectionProps = { + tolerations: Toleration[]; + setTolerations: (tolerations: Toleration[]) => void; +}; + +const ManageTolerationSection: React.FC = ({ + tolerations, + setTolerations, +}) => { + const [isTolerationModalOpen, setIsTolerationModalOpen] = React.useState(false); + const isEmpty = tolerations.length === 0; + return ( + <> + + + {ManageHardwareProfileSectionTitles[ManageHardwareProfileSectionID.TOLERATIONS]} + + {!isEmpty && ( + + + + )} + + } + > + Tolerations are applied to pods and allow the scheduler to schedule pods on nodes with + matching taints. + {tolerations.length !== 0 && ( + setTolerations(newTolerations)} + /> + )} + + {isTolerationModalOpen && ( + setIsTolerationModalOpen(false)} + onSave={(toleration) => setTolerations([...tolerations, toleration])} + /> + )} + {isEmpty && ( + + )} + + ); +}; + +export default ManageTolerationSection; diff --git a/frontend/src/pages/hardwareProfiles/manage/types.ts b/frontend/src/pages/hardwareProfiles/manage/types.ts new file mode 100644 index 0000000000..478da51707 --- /dev/null +++ b/frontend/src/pages/hardwareProfiles/manage/types.ts @@ -0,0 +1,14 @@ +import { HardwareProfileKind } from '~/k8sTypes'; + +export enum ManageHardwareProfileSectionID { + DETAILS = 'details', + IDENTIFIERS = 'identifiers', + NODE_SELECTORS = 'node-selectors', + TOLERATIONS = 'tolerations', +} + +export type ManageHardwareProfileSectionTitlesType = { + [key in ManageHardwareProfileSectionID]: string; +}; + +export type HardwareProfileFormData = { name: string } & HardwareProfileKind['spec']; diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx index 5c03c07c27..3fdca5f935 100644 --- a/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx +++ b/frontend/src/pages/hardwareProfiles/nodeResource/CountFormField.tsx @@ -3,13 +3,14 @@ import { FormGroup, FormHelperText, HelperText, HelperTextItem } from '@patternf import MemoryField from '~/components/MemoryField'; import CPUField from '~/components/CPUField'; import NumberInputWrapper from '~/components/NumberInputWrapper'; +import { IdentifierResourceType } from '~/types'; type CountFormFieldProps = { label: string; fieldId: string; size: number | string; setSize: (value: number | string) => void; - identifier: string; + type?: IdentifierResourceType; errorMessage?: string; isValid?: boolean; }; @@ -19,15 +20,15 @@ const CountFormField: React.FC = ({ fieldId, size, setSize, - identifier, + type, errorMessage, isValid = true, }) => { const renderInputField = () => { - switch (identifier) { - case 'cpu': + switch (type) { + case IdentifierResourceType.CPU: return setSize(value)} value={size} />; - case 'memory': + case IdentifierResourceType.MEMORY: return setSize(value)} value={String(size)} />; default: return ( @@ -45,12 +46,12 @@ const CountFormField: React.FC = ({ }; return ( - + {renderInputField()} {!isValid && errorMessage && ( - + {errorMessage} diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx index 9e4971ded7..2d296fb59c 100644 --- a/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx +++ b/frontend/src/pages/hardwareProfiles/nodeResource/ManageNodeResourceModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Modal } from '@patternfly/react-core/deprecated'; import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; -import { Identifier } from '~/types'; +import { Identifier, IdentifierResourceType } from '~/types'; import useGenericObjectState from '~/utilities/useGenericObjectState'; import { CPU_UNITS, MEMORY_UNITS_FOR_SELECTION, UnitOption } from '~/utilities/valueUnits'; import { EMPTY_IDENTIFIER } from './const'; @@ -32,11 +32,11 @@ const ManageNodeResourceModal: React.FC = ({ !nodeResources.some((i) => i.identifier === identifier.identifier); React.useEffect(() => { - switch (identifier.identifier) { - case 'cpu': + switch (identifier.resourceType) { + case IdentifierResourceType.CPU: setUnitOptions(CPU_UNITS); break; - case 'memory': + case IdentifierResourceType.MEMORY: setUnitOptions(MEMORY_UNITS_FOR_SELECTION); break; default: @@ -44,9 +44,8 @@ const ManageNodeResourceModal: React.FC = ({ } }, [identifier]); - const isValidCounts = unitOptions - ? validateDefaultCount(identifier, unitOptions) && validateMinCount(identifier, unitOptions) - : true; + const isValidCounts = + validateDefaultCount(identifier, unitOptions) && validateMinCount(identifier, unitOptions); const isButtonDisabled = !identifier.displayName || !identifier.identifier || !isUniqueIdentifier || !isValidCounts; @@ -75,7 +74,6 @@ const ManageNodeResourceModal: React.FC = ({ identifier={identifier} setIdentifier={setIdentifier} unitOptions={unitOptions} - isExistingIdentifier={!!existingIdentifier} isUniqueIdentifier={isUniqueIdentifier} /> diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx index 90b7858259..29b600efee 100644 --- a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceForm.tsx @@ -7,9 +7,16 @@ import { HelperText, HelperTextItem, } from '@patternfly/react-core'; -import { Identifier } from '~/types'; +import { Identifier, IdentifierResourceType } from '~/types'; import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; import { UnitOption } from '~/utilities/valueUnits'; +import SimpleSelect from '~/components/SimpleSelect'; +import { asEnumMember } from '~/utilities/utils'; +import { + DEFAULT_CPU_SIZE, + DEFAULT_MEMORY_SIZE, + EMPTY_IDENTIFIER, +} from '~/pages/hardwareProfiles/nodeResource/const'; import { validateDefaultCount, validateMinCount } from './utils'; import CountFormField from './CountFormField'; @@ -17,7 +24,6 @@ type NodeResourceFormProps = { identifier: Identifier; setIdentifier: UpdateObjectAtPropAndValue; unitOptions?: UnitOption[]; - isExistingIdentifier?: boolean; isUniqueIdentifier?: boolean; }; @@ -25,7 +31,6 @@ const NodeResourceForm: React.FC = ({ identifier, setIdentifier, unitOptions, - isExistingIdentifier, isUniqueIdentifier, }) => { const validated = isUniqueIdentifier ? 'default' : 'error'; @@ -36,6 +41,7 @@ const NodeResourceForm: React.FC = ({ setIdentifier('displayName', value)} + data-testid="node-resource-label-input" /> @@ -43,11 +49,8 @@ const NodeResourceForm: React.FC = ({ setIdentifier('identifier', value)} - isDisabled={ - isExistingIdentifier && - (identifier.identifier === 'cpu' || identifier.identifier === 'memory') - } validated={validated} + data-testid="node-resource-identifier-input" /> {!isUniqueIdentifier && ( @@ -61,30 +64,67 @@ const NodeResourceForm: React.FC = ({ )} + + ({ + key: v, + label: v, + })), + { key: 'Other', label: 'Other' }, + ]} + value={identifier.resourceType || 'Other'} + onChange={(value) => { + const resourceType = asEnumMember(value, IdentifierResourceType); + switch (resourceType) { + case IdentifierResourceType.CPU: + setIdentifier('resourceType', resourceType); + setIdentifier('minCount', DEFAULT_CPU_SIZE.minCount); + setIdentifier('maxCount', DEFAULT_CPU_SIZE.maxCount); + setIdentifier('defaultCount', DEFAULT_CPU_SIZE.defaultCount); + break; + case IdentifierResourceType.MEMORY: + setIdentifier('resourceType', resourceType); + setIdentifier('minCount', DEFAULT_MEMORY_SIZE.minCount); + setIdentifier('maxCount', DEFAULT_MEMORY_SIZE.maxCount); + setIdentifier('defaultCount', DEFAULT_MEMORY_SIZE.defaultCount); + break; + default: + setIdentifier('resourceType', undefined); + setIdentifier('minCount', EMPTY_IDENTIFIER.minCount); + setIdentifier('maxCount', EMPTY_IDENTIFIER.maxCount); + setIdentifier('defaultCount', EMPTY_IDENTIFIER.defaultCount); + } + }} + /> + + setIdentifier('defaultCount', value)} - isValid={unitOptions ? validateDefaultCount(identifier, unitOptions) : true} + isValid={validateDefaultCount(identifier, unitOptions)} errorMessage="Default must be equal to or between the minimum and maximum allowed limits." /> setIdentifier('minCount', value)} - isValid={unitOptions ? validateMinCount(identifier, unitOptions) : true} + isValid={validateMinCount(identifier, unitOptions)} errorMessage="Minimum allowed value cannot exceed the maximum allowed value." /> setIdentifier('maxCount', value)} /> diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx index 640e648ba2..94143eb72d 100644 --- a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTable.tsx @@ -7,7 +7,7 @@ import ManageNodeResourceModal from './ManageNodeResourceModal'; type NodeResourceTableProps = { nodeResources: Identifier[]; - onUpdate?: (identifiers: Identifier[]) => void; + onUpdate?: (nodeResources: Identifier[]) => void; }; const NodeResourceTable: React.FC = ({ nodeResources, onUpdate }) => { @@ -29,7 +29,7 @@ const NodeResourceTable: React.FC = ({ nodeResources, on } rowRenderer={(identifier, rowIndex) => ( { setEditIdentifier(newIdentifier); diff --git a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx index dff5b3d099..85c296658b 100644 --- a/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx +++ b/frontend/src/pages/hardwareProfiles/nodeResource/NodeResourceTableRow.tsx @@ -20,6 +20,7 @@ const NodeResourceTableRow: React.FC = ({ {identifier.displayName} {identifier.identifier} + {identifier.resourceType ?? 'Other'} {identifier.defaultCount} {identifier.minCount} {identifier.maxCount} @@ -29,7 +30,6 @@ const NodeResourceTableRow: React.FC = ({