diff --git a/frontend/src/__mocks__/mockModelVersion.ts b/frontend/src/__mocks__/mockModelVersion.ts index 0207d9ba96..29396dab1f 100644 --- a/frontend/src/__mocks__/mockModelVersion.ts +++ b/frontend/src/__mocks__/mockModelVersion.ts @@ -1,4 +1,4 @@ -import { ModelVersion, ModelVersionState } from '~/concepts/modelRegistry/types'; +import { ModelVersion, ModelState } from '~/concepts/modelRegistry/types'; import { createModelRegistryLabelsObject } from './utils'; type MockModelVersionType = { @@ -7,7 +7,7 @@ type MockModelVersionType = { registeredModelId?: string; name?: string; labels?: string[]; - state?: ModelVersionState; + state?: ModelState; description?: string; }; @@ -17,7 +17,7 @@ export const mockModelVersion = ({ name = 'new model version', labels = [], id = '1', - state = ModelVersionState.LIVE, + state = ModelState.LIVE, description = 'Description of model version', }: MockModelVersionType): ModelVersion => ({ author, diff --git a/frontend/src/__mocks__/mockRegisteredModel.ts b/frontend/src/__mocks__/mockRegisteredModel.ts index 5524026851..0ba8a8c70c 100644 --- a/frontend/src/__mocks__/mockRegisteredModel.ts +++ b/frontend/src/__mocks__/mockRegisteredModel.ts @@ -1,17 +1,17 @@ -import { RegisteredModel, RegisteredModelState } from '~/concepts/modelRegistry/types'; +import { RegisteredModel, ModelState } from '~/concepts/modelRegistry/types'; import { createModelRegistryLabelsObject } from './utils'; type MockRegisteredModelType = { id?: string; name?: string; - state?: RegisteredModelState; + state?: ModelState; description?: string; labels?: string[]; }; export const mockRegisteredModel = ({ name = 'test', - state = RegisteredModelState.LIVE, + state = ModelState.LIVE, description = '', labels = [], id = '1', diff --git a/frontend/src/__mocks__/mockRegisteredModelsList.ts b/frontend/src/__mocks__/mockRegisteredModelsList.ts index 216c50fa97..47acb668a3 100644 --- a/frontend/src/__mocks__/mockRegisteredModelsList.ts +++ b/frontend/src/__mocks__/mockRegisteredModelsList.ts @@ -3,8 +3,7 @@ import { mockRegisteredModel } from './mockRegisteredModel'; export const mockRegisteredModelList = ({ size = 5, -}: Partial): RegisteredModelList => ({ - items: [ + items = [ mockRegisteredModel({ name: 'test-1' }), mockRegisteredModel({ name: 'test-2' }), mockRegisteredModel({ @@ -46,6 +45,8 @@ export const mockRegisteredModelList = ({ ], }), ], +}: Partial): RegisteredModelList => ({ + items, nextPageToken: '', pageSize: 0, size, diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionArchive.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionArchive.ts index cda7714b8b..37b64e52cb 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionArchive.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/modelVersionArchive.ts @@ -79,10 +79,6 @@ class ModelVersionArchive { this.wait(); } - findTableKebabMenu() { - return cy.findByTestId('model-versions-table-kebab-action'); - } - findModelVersionsTableKebab() { return cy.findByTestId('model-versions-table-kebab-action'); } diff --git a/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registeredModelArchive.ts b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registeredModelArchive.ts new file mode 100644 index 0000000000..09b19e2c12 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/modelRegistry/registeredModelArchive.ts @@ -0,0 +1,118 @@ +import { + registeredModelArchiveDetailsUrl, + registeredModelArchiveUrl, + registeredModelUrl, +} from '~/pages/modelRegistry/screens/routeUtils'; +import { TableRow } from '~/__tests__/cypress/cypress/pages/components/table'; +import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; + +class ArchiveModelTableRow extends TableRow { + findName() { + return this.find().findByTestId('model-name'); + } + + findDescription() { + return this.find().findByTestId('description'); + } + + findLabelPopoverText() { + return this.find().findByTestId('popover-label-text'); + } + + findLabelModalText() { + return this.find().findByTestId('modal-label-text'); + } + + shouldContainsPopoverLabels(labels: string[]) { + cy.findByTestId('popover-label-group').within(() => labels.map((label) => cy.contains(label))); + return this; + } +} + +class RestoreModelModal extends Modal { + constructor() { + super('Restore model?'); + } + + findRestoreButton() { + return cy.findByTestId('modal-submit-button'); + } +} + +class ArchiveModelModal extends Modal { + constructor() { + super('Archive model?'); + } + + findArchiveButton() { + return cy.findByTestId('modal-submit-button'); + } + + findModalTextInput() { + return cy.findByTestId('confirm-archive-input'); + } +} + +class ModelArchive { + private wait() { + cy.findByTestId('app-page-title').should('exist'); + cy.testA11y(); + } + + visit() { + cy.visit(registeredModelArchiveUrl('modelregistry-sample')); + this.wait(); + } + + visitArchiveModelDetail() { + cy.visit(registeredModelArchiveDetailsUrl('2', 'modelregistry-sample')); + } + + visitModelList() { + cy.visit('/modelRegistry/modelregistry-sample'); + this.wait(); + } + + visitModelDetails() { + cy.visit(registeredModelUrl('2', 'modelregistry-sample')); + this.wait(); + } + + findTableKebabMenu() { + return cy.findByTestId('registered-models-table-kebab-action'); + } + + shouldArchiveVersionsEmpty() { + cy.findByTestId('empty-archive-model-state').should('exist'); + } + + findArchiveModelBreadcrumbItem() { + return cy.findByTestId('archive-model-page-breadcrumb'); + } + + findArchiveModelTable() { + return cy.findByTestId('registered-models-archive-table'); + } + + findArchiveModelsTableRows() { + return this.findArchiveModelTable().find('tbody tr'); + } + + findRestoreButton() { + return cy.findByTestId('restore-button'); + } + + getRow(name: string) { + return new ArchiveModelTableRow(() => + this.findArchiveModelTable().find(`[data-label="Model name"]`).contains(name).parents('tr'), + ); + } + + findModelVersionsDetailsHeaderAction() { + return cy.findByTestId('model-version-action-toggle'); + } +} + +export const registeredModelArchive = new ModelArchive(); +export const restoreModelModal = new RestoreModelModal(); +export const archiveModelModal = new ArchiveModelModal(); diff --git a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts index 0f29cdf747..c93f612a37 100644 --- a/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts +++ b/frontend/src/__tests__/cypress/cypress/support/commands/odh.ts @@ -368,6 +368,12 @@ declare global { response: OdhResponse, ): Cypress.Chainable; + interceptOdh( + type: 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + options: { path: { serviceName: string; apiVersion: string; registeredModelId: number } }, + response: OdhResponse, + ): Cypress.Chainable; + interceptOdh( type: `GET /api/service/pipelines/:namespace/:serviceName/apis/v2beta1/pipelines/:pipelineId/versions/:pipelineVersionId`, options: { diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/ModelVersionArchive.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/ModelVersionArchive.cy.ts index a6f9b3b0a7..83e5d3f660 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/ModelVersionArchive.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/ModelVersionArchive.cy.ts @@ -7,7 +7,7 @@ import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; import { ModelRegistryModel } from '~/__tests__/cypress/cypress/utils/models'; import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; -import { ModelVersion, ModelVersionState } from '~/concepts/modelRegistry/types'; +import { ModelVersion, ModelState } from '~/concepts/modelRegistry/types'; import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; import { @@ -39,9 +39,9 @@ const initIntercepts = ({ 'Test label y', 'Test label z', ], - state: ModelVersionState.ARCHIVED, + state: ModelState.ARCHIVED, }), - mockModelVersion({ id: '2', name: 'model version 2', state: ModelVersionState.ARCHIVED }), + mockModelVersion({ id: '2', name: 'model version 2', state: ModelState.ARCHIVED }), mockModelVersion({ id: '3', name: 'model version 3' }), ], }: HandlersProps) => { @@ -100,7 +100,7 @@ const initIntercepts = ({ modelVersionId: 2, }, }, - mockModelVersion({ id: '2', name: 'model version 2', state: ModelVersionState.ARCHIVED }), + mockModelVersion({ id: '2', name: 'model version 2', state: ModelState.ARCHIVED }), ); cy.interceptOdh( @@ -112,7 +112,7 @@ const initIntercepts = ({ modelVersionId: 3, }, }, - mockModelVersion({ id: '3', name: 'model version 3', state: ModelVersionState.LIVE }), + mockModelVersion({ id: '3', name: 'model version 3', state: ModelState.LIVE }), ); }; diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/RegisteredModelArchive.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/RegisteredModelArchive.cy.ts new file mode 100644 index 0000000000..21cd8a5413 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/RegisteredModelArchive.cy.ts @@ -0,0 +1,280 @@ +/* eslint-disable camelcase */ +import { mockK8sResourceList } from '~/__mocks__'; +import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; +import { MODEL_REGISTRY_API_VERSION } from '~/concepts/modelRegistry/const'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; +import { ModelRegistryModel } from '~/__tests__/cypress/cypress/utils/models'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { ModelState, ModelVersion, RegisteredModel } from '~/concepts/modelRegistry/types'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; +import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { + archiveModelModal, + registeredModelArchive, + restoreModelModal, +} from '~/__tests__/cypress/cypress/pages/modelRegistry/registeredModelArchive'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; + +type HandlersProps = { + registeredModelsSize?: number; + registeredModels?: RegisteredModel[]; + modelVersions?: ModelVersion[]; +}; + +const initIntercepts = ({ + registeredModelsSize = 4, + registeredModels = [ + mockRegisteredModel({ + name: 'model 1', + id: '1', + labels: [ + 'Financial data', + 'Fraud detection', + 'Test label', + 'Machine learning', + 'Next data to be overflow', + 'Test label x', + 'Test label y', + 'Test label z', + ], + state: ModelState.ARCHIVED, + }), + mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.ARCHIVED }), + mockRegisteredModel({ id: '3', name: 'model 3' }), + mockRegisteredModel({ id: '4', name: 'model 4' }), + ], + modelVersions = [ + mockModelVersion({ author: 'Author 1' }), + mockModelVersion({ name: 'model version' }), + ], +}: HandlersProps) => { + cy.interceptOdh( + 'GET /api/config', + mockDashboardConfig({ + disableModelRegistry: false, + }), + ); + + cy.interceptK8sList( + ModelRegistryModel, + mockK8sResourceList([mockModelRegistry({}), mockModelRegistry({ name: 'test-registry' })]), + ); + + cy.interceptK8s(ModelRegistryModel, mockModelRegistry({})); + + cy.interceptOdh( + `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models`, + { + path: { serviceName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockRegisteredModelList({ size: registeredModelsSize }), + ); + + cy.interceptOdh( + `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models`, + { + path: { serviceName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockRegisteredModelList({ items: registeredModels }), + ); + + cy.interceptOdh( + `GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId/versions`, + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockModelVersionList({ items: modelVersions }), + ); + + cy.interceptOdh( + 'GET /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.ARCHIVED }), + ); +}; + +describe('Model archive list', () => { + it('No archive models in the selected model registry', () => { + initIntercepts({ + registeredModels: [], + }); + registeredModelArchive.visitModelList(); + verifyRelativeURL('/modelRegistry/modelregistry-sample'); + registeredModelArchive.findTableKebabMenu().findDropdownItem('View archived models').click(); + registeredModelArchive.shouldArchiveVersionsEmpty(); + }); + + it('Archive models list', () => { + initIntercepts({}); + registeredModelArchive.visit(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive'); + + //breadcrumb + registeredModelArchive.findArchiveModelBreadcrumbItem().contains('Archived models'); + + // name, last modified, owner, labels modal + registeredModelArchive.findArchiveModelTable().should('be.visible'); + registeredModelArchive.findArchiveModelsTableRows().should('have.length', 2); + + const archiveModelRow = registeredModelArchive.getRow('model 1'); + + archiveModelRow.findLabelModalText().contains('5 more'); + archiveModelRow.findLabelModalText().click(); + labelModal.shouldContainsModalLabels([ + 'Financial', + 'Financial data', + 'Fraud detection', + 'Test label', + 'Machine learning', + 'Next data to be overflow', + 'Test label x', + 'Test label y', + 'Test label y', + ]); + labelModal.findCloseModal().click(); + }); +}); + +describe('Restoring archive model', () => { + it('Restore from archive models table', () => { + cy.interceptOdh( + 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE }), + ).as('modelRestored'); + + initIntercepts({}); + registeredModelArchive.visit(); + + // Bypass patternfly ExpandableSection error https://github.com/patternfly/patternfly-react/issues/10410 + // Cannot destructure property 'offsetWidth' of 'this.expandableContentRef.current' as it is null. + Cypress.on('uncaught:exception', () => false); + + const archiveModelRow = registeredModelArchive.getRow('model 2'); + archiveModelRow.findKebabAction('Restore model').click(); + + restoreModelModal.findRestoreButton().click(); + + cy.wait('@modelRestored').then((interception) => { + expect(interception.request.body).to.eql({ + customProperties: {}, + description: '', + externalID: '1234132asdfasdf', + state: 'LIVE', + }); + }); + }); + + it('Restore from archive model details', () => { + cy.interceptOdh( + 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE }), + ).as('modelRestored'); + + initIntercepts({}); + registeredModelArchive.visitArchiveModelDetail(); + + registeredModelArchive.findRestoreButton().click(); + restoreModelModal.findRestoreButton().click(); + + cy.wait('@modelRestored').then((interception) => { + expect(interception.request.body).to.eql({ + customProperties: {}, + description: '', + externalID: '1234132asdfasdf', + state: 'LIVE', + }); + }); + }); +}); + +describe('Archiving model', () => { + it('Archive model from registered models table', () => { + cy.interceptOdh( + 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 3, + }, + }, + mockRegisteredModel({ id: '3', name: 'model 3', state: ModelState.ARCHIVED }), + ).as('modelArchived'); + + initIntercepts({}); + registeredModelArchive.visitModelList(); + + const modelRow = modelRegistry.getRow('model 3'); + modelRow.findKebabAction('Archive model').click(); + archiveModelModal.findArchiveButton().should('be.disabled'); + archiveModelModal.findModalTextInput().fill('model 3'); + archiveModelModal.findArchiveButton().should('be.enabled').click(); + cy.wait('@modelArchived').then((interception) => { + expect(interception.request.body).to.eql({ + customProperties: {}, + description: '', + externalID: '1234132asdfasdf', + state: 'ARCHIVED', + }); + }); + }); + + it('Archive model from model details', () => { + cy.interceptOdh( + 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/registered_models/:registeredModelId', + { + path: { + serviceName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.ARCHIVED }), + ).as('modelArchived'); + + initIntercepts({}); + registeredModelArchive.visitModelDetails(); + registeredModelArchive + .findModelVersionsDetailsHeaderAction() + .findDropdownItem('Archive model') + .click(); + + archiveModelModal.findArchiveButton().should('be.disabled'); + archiveModelModal.findModalTextInput().fill('model 2'); + archiveModelModal.findArchiveButton().should('be.enabled').click(); + cy.wait('@modelArchived').then((interception) => { + expect(interception.request.body).to.eql({ + customProperties: {}, + description: '', + externalID: '1234132asdfasdf', + state: 'ARCHIVED', + }); + }); + }); +}); diff --git a/frontend/src/api/modelRegistry/__tests__/custom.spec.ts b/frontend/src/api/modelRegistry/__tests__/custom.spec.ts index 30e2a396d2..5efcfcce41 100644 --- a/frontend/src/api/modelRegistry/__tests__/custom.spec.ts +++ b/frontend/src/api/modelRegistry/__tests__/custom.spec.ts @@ -1,10 +1,6 @@ import { proxyCREATE, proxyGET, proxyPATCH } from '~/api/proxyUtils'; import { handleModelRegistryFailures } from '~/api/modelRegistry/errorUtils'; -import { - RegisteredModelState, - ModelVersionState, - ModelArtifactState, -} from '~/concepts/modelRegistry/types'; +import { ModelState, ModelArtifactState } from '~/concepts/modelRegistry/types'; import { createModelArtifact, createModelVersion, @@ -51,7 +47,7 @@ describe('createRegisteredModel', () => { description: 'test', externalID: '1', name: 'test new registered model', - state: RegisteredModelState.LIVE, + state: ModelState.LIVE, customProperties: {}, }), ).toBe(mockResultPromise); @@ -63,7 +59,7 @@ describe('createRegisteredModel', () => { description: 'test', externalID: '1', name: 'test new registered model', - state: RegisteredModelState.LIVE, + state: ModelState.LIVE, customProperties: {}, }, {}, @@ -83,7 +79,7 @@ describe('createModelVersion', () => { author: 'test author', registeredModelId: '1', name: 'test new model version', - state: ModelVersionState.LIVE, + state: ModelState.LIVE, customProperties: {}, }), ).toBe(mockResultPromise); @@ -97,7 +93,7 @@ describe('createModelVersion', () => { author: 'test author', registeredModelId: '1', name: 'test new model version', - state: ModelVersionState.LIVE, + state: ModelState.LIVE, customProperties: {}, }, {}, diff --git a/frontend/src/concepts/modelRegistry/types.ts b/frontend/src/concepts/modelRegistry/types.ts index 4bae16446f..e93a9d8f72 100644 --- a/frontend/src/concepts/modelRegistry/types.ts +++ b/frontend/src/concepts/modelRegistry/types.ts @@ -1,11 +1,6 @@ import { K8sAPIOptions } from '~/k8sTypes'; -export enum RegisteredModelState { - LIVE = 'LIVE', - ARCHIVED = 'ARCHIVED', -} - -export enum ModelVersionState { +export enum ModelState { LIVE = 'LIVE', ARCHIVED = 'ARCHIVED', } @@ -109,13 +104,13 @@ export type ModelArtifact = ModelRegistryBase & { }; export type ModelVersion = ModelRegistryBase & { - state?: ModelVersionState; + state?: ModelState; author?: string; registeredModelId: string; }; export type RegisteredModel = ModelRegistryBase & { - state?: RegisteredModelState; + state?: ModelState; }; export type InferenceService = ModelRegistryBase & { diff --git a/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx b/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx index 0017723657..aedcb6c65d 100644 --- a/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx +++ b/frontend/src/pages/modelRegistry/ModelRegistryRoutes.tsx @@ -9,6 +9,8 @@ import ModelVersionsDetails from './screens/ModelVersionDetails/ModelVersionDeta import { ModelVersionDetailsTab } from './screens/ModelVersionDetails/const'; import ModelVersionsArchive from './screens/ModelVersionsArchive/ModelVersionsArchive'; import ModelVersionsArchiveDetails from './screens/ModelVersionsArchive/ModelVersionArchiveDetails'; +import RegisteredModelsArchive from './screens/RegisteredModelsArchive/RegisteredModelsArchive'; +import RegisteredModelsArchiveDetails from './screens/RegisteredModelsArchive/RegisteredModelArchiveDetails'; const ModelRegistryRoutes: React.FC = () => ( @@ -21,7 +23,7 @@ const ModelRegistryRoutes: React.FC = () => ( /> } > - } /> + } /> } /> ( } /> + + } /> + + } /> + + } + /> + + } + /> + } /> + + } /> + } /> diff --git a/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx b/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx index cd1c028c12..4bc6619153 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelRegistry.tsx @@ -1,35 +1,29 @@ import React from 'react'; import ApplicationsPage from '~/pages/ApplicationsPage'; import useRegisteredModels from '~/concepts/modelRegistry/apiHooks/useRegisteredModels'; -import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; import TitleWithIcon from '~/concepts/design/TitleWithIcon'; import { ProjectObjectType } from '~/concepts/design/utils'; import RegisteredModelListView from './RegisteredModels/RegisteredModelListView'; -import EmptyModelRegistryState from './components/EmptyModelRegistryState'; import ModelRegistrySelectorNavigator from './ModelRegistrySelectorNavigator'; +import { filterLiveModels } from './utils'; -const ModelRegistry: React.FC = () => { - const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); - const [registeredModels, loaded, loadError] = useRegisteredModels(); +type ModelRegistryProps = Omit< + React.ComponentProps, + | 'title' + | 'description' + | 'loadError' + | 'loaded' + | 'provideChildrenPadding' + | 'removeChildrenTopPadding' + | 'headerContent' +>; + +const ModelRegistry: React.FC = ({ ...pageProps }) => { + const [registeredModels, loaded, loadError, refresh] = useRegisteredModels(); return ( { - // TODO: Add primary action - }} - secondaryActionOnClick={() => { - // TODO: Add secondary action - }} - /> - } + {...pageProps} title={ } @@ -44,7 +38,10 @@ const ModelRegistry: React.FC = () => { provideChildrenPadding removeChildrenTopPadding > - + ); }; diff --git a/frontend/src/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx b/frontend/src/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx index 3f2ed4f034..98a0216e98 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx @@ -3,7 +3,7 @@ import { Dropdown, DropdownList, MenuToggle, DropdownItem } from '@patternfly/re import { useNavigate } from 'react-router'; import { ArchiveModelVersionModal } from '~/pages/modelRegistry/screens/components/ArchiveModelVersionModal'; import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; -import { ModelVersion, ModelVersionState } from '~/concepts/modelRegistry/types'; +import { ModelVersion, ModelState } from '~/concepts/modelRegistry/types'; import { getPatchBodyForModelVersion } from '~/pages/modelRegistry/screens/utils'; import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; import { modelVersionArchiveDetailsUrl } from '~/pages/modelRegistry/screens/routeUtils'; @@ -72,7 +72,7 @@ const ModelVersionsDetailsHeaderActions: React.FC diff --git a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx index 8affa80a3a..049454ed25 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx @@ -125,7 +125,7 @@ const ModelVersionListView: React.FC = ({ setIsArchivedModelVersionKebabOpen(!isArchivedModelVersionKebabOpen) } isExpanded={isArchivedModelVersionKebabOpen} - aria-label="View archived models" + aria-label="View archived versions" > diff --git a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx index b36ecbd556..0bf008a6ec 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx @@ -44,7 +44,7 @@ const ModelVersions: React.FC = ({ tab, ...pageProps }) => { } title={rm?.name} - headerAction={} + headerAction={rm && } description={rm?.description} loadError={loadError} loaded={loaded} diff --git a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsHeaderActions.tsx b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsHeaderActions.tsx index 453fc956df..0ab9c8826b 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsHeaderActions.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsHeaderActions.tsx @@ -1,42 +1,84 @@ import * as React from 'react'; -import { Dropdown, DropdownList, MenuToggle, DropdownItem } from '@patternfly/react-core'; +import { + Dropdown, + DropdownList, + MenuToggle, + DropdownItem, + Flex, + FlexItem, +} from '@patternfly/react-core'; +import { useNavigate } from 'react-router'; +import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; +import { ArchiveRegisteredModelModal } from '~/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal'; +import { getPatchBodyForRegisteredModel } from '~/pages/modelRegistry/screens/utils'; +import { registeredModelsUrl } from '~/pages/modelRegistry/screens/routeUtils'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import { RegisteredModel, ModelState } from '~/concepts/modelRegistry/types'; -const ModelVersionsHeaderActions: React.FC = () => { +interface ModelVersionsHeaderActionsProps { + rm: RegisteredModel; +} + +const ModelVersionsHeaderActions: React.FC = ({ rm }) => { + const { apiState } = React.useContext(ModelRegistryContext); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + + const navigate = useNavigate(); const [isOpen, setOpen] = React.useState(false); const tooltipRef = React.useRef(null); + const [isArchiveModalOpen, setIsArchiveModalOpen] = React.useState(false); return ( <> - setOpen(false)} - onOpenChange={(open) => setOpen(open)} - toggle={(toggleRef) => ( - setOpen(!isOpen)} - isExpanded={isOpen} - aria-label="Model version action toggle" - data-testid="model-version-action-toggle" - > - Actions - - )} - > - - undefined} - ref={tooltipRef} - isDisabled // This feature is currently disabled but will be enabled in a future PR post-summit release. + + + setOpen(false)} + onOpenChange={(open) => setOpen(open)} + popperProps={{ position: 'end' }} + toggle={(toggleRef) => ( + setOpen(!isOpen)} + isExpanded={isOpen} + aria-label="Model version action toggle" + data-testid="model-version-action-toggle" + > + Actions + + )} > - Archive model - - - + + setIsArchiveModalOpen(true)} + ref={tooltipRef} + > + Archive model + + + + + + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved + getPatchBodyForRegisteredModel(rm, { state: ModelState.ARCHIVED }), + rm.id, + ) + .then(() => navigate(registeredModelsUrl(preferredModelRegistry?.metadata.name))) + } + isOpen={isArchiveModalOpen} + registeredModelName={rm.name} + /> ); }; diff --git a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx index d585012b16..3271fcb940 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; import { Text, TextVariants, Truncate, FlexItem } from '@patternfly/react-core'; import { Link, useNavigate } from 'react-router-dom'; -import { ModelVersion, ModelVersionState } from '~/concepts/modelRegistry/types'; +import { ModelVersion, ModelState } from '~/concepts/modelRegistry/types'; import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; import ModelLabels from '~/pages/modelRegistry/screens/components/ModelLabels'; import ModelTimestamp from '~/pages/modelRegistry/screens/components/ModelTimestamp'; @@ -97,7 +97,7 @@ const ModelVersionsTableRow: React.FC = ({ .patchModelVersion( {}, // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved - getPatchBodyForModelVersion(mv, { state: ModelVersionState.ARCHIVED }), + getPatchBodyForModelVersion(mv, { state: ModelState.ARCHIVED }), mv.id, ) .then(refresh) @@ -112,7 +112,7 @@ const ModelVersionsTableRow: React.FC = ({ .patchModelVersion( {}, // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved - getPatchBodyForModelVersion(mv, { state: ModelVersionState.LIVE }), + getPatchBodyForModelVersion(mv, { state: ModelState.LIVE }), mv.id, ) .then(() => diff --git a/frontend/src/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx b/frontend/src/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx index 353092219a..4abe969278 100644 --- a/frontend/src/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx +++ b/frontend/src/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx @@ -11,7 +11,7 @@ import ModelVersionDetailsTabs from '~/pages/modelRegistry/screens/ModelVersionD import { RestoreModelVersionModal } from '~/pages/modelRegistry/screens/components/RestoreModelVersionModal'; import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; import { getPatchBodyForModelVersion } from '~/pages/modelRegistry/screens/utils'; -import { ModelVersionState } from '~/concepts/modelRegistry/types'; +import { ModelState } from '~/concepts/modelRegistry/types'; import ModelVersionArchiveDetailsBreadcrumb from './ModelVersionArchiveDetailsBreadcrumb'; type ModelVersionsArchiveDetailsProps = { @@ -78,7 +78,7 @@ const ModelVersionsArchiveDetails: React.FC = .patchModelVersion( {}, // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved - getPatchBodyForModelVersion(mv, { state: ModelVersionState.LIVE }), + getPatchBodyForModelVersion(mv, { state: ModelState.LIVE }), mv.id, ) .then(() => diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx index 8b83b4c3f0..f1591997a1 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx @@ -1,44 +1,56 @@ import * as React from 'react'; import { SearchInput, ToolbarFilter, ToolbarGroup, ToolbarItem } from '@patternfly/react-core'; import { FilterIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router'; import { SearchType } from '~/concepts/dashboard/DashboardSearchField'; import { RegisteredModel } from '~/concepts/modelRegistry/types'; import SimpleDropdownSelect from '~/components/SimpleDropdownSelect'; +import { filterRegisteredModels } from '~/pages/modelRegistry/screens/utils'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import EmptyModelRegistryState from '~/pages/modelRegistry/screens/components/EmptyModelRegistryState'; +import { registeredModelArchiveUrl } from '~/pages/modelRegistry/screens/routeUtils'; import RegisteredModelTable from './RegisteredModelTable'; import RegisteredModelsTableToolbar from './RegisteredModelsTableToolbar'; type RegisteredModelListViewProps = { registeredModels: RegisteredModel[]; + refresh: () => void; }; const RegisteredModelListView: React.FC = ({ registeredModels: unfilteredRegisteredModels, + refresh, }) => { + const navigate = useNavigate(); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); const [search, setSearch] = React.useState(''); - const searchTypes = React.useMemo(() => [SearchType.KEYWORD], []); // TODO Add owner once RHOAIENG-5066 is completed. + const searchTypes = React.useMemo(() => [SearchType.KEYWORD], []); // TODO Add owner once RHOAIENG-7566 is completed. - const filteredRegisteredModels = unfilteredRegisteredModels.filter((rm) => { - if (!search) { - return true; - } - - switch (searchType) { - case SearchType.KEYWORD: - return ( - rm.name.toLowerCase().includes(search.toLowerCase()) || - (rm.description && rm.description.toLowerCase().includes(search.toLowerCase())) - ); - - case SearchType.OWNER: - // TODO Implement owner search functionality once RHOAIENG-5066 is completed. - return; - - default: - return true; - } - }); + if (unfilteredRegisteredModels.length === 0) { + return ( + { + // TODO: Add primary action + }} + secondaryActionOnClick={() => { + navigate(registeredModelArchiveUrl(preferredModelRegistry?.metadata.name)); + }} + /> + ); + } + // console.log(unfilteredRegisteredModels); + const filteredRegisteredModels = filterRegisteredModels( + unfilteredRegisteredModels, + search, + searchType, + ); const resetFilters = () => { setSearch(''); @@ -81,6 +93,7 @@ const RegisteredModelListView: React.FC = ({ return ( } diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx index 9a0d91ab96..b6ffe98cf6 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTable.tsx @@ -8,12 +8,14 @@ import RegisteredModelTableRow from './RegisteredModelTableRow'; type RegisteredModelTableProps = { clearFilters: () => void; registeredModels: RegisteredModel[]; + refresh: () => void; } & Partial, 'toolbarContent'>>; const RegisteredModelTable: React.FC = ({ clearFilters, registeredModels, toolbarContent, + refresh, }) => ( = ({ toolbarContent={toolbarContent} enablePagination emptyTableView={} - rowRenderer={(rm) => } + rowRenderer={(rm) => ( + + )} /> ); diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx index 8a4617f5ae..28d6bca4c4 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow.tsx @@ -2,31 +2,70 @@ import * as React from 'react'; import { useNavigate, Link } from 'react-router-dom'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; import { FlexItem, Text, TextVariants, Truncate } from '@patternfly/react-core'; -import { RegisteredModel } from '~/concepts/modelRegistry/types'; +import { ModelState, RegisteredModel } from '~/concepts/modelRegistry/types'; import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; import ModelTimestamp from '~/pages/modelRegistry/screens/components/ModelTimestamp'; -import { registeredModelUrl } from '~/pages/modelRegistry/screens/routeUtils'; +import { + registeredModelArchiveDetailsUrl, + registeredModelUrl, +} from '~/pages/modelRegistry/screens/routeUtils'; import ModelLabels from '~/pages/modelRegistry/screens/components/ModelLabels'; -import { ModelVersionsTab } from '~/pages/modelRegistry/screens/ModelVersions/const'; +import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; +import { ArchiveRegisteredModelModal } from '~/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal'; +import { getPatchBodyForRegisteredModel } from '~/pages/modelRegistry/screens/utils'; +import { RestoreRegisteredModelModal } from '~/pages/modelRegistry/screens/components/RestoreRegisteredModel'; import RegisteredModelOwner from './RegisteredModelOwner'; type RegisteredModelTableRowProps = { registeredModel: RegisteredModel; + isArchiveRow?: boolean; + refresh: () => void; }; const RegisteredModelTableRow: React.FC = ({ registeredModel: rm, + isArchiveRow, + refresh, }) => { + const { apiState } = React.useContext(ModelRegistryContext); const navigate = useNavigate(); const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const [isArchiveModalOpen, setIsArchiveModalOpen] = React.useState(false); + const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); const rmUrl = registeredModelUrl(rm.id, preferredModelRegistry?.metadata.name); + const actions = isArchiveRow + ? [ + { + title: 'Restore model', + onClick: () => setIsRestoreModalOpen(true), + }, + ] + : [ + { + title: 'Deploy', + isDisabled: true, + // TODO: Implement functionality for onClick. This will be added in another PR + onClick: () => undefined, + }, + { + title: 'Archive model', + onClick: () => setIsArchiveModalOpen(true), + }, + ]; + return ( diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts index dca8dd9132..f1693aa873 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns.ts @@ -26,7 +26,7 @@ export const rmColumns: SortableData[] = [ { field: 'owner', label: 'Owner', - sortable: false, // TODO Add sortable once RHOAIENG-5066 is completed. + sortable: false, // TODO Add sortable once RHOAIENG-7566 is completed. }, { field: 'kebab', diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx index 480a8c93cf..ee02112b87 100644 --- a/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableToolbar.tsx @@ -12,6 +12,9 @@ import { ToolbarToggleGroup, } from '@patternfly/react-core'; import { EllipsisVIcon, FilterIcon } from '@patternfly/react-icons'; +import { useNavigate } from 'react-router'; +import { registeredModelArchiveUrl } from '~/pages/modelRegistry/screens/routeUtils'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; type RegisteredModelsTableToolbarProps = { toggleGroupItems?: React.ReactNode; @@ -20,6 +23,8 @@ type RegisteredModelsTableToolbarProps = { const RegisteredModelsTableToolbar: React.FC = ({ toggleGroupItems: tableToggleGroupItems, }) => { + const navigate = useNavigate(); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); const [isRegisterNewVersionOpen, setIsRegisterNewVersionOpen] = React.useState(false); const [isArchivedModelKebabOpen, setIsArchivedModelKebabOpen] = React.useState(false); @@ -83,6 +88,7 @@ const RegisteredModelsTableToolbar: React.FC onOpenChange={(isOpen: boolean) => setIsArchivedModelKebabOpen(isOpen)} toggle={(tr: React.Ref) => ( setIsArchivedModelKebabOpen(!isArchivedModelKebabOpen)} @@ -95,7 +101,13 @@ const RegisteredModelsTableToolbar: React.FC shouldFocusToggleOnSelect > - View archived models + + navigate(registeredModelArchiveUrl(preferredModelRegistry?.metadata.name)) + } + > + View archived models + diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetails.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetails.tsx new file mode 100644 index 0000000000..2488e1a200 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetails.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { Button, Flex, FlexItem, Label, Text } from '@patternfly/react-core'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import { registeredModelUrl } from '~/pages/modelRegistry/screens/routeUtils'; +import useRegisteredModelById from '~/concepts/modelRegistry/apiHooks/useRegisteredModelById'; +import { ModelRegistryContext } from '~/concepts/modelRegistry/context/ModelRegistryContext'; +import { getPatchBodyForRegisteredModel } from '~/pages/modelRegistry/screens/utils'; +import { ModelState } from '~/concepts/modelRegistry/types'; +import { RestoreRegisteredModelModal } from '~/pages/modelRegistry/screens/components/RestoreRegisteredModel'; +import ModelVersionsTabs from '~/pages/modelRegistry/screens/ModelVersions/ModelVersionsTabs'; +import { ModelVersionsTab } from '~/pages/modelRegistry/screens/ModelVersions/const'; +import useModelVersionsByRegisteredModel from '~/concepts/modelRegistry/apiHooks/useModelVersionsByRegisteredModel'; +import RegisteredModelArchiveDetailsBreadcrumb from './RegisteredModelArchiveDetailsBreadcrumb'; + +type RegisteredModelsArchiveDetailsProps = { + tab: ModelVersionsTab; +} & Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'description' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const RegisteredModelsArchiveDetails: React.FC = ({ + tab, + ...pageProps +}) => { + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const { apiState } = React.useContext(ModelRegistryContext); + + const navigate = useNavigate(); + + const { registeredModelId: rmId } = useParams(); + const [rm, rmLoaded, rmLoadError, rmRefresh] = useRegisteredModelById(rmId); + const [modelVersions, mvLoaded, mvLoadError, refresh] = useModelVersionsByRegisteredModel(rmId); + const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); + + return ( + <> + + } + title={ + rm && ( + + + {rm.name} + + + + + + ) + } + headerAction={ + + } + description={rm?.description} + loadError={rmLoadError} + loaded={rmLoaded} + provideChildrenPadding + > + {rm !== null && mvLoaded && !mvLoadError && ( + + )} + + {rm !== null && ( + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved + getPatchBodyForRegisteredModel(rm, { state: ModelState.LIVE }), + rm.id, + ) + .then(() => + navigate(registeredModelUrl(rm.id, preferredModelRegistry?.metadata.name)), + ) + } + isOpen={isRestoreModalOpen} + registeredModelName={rm.name} + /> + )} + + ); +}; + +export default RegisteredModelsArchiveDetails; diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetailsBreadcrumb.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetailsBreadcrumb.tsx new file mode 100644 index 0000000000..fdc160a20c --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetailsBreadcrumb.tsx @@ -0,0 +1,28 @@ +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { RegisteredModel } from '~/concepts/modelRegistry/types'; +import { registeredModelArchiveUrl } from '~/pages/modelRegistry/screens/routeUtils'; + +type RegisteredModelArchiveDetailsBreadcrumbProps = { + preferredModelRegistry?: string; + registeredModel: RegisteredModel | null; +}; + +const RegisteredModelArchiveDetailsBreadcrumb: React.FC< + RegisteredModelArchiveDetailsBreadcrumbProps +> = ({ preferredModelRegistry, registeredModel }) => ( + + Registered models - {preferredModelRegistry}} + /> + ( + Archived models + )} + /> + {registeredModel?.name || 'Loading...'} + +); + +export default RegisteredModelArchiveDetailsBreadcrumb; diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx new file mode 100644 index 0000000000..c98930d376 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchive.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import ApplicationsPage from '~/pages/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/concepts/modelRegistry/context/ModelRegistrySelectorContext'; +import { filterArchiveModels } from '~/pages/modelRegistry/screens/utils'; +import useRegisteredModels from '~/concepts/modelRegistry/apiHooks/useRegisteredModels'; +import RegisteredModelsArchiveListView from './RegisteredModelsArchiveListView'; + +type RegisteredModelsArchiveProps = Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const RegisteredModelsArchive: React.FC = ({ ...pageProps }) => { + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const [registeredModels, loaded, loadError, refresh] = useRegisteredModels(); + + return ( + + ( + + Registered models - {preferredModelRegistry?.metadata.name} + + )} + /> + + Archived models + + + } + title={`Archived models of ${preferredModelRegistry?.metadata.name}`} + loadError={loadError} + loaded={loaded} + provideChildrenPadding + > + + + ); +}; + +export default RegisteredModelsArchive; diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx new file mode 100644 index 0000000000..7e5de0392c --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { + SearchInput, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import { SearchType } from '~/concepts/dashboard/DashboardSearchField'; +import { RegisteredModel } from '~/concepts/modelRegistry/types'; +import SimpleDropdownSelect from '~/components/SimpleDropdownSelect'; +import { filterRegisteredModels } from '~/pages/modelRegistry/screens/utils'; +import EmptyModelRegistryState from '~/pages/modelRegistry/screens/components/EmptyModelRegistryState'; +import RegisteredModelsArchiveTable from './RegisteredModelsArchiveTable'; + +type RegisteredModelsArchiveListViewProps = { + registeredModels: RegisteredModel[]; + refresh: () => void; +}; + +const RegisteredModelsArchiveListView: React.FC = ({ + registeredModels: unfilteredRegisteredModels, + refresh, +}) => { + const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); + const [search, setSearch] = React.useState(''); + + const searchTypes = [SearchType.KEYWORD, SearchType.OWNER]; + + const filteredRegisteredModels = filterRegisteredModels( + unfilteredRegisteredModels, + search, + searchType, + ); + + if (unfilteredRegisteredModels.length === 0) { + return ( + + ); + } + + return ( + setSearch('')} + registeredModels={filteredRegisteredModels} + toolbarContent={ + + } breakpoint="xl"> + + setSearch('')} + deleteChipGroup={() => setSearch('')} + categoryName="Keyword" + > + ({ + key, + label: key, + }))} + value={searchType} + onChange={(newSearchType) => { + setSearchType(newSearchType as SearchType); + }} + icon={} + /> + + + { + setSearch(searchValue); + }} + onClear={() => setSearch('')} + style={{ minWidth: '200px' }} + data-testid="registered-models-archive-table-search" + /> + + + + + } + /> + ); +}; + +export default RegisteredModelsArchiveListView; diff --git a/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveTable.tsx b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveTable.tsx new file mode 100644 index 0000000000..eeec2cd6e1 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveTable.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Table } from '~/components/table'; +import { RegisteredModel } from '~/concepts/modelRegistry/types'; +import DashboardEmptyTableView from '~/concepts/dashboard/DashboardEmptyTableView'; +import { rmColumns } from '~/pages/modelRegistry/screens/RegisteredModels/RegisteredModelsTableColumns'; +import RegisteredModelTableRow from '~/pages/modelRegistry/screens/RegisteredModels/RegisteredModelTableRow'; + +type RegisteredModelsArchiveTableProps = { + clearFilters: () => void; + registeredModels: RegisteredModel[]; + refresh: () => void; +} & Partial, 'toolbarContent'>>; + +const RegisteredModelsArchiveTable: React.FC = ({ + clearFilters, + registeredModels, + toolbarContent, + refresh, +}) => ( +
- + @@ -47,17 +86,38 @@ const RegisteredModelTableRow: React.FC = ({
- navigate(`${rmUrl}/${ModelVersionsTab.DETAILS}`), - }, - { - title: 'Archive model', - isDisabled: true, // This feature is currently disabled but will be enabled in a future PR post-summit release. - }, - ]} + + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved + getPatchBodyForRegisteredModel(rm, { state: ModelState.ARCHIVED }), + rm.id, + ) + .then(refresh) + } + isOpen={isArchiveModalOpen} + registeredModelName={rm.name} + /> + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + // TODO remove the getPatchBody* functions when https://issues.redhat.com/browse/RHOAIENG-6652 is resolved + getPatchBodyForRegisteredModel(rm, { state: ModelState.LIVE }), + rm.id, + ) + .then(() => + navigate(registeredModelUrl(rm.id, preferredModelRegistry?.metadata.name)), + ) + } + isOpen={isRestoreModalOpen} + registeredModelName={rm.name} />
} + defaultSortColumn={1} + rowRenderer={(rm) => ( + + )} + /> +); + +export default RegisteredModelsArchiveTable; diff --git a/frontend/src/pages/modelRegistry/screens/__tests__/utils.spec.ts b/frontend/src/pages/modelRegistry/screens/__tests__/utils.spec.ts index a9144b7d91..a3b6016d5c 100644 --- a/frontend/src/pages/modelRegistry/screens/__tests__/utils.spec.ts +++ b/frontend/src/pages/modelRegistry/screens/__tests__/utils.spec.ts @@ -7,8 +7,7 @@ import { ModelRegistryMetadataType, RegisteredModel, ModelVersion, - RegisteredModelState, - ModelVersionState, + ModelState, } from '~/concepts/modelRegistry/types'; import { filterModelVersions, @@ -19,6 +18,8 @@ import { getPatchBody, filterArchiveVersions, filterLiveVersions, + filterArchiveModels, + filterLiveModels, } from '~/pages/modelRegistry/screens/utils'; import { SearchType } from '~/concepts/dashboard/DashboardSearchField'; @@ -271,7 +272,7 @@ describe('getPatchBody', () => { name: 'test-model', description: 'Description here', labels: [], - state: RegisteredModelState.LIVE, + state: ModelState.LIVE, }); const result = getPatchBody( registeredModel, @@ -287,7 +288,7 @@ describe('getPatchBody', () => { customProperties: { label1: { string_value: '', metadataType: ModelRegistryMetadataType.STRING }, }, - state: RegisteredModelState.LIVE, + state: ModelState.LIVE, externalID: '1234132asdfasdf', } satisfies Partial); }); @@ -303,7 +304,7 @@ describe('getPatchBody', () => { registeredModelId: '1', description: 'New description', customProperties: {}, - state: ModelVersionState.LIVE, + state: ModelState.LIVE, } satisfies Partial); }); @@ -319,20 +320,20 @@ describe('getPatchBody', () => { author: 'Test author', description: 'New description', customProperties: {}, - state: ModelVersionState.LIVE, + state: ModelState.LIVE, } satisfies Partial); }); }); describe('filterModelVersions', () => { const modelVersions: ModelVersion[] = [ - mockModelVersion({ name: 'Test 1', state: ModelVersionState.ARCHIVED }), + mockModelVersion({ name: 'Test 1', state: ModelState.ARCHIVED }), mockModelVersion({ name: 'Test 2', description: 'Description2', }), - mockModelVersion({ name: 'Test 3', author: 'Author3', state: ModelVersionState.ARCHIVED }), - mockModelVersion({ name: 'Test 4', state: ModelVersionState.ARCHIVED }), + mockModelVersion({ name: 'Test 3', author: 'Author3', state: ModelState.ARCHIVED }), + mockModelVersion({ name: 'Test 4', state: ModelState.ARCHIVED }), mockModelVersion({ name: 'Test 5' }), ]; @@ -352,7 +353,7 @@ describe('filterModelVersions', () => { }); test('filters archived model versions', () => { - const filtered = filterModelVersions(modelVersions, '', SearchType.KEYWORD, true); + const filtered = filterModelVersions(modelVersions, '', SearchType.KEYWORD); expect(filtered).toEqual([modelVersions[0], modelVersions[2], modelVersions[3]]); }); @@ -364,15 +365,15 @@ describe('filterModelVersions', () => { describe('Filter model version state', () => { const modelVersions: ModelVersion[] = [ - mockModelVersion({ name: 'Test 1', state: ModelVersionState.ARCHIVED }), + mockModelVersion({ name: 'Test 1', state: ModelState.ARCHIVED }), mockModelVersion({ name: 'Test 2', - state: ModelVersionState.LIVE, + state: ModelState.LIVE, description: 'Description2', }), - mockModelVersion({ name: 'Test 3', author: 'Author3', state: ModelVersionState.ARCHIVED }), - mockModelVersion({ name: 'Test 4', state: ModelVersionState.ARCHIVED }), - mockModelVersion({ name: 'Test 5', state: ModelVersionState.LIVE }), + mockModelVersion({ name: 'Test 3', author: 'Author3', state: ModelState.ARCHIVED }), + mockModelVersion({ name: 'Test 4', state: ModelState.ARCHIVED }), + mockModelVersion({ name: 'Test 5', state: ModelState.LIVE }), ]; describe('filterArchiveVersions', () => { @@ -394,7 +395,45 @@ describe('Filter model version state', () => { }); it('should return an empty array if the input array is empty', () => { - const result = filterArchiveVersions([]); + const result = filterLiveVersions([]); + expect(result).toEqual([]); + }); + }); +}); + +describe('Filter model state', () => { + const models: RegisteredModel[] = [ + mockRegisteredModel({ name: 'Test 1', state: ModelState.ARCHIVED }), + mockRegisteredModel({ + name: 'Test 2', + state: ModelState.LIVE, + description: 'Description2', + }), + mockRegisteredModel({ name: 'Test 3', state: ModelState.ARCHIVED }), + mockRegisteredModel({ name: 'Test 4', state: ModelState.ARCHIVED }), + mockRegisteredModel({ name: 'Test 5', state: ModelState.LIVE }), + ]; + + describe('filterArchiveModels', () => { + it('should filter out only the archived versions', () => { + const archivedModels = filterArchiveModels(models); + expect(archivedModels).toEqual([models[0], models[2], models[3]]); + }); + + it('should return an empty array if the input array is empty', () => { + const result = filterArchiveModels([]); + expect(result).toEqual([]); + }); + }); + + describe('filterLiveModels', () => { + it('should filter out only the live models', () => { + const liveModels = filterLiveModels(models); + expect(liveModels).toEqual([models[1], models[4]]); + }); + + it('should return an empty array if the input array is empty', () => { + const result = filterLiveModels([]); expect(result).toEqual([]); }); }); diff --git a/frontend/src/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx b/frontend/src/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx new file mode 100644 index 0000000000..060baaa901 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/components/ArchiveRegisteredModelModal.tsx @@ -0,0 +1,93 @@ +import * as React from 'react'; +import { Flex, FlexItem, Modal, Stack, StackItem, TextInput } from '@patternfly/react-core'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import useNotification from '~/utilities/useNotification'; + +interface ArchiveRegisteredModelModalProps { + onCancel: () => void; + onSubmit: () => void; + isOpen: boolean; + registeredModelName: string; +} + +export const ArchiveRegisteredModelModal: React.FC = ({ + onCancel, + onSubmit, + isOpen, + registeredModelName, +}) => { + const notification = useNotification(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [error, setError] = React.useState(); + const [confirmInputValue, setConfirmInputValue] = React.useState(''); + const isDisabled = confirmInputValue.trim() !== registeredModelName || isSubmitting; + + const onClose = React.useCallback(() => { + setConfirmInputValue(''); + onCancel(); + }, [onCancel]); + + const onConfirm = React.useCallback(async () => { + setIsSubmitting(true); + + try { + await onSubmit(); + onClose(); + notification.success(`${registeredModelName} and all its versions archived.`); + } catch (e) { + if (e instanceof Error) { + setError(e); + } + } finally { + setIsSubmitting(false); + } + }, [onSubmit, onClose, notification, registeredModelName]); + + return ( + + } + data-testid="archive-registered-model-modal" + > + + + {registeredModelName} and all of its versions will be archived and unavailable for + use unless it is restored. + + + + + Type {registeredModelName} to confirm archiving: + + setConfirmInputValue(newValue)} + onKeyDown={(event) => { + if (event.key === 'Enter' && !isDisabled) { + onConfirm(); + } + }} + /> + + + + + ); +}; diff --git a/frontend/src/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx b/frontend/src/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx new file mode 100644 index 0000000000..9207519a03 --- /dev/null +++ b/frontend/src/pages/modelRegistry/screens/components/RestoreRegisteredModel.tsx @@ -0,0 +1,66 @@ +import * as React from 'react'; +import { Modal } from '@patternfly/react-core'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import useNotification from '~/utilities/useNotification'; + +interface RestoreRegisteredModelModalProps { + onCancel: () => void; + onSubmit: () => void; + isOpen: boolean; + registeredModelName: string; +} + +export const RestoreRegisteredModelModal: React.FC = ({ + onCancel, + onSubmit, + isOpen, + registeredModelName, +}) => { + const notification = useNotification(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [error, setError] = React.useState(); + + const onClose = React.useCallback(() => { + onCancel(); + }, [onCancel]); + + const onConfirm = React.useCallback(async () => { + setIsSubmitting(true); + + try { + await onSubmit(); + onClose(); + notification.success(`${registeredModelName} and all its versions restored.`); + } catch (e) { + if (e instanceof Error) { + setError(e); + } + } finally { + setIsSubmitting(false); + } + }, [onSubmit, onClose, notification, registeredModelName]); + + return ( + + } + data-testid="restore-registered-model-modal" + > + {registeredModelName} and all of its versions will be restored and returned to the + registered models list. + + ); +}; diff --git a/frontend/src/pages/modelRegistry/screens/routeUtils.ts b/frontend/src/pages/modelRegistry/screens/routeUtils.ts index 6e46b46b69..269fbf0475 100644 --- a/frontend/src/pages/modelRegistry/screens/routeUtils.ts +++ b/frontend/src/pages/modelRegistry/screens/routeUtils.ts @@ -13,6 +13,17 @@ export const modelVersionUrl = ( export const modelVersionArchiveUrl = (rmId?: string, preferredModelRegistry?: string): string => `/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}/versions/archive`; +export const registeredModelArchiveUrl = (preferredModelRegistry?: string): string => + `/modelRegistry/${preferredModelRegistry}/registeredModels/archive`; + +export const registeredModelsUrl = (preferredModelRegistry?: string): string => + `/modelRegistry/${preferredModelRegistry}/registeredModels`; + +export const registeredModelArchiveDetailsUrl = ( + rmId?: string, + preferredModelRegistry?: string, +): string => `/modelRegistry/${preferredModelRegistry}/registeredModels/archive/${rmId}`; + export const modelVersionArchiveDetailsUrl = ( mvId: string, rmId?: string, diff --git a/frontend/src/pages/modelRegistry/screens/utils.ts b/frontend/src/pages/modelRegistry/screens/utils.ts index 3ba85c2032..e321cf5789 100644 --- a/frontend/src/pages/modelRegistry/screens/utils.ts +++ b/frontend/src/pages/modelRegistry/screens/utils.ts @@ -5,7 +5,7 @@ import { ModelRegistryMetadataType, ModelRegistryStringCustomProperties, ModelVersion, - ModelVersionState, + ModelState, RegisteredModel, } from '~/concepts/modelRegistry/types'; import { KeyValuePair } from '~/types'; @@ -112,13 +112,8 @@ export const filterModelVersions = ( unfilteredModelVersions: ModelVersion[], search: string, searchType: SearchType, - archived?: boolean, ): ModelVersion[] => unfilteredModelVersions.filter((mv: ModelVersion) => { - if (archived && mv.state !== ModelVersionState.ARCHIVED) { - return false; - } - if (!search) { return true; } @@ -142,8 +137,40 @@ export const filterModelVersions = ( } }); +export const filterRegisteredModels = ( + unfilteredRegisteredModels: RegisteredModel[], + search: string, + searchType: SearchType, +): RegisteredModel[] => + unfilteredRegisteredModels.filter((rm: RegisteredModel) => { + if (!search) { + return true; + } + + switch (searchType) { + case SearchType.KEYWORD: + return ( + rm.name.toLowerCase().includes(search.toLowerCase()) || + (rm.description && rm.description.toLowerCase().includes(search.toLowerCase())) + ); + + case SearchType.OWNER: + // TODO Implement owner search functionality once RHOAIENG-7566 is completed. + return; + + default: + return true; + } + }); + export const filterArchiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => - modelVersions.filter((mv) => mv.state === ModelVersionState.ARCHIVED); + modelVersions.filter((mv) => mv.state === ModelState.ARCHIVED); export const filterLiveVersions = (modelVersions: ModelVersion[]): ModelVersion[] => - modelVersions.filter((mv) => mv.state === ModelVersionState.LIVE); + modelVersions.filter((mv) => mv.state === ModelState.LIVE); + +export const filterArchiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.ARCHIVED); + +export const filterLiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => + registeredModels.filter((rm) => rm.state === ModelState.LIVE);