diff --git a/clients/ui/bff/internal/mocks/static_data_mock.go b/clients/ui/bff/internal/mocks/static_data_mock.go index a156c8d5..05474fd8 100644 --- a/clients/ui/bff/internal/mocks/static_data_mock.go +++ b/clients/ui/bff/internal/mocks/static_data_mock.go @@ -82,7 +82,20 @@ func GetModelVersionMocks() []openapi.ModelVersion { State: stateToPointer(openapi.MODELVERSIONSTATE_LIVE), } - return []openapi.ModelVersion{model1, model2} + model3 := openapi.ModelVersion{ + CustomProperties: newCustomProperties(), + Name: "Version Three", + Description: stringToPointer("This version didn't improve stuff and things"), + ExternalId: stringToPointer("934589791"), + Id: stringToPointer("3"), + CreateTimeSinceEpoch: stringToPointer("1725282249921"), + LastUpdateTimeSinceEpoch: stringToPointer("1725282249921"), + RegisteredModelId: "3", + Author: stringToPointer("Sherlock Holmes"), + State: stateToPointer(openapi.MODELVERSIONSTATE_ARCHIVED), + } + + return []openapi.ModelVersion{model1, model2, model3} } func GetModelVersionListMock() openapi.ModelVersionList { diff --git a/clients/ui/frontend/src/__mocks__/mockModelArtifact.ts b/clients/ui/frontend/src/__mocks__/mockModelArtifact.ts index 8f2bb628..8f5ac45b 100644 --- a/clients/ui/frontend/src/__mocks__/mockModelArtifact.ts +++ b/clients/ui/frontend/src/__mocks__/mockModelArtifact.ts @@ -1,34 +1,17 @@ -import { ModelArtifact, ModelArtifactState } from '~/app/types'; +import { ModelArtifact } from '~/app/types'; -type MockModelArtifact = { - id?: string; - name?: string; - uri?: string; - state?: ModelArtifactState; - author?: string; -}; - -export const mockModelArtifact = ({ - id = '1', - name = 'test', - uri = 'test', - state = ModelArtifactState.LIVE, - author = 'Author 1', -}: MockModelArtifact): ModelArtifact => ({ - id, - name, - externalID: '1234132asdfasdf', - description: '', - createTimeSinceEpoch: '1710404288975', - lastUpdateTimeSinceEpoch: '1710404288975', +export const mockModelArtifact = (partial?: Partial): ModelArtifact => ({ + createTimeSinceEpoch: '1712234877179', + id: '1', + lastUpdateTimeSinceEpoch: '1712234877179', + name: 'fraud detection model version 1', + description: 'Description of model version', + artifactType: 'model-artifact', customProperties: {}, - uri, - state, - author, - modelFormatName: 'test', - storageKey: 'test', - storagePath: 'test', - modelFormatVersion: 'test', - serviceAccountName: 'test', - artifactType: 'test', + storageKey: 'test storage key', + storagePath: 'test path', + uri: 's3://test-bucket/demo-models/test-path?endpoint=test-endpoint&defaultRegion=test-region', + modelFormatName: 'test model format', + modelFormatVersion: 'test version 1', + ...partial, }); diff --git a/clients/ui/frontend/src/__mocks__/mockModelArtifactList.ts b/clients/ui/frontend/src/__mocks__/mockModelArtifactList.ts new file mode 100644 index 00000000..8bf2ca20 --- /dev/null +++ b/clients/ui/frontend/src/__mocks__/mockModelArtifactList.ts @@ -0,0 +1,11 @@ +/* eslint-disable camelcase */ +import { ModelArtifactList } from '~/app/types'; + +export const mockModelArtifactList = ({ + items = [], +}: Partial): ModelArtifactList => ({ + items, + nextPageToken: '', + pageSize: 0, + size: 1, +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts index bffc77fe..8af39893 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistry.ts @@ -103,6 +103,10 @@ class ModelRegistry { cy.findByTestId('registered-models-table-toolbar').should('exist'); } + shouldArchiveModelVersionsEmpty() { + cy.findByTestId('empty-archive-model-versions').should('exist'); + } + tabEnabled() { appChrome.findNavItem('Model Registry').should('exist'); return this; diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts new file mode 100644 index 00000000..81afa063 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive.ts @@ -0,0 +1,128 @@ +import { TableRow } from '~/__tests__/cypress/cypress/pages/components/table'; +import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; + +class ArchiveVersionTableRow extends TableRow { + findName() { + return this.find().findByTestId('model-version-name'); + } + + findDescription() { + return this.find().findByTestId('model-version-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 RestoreVersionModal extends Modal { + constructor() { + super('Restore version?'); + } + + findRestoreButton() { + return cy.findByTestId('modal-submit-button'); + } +} + +class ArchiveVersionModal extends Modal { + constructor() { + super('Archive version?'); + } + + findArchiveButton() { + return cy.findByTestId('modal-submit-button'); + } + + findModalTextInput() { + return cy.findByTestId('confirm-archive-input'); + } +} + +class ModelVersionArchive { + private wait() { + cy.findByTestId('app-page-title').should('exist'); + cy.testA11y(); + } + + visit() { + const rmId = '1'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}/versions/archive`); + this.wait(); + } + + visitArchiveVersionDetail() { + const mvId = '2'; + const rmId = '1'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit( + `/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}/versions/archive/${mvId}`, + ); + } + + visitModelVersionList() { + const rmId = '1'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}/versions`); + this.wait(); + } + + visitModelVersionDetails() { + const mvId = '3'; + const rmId = '1'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}/versions/${mvId}`); + this.wait(); + } + + findModelVersionsTableKebab() { + return cy.findByTestId('model-versions-table-kebab-action'); + } + + shouldArchiveVersionsEmpty() { + cy.findByTestId('empty-archive-state').should('exist'); + } + + findArchiveVersionBreadcrumbItem() { + return cy.findByTestId('archive-version-page-breadcrumb'); + } + + findArchiveVersionTable() { + return cy.findByTestId('model-versions-archive-table'); + } + + findArchiveVersionsTableRows() { + return this.findArchiveVersionTable().find('tbody tr'); + } + + findRestoreButton() { + return cy.findByTestId('restore-button'); + } + + getRow(name: string) { + return new ArchiveVersionTableRow(() => + this.findArchiveVersionTable() + .find(`[data-label="Version name"]`) + .contains(name) + .parents('tr'), + ); + } + + findModelVersionsDetailsHeaderAction() { + return cy.findByTestId('model-version-details-action-button'); + } +} + +export const modelVersionArchive = new ModelVersionArchive(); +export const restoreVersionModal = new RestoreVersionModal(); +export const archiveVersionModal = new ArchiveVersionModal(); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts new file mode 100644 index 00000000..b311fa89 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails.ts @@ -0,0 +1,73 @@ +class ModelVersionDetails { + visit() { + const preferredModelRegistry = 'modelregistry-sample'; + const rmId = '1'; + const mvId = '1'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}/versions/${mvId}`); + this.wait(); + } + + private wait() { + cy.findByTestId('app-page-title').should('exist'); + cy.testA11y(); + } + + findVersionId() { + return cy.findByTestId('model-version-id'); + } + + findDescription() { + return cy.findByTestId('model-version-description'); + } + + findMoreLabelsButton() { + return cy.findByTestId('label-group').find('button'); + } + + findStorageURI() { + return cy.findByTestId('storage-uri'); + } + + findStorageEndpoint() { + return cy.findByTestId('storage-endpoint'); + } + + findStorageRegion() { + return cy.findByTestId('storage-region'); + } + + findStorageBucket() { + return cy.findByTestId('storage-bucket'); + } + + findStoragePath() { + return cy.findByTestId('storage-path'); + } + + shouldContainsModalLabels(labels: string[]) { + cy.findByTestId('label-group').within(() => labels.map((label) => cy.contains(label))); + return this; + } + + findModelVersionDropdownButton() { + return cy.findByTestId('model-version-toggle-button'); + } + + findModelVersionDropdownSearch() { + return cy.findByTestId('search-input'); + } + + findModelVersionDropdownItem(name: string) { + return cy.findByTestId('model-version-selector-list').find('li').contains(name); + } + + findDetailsTab() { + return cy.findByTestId('model-versions-details-tab'); + } + + findRegisteredDeploymentsTab() { + return cy.findByTestId('deployments-tab'); + } +} + +export const modelVersionDetails = new ModelVersionDetails(); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registeredModelArchive.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registeredModelArchive.ts new file mode 100644 index 00000000..05b1186e --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/pages/modelRegistryView/registeredModelArchive.ts @@ -0,0 +1,128 @@ +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() { + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/archive`); + this.wait(); + } + + visitArchiveModelDetail() { + const rmId = '2'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/archive/${rmId}`); + } + + visitArchiveModelVersionList() { + const rmId = '2'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/archive/${rmId}/versions`); + } + + visitModelList() { + cy.visit('/modelRegistry/modelregistry-sample'); + this.wait(); + } + + visitModelDetails() { + const rmId = '2'; + const preferredModelRegistry = 'modelregistry-sample'; + cy.visit(`/modelRegistry/${preferredModelRegistry}/registeredModels/${rmId}`); + 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'); + } + + findRegisteredModelsArchiveTableHeaderButton(name: string) { + return this.findArchiveModelTable().find('thead').findByRole('button', { name }); + } + + 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/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts index edf7c007..1b07dfb8 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/support/commands/api.ts @@ -10,6 +10,9 @@ import type { RegisteredModelList, } from '~/app/types'; +const MODEL_REGISTRY_API_VERSION = 'v1'; +export { MODEL_REGISTRY_API_VERSION }; + type SuccessErrorResponse = { success: boolean; error?: string; @@ -65,21 +68,21 @@ declare global { options: { path: { modelRegistryName: string; apiVersion: string; registeredModelId: number }; }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable) & (( type: 'GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', options: { path: { modelRegistryName: string; apiVersion: string; modelVersionId: number }; }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable) & (( type: 'GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId/artifacts', options: { path: { modelRegistryName: string; apiVersion: string; modelVersionId: number }; }, - response: ApiResponse, + response: ApiResponse>, ) => Cypress.Chainable) & (( type: 'POST /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId/artifacts', diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts index 91592ae1..dc9940b2 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelRegistry/modelRegistry.cy.ts @@ -8,8 +8,7 @@ import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/mod import { mockBFFResponse } from '~/__mocks__/mockBFFResponse'; import type { ModelRegistry, ModelVersion, RegisteredModel } from '~/app/types'; import { be } from '~/__tests__/cypress/cypress/utils/should'; - -const MODEL_REGISTRY_API_VERSION = 'v1'; +import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; type HandlersProps = { modelRegistries?: ModelRegistry[]; diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts new file mode 100644 index 00000000..a1a3cef4 --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionArchive.cy.ts @@ -0,0 +1,303 @@ +/* eslint-disable camelcase */ +import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; +import { labelModal } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import type { ModelRegistry, ModelVersion } from '~/app/types'; +import { ModelState } from '~/app/types'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { mockBFFResponse } from '~/__mocks__/utils'; +import { modelVersionArchive } from '~/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionArchive'; +import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; + +type HandlersProps = { + registeredModelsSize?: number; + modelVersions?: ModelVersion[]; + modelRegistries?: ModelRegistry[]; +}; + +const initIntercepts = ({ + registeredModelsSize = 4, + modelVersions = [ + mockModelVersion({ + name: 'model version 1', + author: 'Author 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, + }), + mockModelVersion({ id: '2', name: 'model version 2', state: ModelState.ARCHIVED }), + mockModelVersion({ id: '3', name: 'model version 3' }), + ], + modelRegistries = [ + mockModelRegistry({ + name: 'modelregistry-sample', + description: 'New model registry', + displayName: 'Model Registry Sample', + }), + mockModelRegistry({ + name: 'modelregistry-sample-2', + description: 'New model registry 2', + displayName: 'Model Registry Sample 2', + }), + ], +}: HandlersProps) => { + cy.interceptApi( + `GET /api/:apiVersion/model_registry`, + { + path: { apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockBFFResponse(modelRegistries), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models`, + { + path: { modelRegistryName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockBFFResponse(mockRegisteredModelList({ size: registeredModelsSize })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockBFFResponse( + mockModelVersionList({ + items: modelVersions, + }), + ), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockBFFResponse(mockRegisteredModel({ name: 'test-1' })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockBFFResponse( + mockModelVersion({ id: '2', name: 'model version 2', state: ModelState.ARCHIVED }), + ), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 3, + }, + }, + mockBFFResponse(mockModelVersion({ id: '3', name: 'model version 3', state: ModelState.LIVE })), + ); +}; + +describe('Model version archive list', () => { + it('No archive versions in the selected registered model', () => { + initIntercepts({ modelVersions: [mockModelVersion({ id: '3', name: 'model version 2' })] }); + modelVersionArchive.visitModelVersionList(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions'); + // TODO: Uncomment when dropdowns are fixed and remove the visit after the comments + // modelVersionArchive + // .findModelVersionsTableKebab() + // .findDropdownItem('View archived versions') + // .click(); + modelVersionArchive.visit(); + modelVersionArchive.shouldArchiveVersionsEmpty(); + }); + + it('Archived version details browser back button should lead to archived versions table', () => { + initIntercepts({}); + modelVersionArchive.visit(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions/archive'); + modelVersionArchive.findArchiveVersionBreadcrumbItem().contains('Archived version'); + const archiveVersionRow = modelVersionArchive.getRow('model version 2'); + archiveVersionRow.findName().contains('model version 2').click(); + verifyRelativeURL( + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/archive/2/details', + ); + cy.go('back'); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions/archive'); + modelVersionArchive.findArchiveVersionBreadcrumbItem().contains('Archived version'); + archiveVersionRow.findName().contains('model version 2').should('exist'); + }); + + it('Archive version list', () => { + initIntercepts({}); + modelVersionArchive.visit(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions/archive'); + + //breadcrumb + modelVersionArchive.findArchiveVersionBreadcrumbItem().contains('Archived version'); + + // name, last modified, owner, labels modal + modelVersionArchive.findArchiveVersionTable().should('be.visible'); + modelVersionArchive.findArchiveVersionsTableRows().should('have.length', 2); + + const archiveVersionRow = modelVersionArchive.getRow('model version 1'); + + archiveVersionRow.findLabelModalText().contains('5 more'); + archiveVersionRow.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(); + }); +}); + +// TODO: Uncomment when we have restoring and archiving mocked +// describe('Restoring archive version', () => { +// it('Restore from archive table', () => { +// cy.interceptApi( +// 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', +// { +// path: { +// modelRegistryName: 'modelregistry-sample', +// apiVersion: MODEL_REGISTRY_API_VERSION, +// modelVersionId: 2, +// }, +// }, +// mockModelVersion({}), +// ).as('versionRestored'); + +// initIntercepts({}); +// modelVersionArchive.visit(); + +// const archiveVersionRow = modelVersionArchive.getRow('model version 2'); +// archiveVersionRow.findKebabAction('Restore version').click(); + +// restoreVersionModal.findRestoreButton().click(); + +// cy.wait('@versionRestored').then((interception) => { +// expect(interception.request.body).to.eql({ +// state: 'LIVE', +// }); +// }); +// }); + +// it('Restore from archive version details', () => { +// cy.interceptApi( +// 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId', +// { +// path: { +// modelRegistryName: 'modelregistry-sample', +// apiVersion: MODEL_REGISTRY_API_VERSION, +// modelVersionId: 2, +// }, +// }, +// mockModelVersion({}), +// ).as('versionRestored'); + +// initIntercepts({}); +// modelVersionArchive.visitArchiveVersionDetail(); + +// modelVersionArchive.findRestoreButton().click(); +// restoreVersionModal.findRestoreButton().click(); + +// cy.wait('@versionRestored').then((interception) => { +// expect(interception.request.body).to.eql({ +// state: 'LIVE', +// }); +// }); +// }); +// }); + +// describe('Archiving version', () => { +// it('Archive version from versions table', () => { +// cy.interceptApi( +// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId', +// { +// path: { +// serviceName: 'modelregistry-sample', +// apiVersion: MODEL_REGISTRY_API_VERSION, +// modelVersionId: 3, +// }, +// }, +// mockModelVersion({}), +// ).as('versionArchived'); + +// initIntercepts({}); +// modelVersionArchive.visitModelVersionList(); + +// const modelVersionRow = modelRegistry.getModelVersionRow('model version 3'); +// modelVersionRow.findKebabAction('Archive model version').click(); +// archiveVersionModal.findArchiveButton().should('be.disabled'); +// archiveVersionModal.findModalTextInput().fill('model version 3'); +// archiveVersionModal.findArchiveButton().should('be.enabled').click(); +// cy.wait('@versionArchived').then((interception) => { +// expect(interception.request.body).to.eql({ +// state: 'ARCHIVED', +// }); +// }); +// }); + +// it('Archive version from versions details', () => { +// cy.interceptApi( +// 'PATCH /api/service/modelregistry/:serviceName/api/model_registry/:apiVersion/model_versions/:modelVersionId', +// { +// path: { +// serviceName: 'modelregistry-sample', +// apiVersion: MODEL_REGISTRY_API_VERSION, +// modelVersionId: 3, +// }, +// }, +// mockModelVersion({}), +// ).as('versionArchived'); + +// initIntercepts({}); +// modelVersionArchive.visitModelVersionDetails(); +// modelVersionArchive +// .findModelVersionsDetailsHeaderAction() +// .findDropdownItem('Archive version') +// .click(); + +// archiveVersionModal.findArchiveButton().should('be.disabled'); +// archiveVersionModal.findModalTextInput().fill('model version 3'); +// archiveVersionModal.findArchiveButton().should('be.enabled').click(); +// cy.wait('@versionArchived').then((interception) => { +// expect(interception.request.body).to.eql({ +// state: 'ARCHIVED', +// }); +// }); +// }); +// }); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionDetails.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionDetails.cy.ts new file mode 100644 index 00000000..3bcf9ece --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersionDetails.cy.ts @@ -0,0 +1,185 @@ +/* eslint-disable camelcase */ +import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { mockBFFResponse } from '~/__mocks__/utils'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockModelArtifactList } from '~/__mocks__/mockModelArtifactList'; +import { mockModelArtifact } from '~/__mocks__/mockModelArtifact'; +import type { ModelRegistry } from '~/app/types'; +import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; +import { modelVersionDetails } from '~/__tests__/cypress/cypress/pages/modelRegistryView/modelVersionDetails'; + +type HandlersProps = { + modelRegistries?: ModelRegistry[]; +}; + +const initIntercepts = ({ + modelRegistries = [ + mockModelRegistry({ + name: 'modelregistry-sample', + description: 'New model registry', + displayName: 'Model Registry Sample', + }), + mockModelRegistry({ + name: 'modelregistry-sample-2', + description: 'New model registry 2', + displayName: 'Model Registry Sample 2', + }), + ], +}: HandlersProps) => { + cy.interceptApi( + `GET /api/:apiVersion/model_registry`, + { + path: { apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockBFFResponse(modelRegistries), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockBFFResponse(mockRegisteredModel({})), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 1, + }, + }, + mockBFFResponse( + mockModelVersionList({ + items: [ + mockModelVersion({ name: 'Version 1', author: 'Author 1', registeredModelId: '1' }), + mockModelVersion({ + author: 'Author 2', + registeredModelId: '1', + id: '2', + name: 'Version 2', + }), + ], + }), + ), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockBFFResponse( + mockModelVersion({ + id: '1', + name: 'Version 1', + labels: [ + 'Testing label', + 'Financial data', + 'Fraud detection', + 'Long label data to be truncated abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc abc', + 'Machine learning', + 'Next data to be overflow', + 'Label x', + 'Label y', + 'Label z', + ], + }), + ), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 2, + }, + }, + mockBFFResponse(mockModelVersion({ id: '2', name: 'Version 2' })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId/artifacts`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockBFFResponse( + mockModelArtifactList({ + items: [ + mockModelArtifact({}), + mockModelArtifact({ + author: 'Author 2', + id: '2', + name: 'Artifact 2', + }), + ], + }), + ), + ); +}; + +describe('Model version details', () => { + describe('Details tab', () => { + beforeEach(() => { + initIntercepts({}); + modelVersionDetails.visit(); + }); + + it('Model version details page header', () => { + verifyRelativeURL( + '/modelRegistry/modelregistry-sample/registeredModels/1/versions/1/details', + ); + cy.findByTestId('app-page-title').should('have.text', 'Version 1'); + cy.findByTestId('breadcrumb-version-name').should('have.text', 'Version 1'); + }); + + it('Model version details tab', () => { + modelVersionDetails.findVersionId().contains('1'); + modelVersionDetails.findDescription().should('have.text', 'Description of model version'); + modelVersionDetails.findMoreLabelsButton().contains('6 more'); + modelVersionDetails.findMoreLabelsButton().click(); + modelVersionDetails.shouldContainsModalLabels([ + 'Testing label', + 'Financial', + 'Financial data', + 'Fraud detection', + 'Machine learning', + 'Next data to be overflow', + 'Label x', + 'Label y', + 'Label z', + ]); + modelVersionDetails.findStorageEndpoint().contains('test-endpoint'); + modelVersionDetails.findStorageRegion().contains('test-region'); + modelVersionDetails.findStorageBucket().contains('test-bucket'); + modelVersionDetails.findStoragePath().contains('demo-models/test-path'); + }); + + it('Switching model versions', () => { + modelVersionDetails.findVersionId().contains('1'); + modelVersionDetails.findModelVersionDropdownButton().click(); + modelVersionDetails.findModelVersionDropdownSearch().fill('Version 2'); + modelVersionDetails.findModelVersionDropdownItem('Version 2').click(); + modelVersionDetails.findVersionId().contains('2'); + }); + }); +}); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts index 31a7ab37..4fca0caa 100644 --- a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/modelVersions.cy.ts @@ -9,8 +9,7 @@ import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; import { mockModelVersion } from '~/__mocks__/mockModelVersion'; import { mockBFFResponse } from '~/__mocks__/utils'; - -const MODEL_REGISTRY_API_VERSION = 'v1'; +import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; type HandlersProps = { registeredModelsSize?: number; @@ -99,7 +98,7 @@ const initIntercepts = ({ modelVersionId: 1, }, }, - mockModelVersion({ id: '1', name: 'model version' }), + mockBFFResponse(mockModelVersion({ id: '1', name: 'model version' })), ); }; @@ -209,14 +208,12 @@ describe('Model Versions', () => { modelRegistry.visit(); const registeredModelRow = modelRegistry.getRow('Fraud detection model'); registeredModelRow.findName().contains('Fraud detection model').click(); - verifyRelativeURL(`/modelRegistry/modelregistry-sample/registeredModels/1/versions`); - // TODO: Uncomment when we have model version details - // const modelVersionRow = modelRegistry.getModelVersionRow('model version'); - // modelVersionRow.findModelVersionName().contains('model version').click(); - // verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions/1/details'); - // cy.findByTestId('app-page-title').should('have.text', 'model version'); - // cy.findByTestId('breadcrumb-version-name').should('have.text', 'model version'); - // cy.go('back'); - // verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions'); + const modelVersionRow = modelRegistry.getModelVersionRow('model version'); + modelVersionRow.findModelVersionName().contains('model version').click(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions/1/details'); + cy.findByTestId('app-page-title').should('have.text', 'model version'); + cy.findByTestId('breadcrumb-version-name').should('have.text', 'model version'); + cy.go('back'); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/1/versions'); }); }); diff --git a/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts new file mode 100644 index 00000000..5abd877d --- /dev/null +++ b/clients/ui/frontend/src/__tests__/cypress/cypress/tests/mocked/registeredModelArchive.cy.ts @@ -0,0 +1,355 @@ +/* eslint-disable camelcase */ +import { mockRegisteredModelList } from '~/__mocks__/mockRegisteredModelsList'; +import { mockModelVersion } from '~/__mocks__/mockModelVersion'; +import { mockRegisteredModel } from '~/__mocks__/mockRegisteredModel'; +import { verifyRelativeURL } from '~/__tests__/cypress/cypress/utils/url'; +import { labelModal, modelRegistry } from '~/__tests__/cypress/cypress/pages/modelRegistry'; +import { mockModelVersionList } from '~/__mocks__/mockModelVersionList'; +import { be } from '~/__tests__/cypress/cypress/utils/should'; +import type { ModelRegistry, ModelVersion, RegisteredModel } from '~/app/types'; +import { ModelState } from '~/app/types'; +import { mockBFFResponse } from '~/__mocks__/utils'; +import { mockModelRegistry } from '~/__mocks__/mockModelRegistry'; +import { MODEL_REGISTRY_API_VERSION } from '~/__tests__/cypress/cypress/support/commands/api'; +import { registeredModelArchive } from '~/__tests__/cypress/cypress/pages/modelRegistryView/registeredModelArchive'; + +type HandlersProps = { + registeredModels?: RegisteredModel[]; + modelVersions?: ModelVersion[]; + modelRegistries?: ModelRegistry[]; +}; + +const initIntercepts = ({ + 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', registeredModelId: '2' }), + mockModelVersion({ name: 'model version' }), + ], + modelRegistries = [ + mockModelRegistry({ + name: 'modelregistry-sample', + description: 'New model registry', + displayName: 'Model Registry Sample', + }), + mockModelRegistry({ + name: 'modelregistry-sample-2', + description: 'New model registry 2', + displayName: 'Model Registry Sample 2', + }), + ], +}: HandlersProps) => { + cy.interceptApi( + `GET /api/:apiVersion/model_registry`, + { + path: { apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockBFFResponse(modelRegistries), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models`, + { + path: { modelRegistryName: 'modelregistry-sample', apiVersion: MODEL_REGISTRY_API_VERSION }, + }, + mockBFFResponse(mockRegisteredModelList({ items: registeredModels })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/model_versions/:modelVersionId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + modelVersionId: 1, + }, + }, + mockBFFResponse(mockModelVersion({ id: '1', name: 'Version 2' })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId/versions`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockBFFResponse(mockModelVersionList({ items: modelVersions })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 2, + }, + }, + mockBFFResponse(mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.ARCHIVED })), + ); + + cy.interceptApi( + `GET /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId`, + { + path: { + modelRegistryName: 'modelregistry-sample', + apiVersion: MODEL_REGISTRY_API_VERSION, + registeredModelId: 3, + }, + }, + mockBFFResponse(mockRegisteredModel({ id: '3', name: 'model 3' })), + ); +}; + +describe('Model archive list', () => { + it('No archive models in the selected model registry', () => { + initIntercepts({ + registeredModels: [], + }); + registeredModelArchive.visit(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive'); + registeredModelArchive.shouldArchiveVersionsEmpty(); + }); + + it('Archived model details browser back button should lead to archived models table', () => { + initIntercepts({}); + registeredModelArchive.visit(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive'); + registeredModelArchive.findArchiveModelBreadcrumbItem().contains('Archived models'); + const archiveModelRow = registeredModelArchive.getRow('model 2'); + archiveModelRow.findName().contains('model 2').click(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive/2/versions'); + cy.findByTestId('app-page-title').should('have.text', 'model 2Archived'); + cy.go('back'); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive'); + registeredModelArchive.findArchiveModelTable().should('be.visible'); + }); + + it('Archived model with no versions', () => { + initIntercepts({ modelVersions: [] }); + registeredModelArchive.visit(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive'); + registeredModelArchive.findArchiveModelBreadcrumbItem().contains('Archived models'); + const archiveModelRow = registeredModelArchive.getRow('model 2'); + archiveModelRow.findName().contains('model 2').click(); + modelRegistry.shouldArchiveModelVersionsEmpty(); + }); + + it('Archived model flow', () => { + initIntercepts({}); + registeredModelArchive.visitArchiveModelVersionList(); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive/2/versions'); + + modelRegistry.findModelVersionsTable().should('be.visible'); + modelRegistry.findModelVersionsTableRows().should('have.length', 2); + const version = modelRegistry.getModelVersionRow('model version'); + version.findModelVersionName().contains('model version').click(); + verifyRelativeURL( + '/modelRegistry/modelregistry-sample/registeredModels/archive/2/versions/1/details', + ); + cy.go('back'); + verifyRelativeURL('/modelRegistry/modelregistry-sample/registeredModels/archive/2/versions'); + }); + + 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(); + + // sort by Last modified + registeredModelArchive + .findRegisteredModelsArchiveTableHeaderButton('Last modified') + .should(be.sortAscending); + registeredModelArchive.findRegisteredModelsArchiveTableHeaderButton('Last modified').click(); + registeredModelArchive + .findRegisteredModelsArchiveTableHeaderButton('Last modified') + .should(be.sortDescending); + + // sort by Model name + registeredModelArchive.findRegisteredModelsArchiveTableHeaderButton('Model name').click(); + registeredModelArchive + .findRegisteredModelsArchiveTableHeaderButton('Model name') + .should(be.sortAscending); + registeredModelArchive.findRegisteredModelsArchiveTableHeaderButton('Model name').click(); + registeredModelArchive + .findRegisteredModelsArchiveTableHeaderButton('Model name') + .should(be.sortDescending); + }); +}); + +// TODO: Uncomment when dropdowns are fixed +// it('Opens the detail page when we select "View Details" from action menu', () => { +// initIntercepts({}); +// registeredModelArchive.visit(); +// const archiveModelRow = registeredModelArchive.getRow('model 2'); +// archiveModelRow.findKebabAction('View details').click(); +// cy.location('pathname').should( +// 'be.equals', +// '/modelRegistry/modelregistry-sample/registeredModels/archive/2/details', +// ); +// }); + +// TODO: Uncomment when we have mock data for restoring and archiving +// describe('Restoring archive model', () => { +// it('Restore from archive models table', () => { +// cy.interceptApi( +// 'PATCH /api/:apiVersion/model_registry/:modelRegistryName/registered_models/:registeredModelId', +// { +// path: { +// modelRegistryName: 'modelregistry-sample', +// apiVersion: MODEL_REGISTRY_API_VERSION, +// registeredModelId: 2, +// }, +// }, +// mockBFFResponse(mockRegisteredModel({ id: '2', name: 'model 2', state: ModelState.LIVE })), +// ).as('modelRestored'); + +// initIntercepts({}); +// registeredModelArchive.visit(); + +// 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({ +// state: 'LIVE', +// }); +// }); +// }); + +// it('Restore from archive model details', () => { +// cy.interceptApi( +// '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({ +// state: 'LIVE', +// }); +// }); +// }); +// }); + +// describe('Archiving model', () => { +// it('Archive model from registered models table', () => { +// cy.interceptApi( +// '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({ +// state: 'ARCHIVED', +// }); +// }); +// }); + +// it('Archive model from model details', () => { +// cy.interceptApi( +// '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.findName().contains('model 3').click(); +// registeredModelArchive +// .findModelVersionsDetailsHeaderAction() +// .findDropdownItem('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({ +// state: 'ARCHIVED', +// }); +// }); +// }); +//}); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx index c7b78d10..c64a58c7 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/ModelRegistryRoutes.tsx @@ -6,6 +6,12 @@ import { modelRegistryUrl } from './screens/routeUtils'; import RegisteredModelsArchive from './screens/RegisteredModelsArchive/RegisteredModelsArchive'; import { ModelVersionsTab } from './screens/ModelVersions/const'; import ModelVersions from './screens/ModelVersions/ModelVersions'; +import { ModelVersionDetailsTab } from './screens/ModelVersionDetails/const'; +import ModelVersionsDetails from './screens/ModelVersionDetails/ModelVersionDetails'; +import ModelVersionsArchive from './screens/ModelVersionsArchive/ModelVersionsArchive'; +import ModelVersionsArchiveDetails from './screens/ModelVersionsArchive/ModelVersionArchiveDetails'; +import ArchiveModelVersionDetails from './screens/ModelVersionsArchive/ArchiveModelVersionDetails'; +import RegisteredModelsArchiveDetails from './screens/RegisteredModelsArchive/RegisteredModelArchiveDetails'; const ModelRegistryRoutes: React.FC = () => ( @@ -28,10 +34,58 @@ const ModelRegistryRoutes: React.FC = () => ( path={ModelVersionsTab.DETAILS} element={} /> + + } /> + } + /> + } /> + + + } /> + + } /> + + } + /> + } /> + + } /> + } /> } /> + + } /> + + } + /> + + } + /> + + } /> + + } + /> + } /> + + } /> + } /> } /> diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx index aeb98464..8416f393 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup.tsx @@ -11,11 +11,13 @@ import ModelPropertiesTableRow from '~/app/pages/modelRegistry/screens/ModelProp type ModelPropertiesDescriptionListGroupProps = { customProperties: ModelRegistryCustomProperties; + isArchive?: boolean; saveEditedCustomProperties: (properties: ModelRegistryCustomProperties) => Promise; }; const ModelPropertiesDescriptionListGroup: React.FC = ({ customProperties = {}, + isArchive, saveEditedCustomProperties, }) => { const [editingPropertyKeys, setEditingPropertyKeys] = React.useState([]); @@ -51,16 +53,18 @@ const ModelPropertiesDescriptionListGroup: React.FC} - iconPosition="start" - isDisabled={isAdding || isSavingEdits} - onClick={() => setIsAdding(true)} - > - Add property - + !isArchive && ( + + ) } isEmpty={!isAdding && keys.length === 0} contentWhenEmpty="No properties" @@ -70,13 +74,14 @@ const ModelPropertiesDescriptionListGroup: React.FC Key {isEditingSomeRow && requiredAsterisk} Value {isEditingSomeRow && requiredAsterisk} - + {shownKeys.map((key) => ( void; isSavingEdits: boolean; + isArchive?: boolean; setIsSavingEdits: (isSaving: boolean) => void; saveEditedProperty: (oldKey: string, newPair: KeyValuePair) => Promise; } & EitherNotBoth< @@ -38,6 +39,7 @@ const ModelPropertiesTableRow: React.FC = ({ setIsEditing, isSavingEdits, setIsSavingEdits, + isArchive, saveEditedProperty, }) => { const { key, value } = keyValuePair; @@ -143,43 +145,45 @@ const ModelPropertiesTableRow: React.FC = ({ )} - - {isEditing ? ( - - - - - - - - - ) : ( - - )} - + {!isArchive && ( + + {isEditing ? ( + + + + + + + + + ) : ( + + )} + + )} ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx new file mode 100644 index 00000000..fe60a7b6 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetails.tsx @@ -0,0 +1,108 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { Breadcrumb, BreadcrumbItem, Flex, FlexItem, Truncate } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; +import useModelVersionById from '~/app/hooks/useModelVersionById'; +import { ModelState } from '~/app/types'; +import { + archiveModelVersionDetailsUrl, + modelVersionArchiveDetailsUrl, + modelVersionUrl, + registeredModelUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; +import { ModelVersionDetailsTab } from './const'; +import ModelVersionSelector from './ModelVersionSelector'; +import ModelVersionDetailsTabs from './ModelVersionDetailsTabs'; +import ModelVersionsDetailsHeaderActions from './ModelVersionDetailsHeaderActions'; + +type ModelVersionsDetailProps = { + tab: ModelVersionDetailsTab; +} & Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'description' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const ModelVersionsDetails: React.FC = ({ tab, ...pageProps }) => { + const navigate = useNavigate(); + + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + + const { modelVersionId: mvId, registeredModelId: rmId } = useParams(); + const [rm] = useRegisteredModelById(rmId); + const [mv, mvLoaded, mvLoadError, refreshModelVersion] = useModelVersionById(mvId); + + const refresh = React.useCallback(() => { + refreshModelVersion(); + }, [refreshModelVersion]); + + useEffect(() => { + if (rm?.state === ModelState.ARCHIVED && mv?.id) { + navigate( + archiveModelVersionDetailsUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name), + ); + } else if (mv?.state === ModelState.ARCHIVED) { + navigate( + modelVersionArchiveDetailsUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name), + ); + } + }, [rm?.state, mv?.id, mv?.state, mv?.registeredModelId, preferredModelRegistry?.name, navigate]); + + return ( + + ( + Model registry - {preferredModelRegistry?.name} + )} + /> + ( + + {rm?.name || 'Loading...'} + + )} + /> + + {mv?.name || 'Loading...'} + + + } + title={mv?.name} + headerAction={ + mvLoaded && + mv && ( + + + + navigate(modelVersionUrl(modelVersionId, rmId, preferredModelRegistry?.name)) + } + /> + + + + + + ) + } + description={} + loadError={mvLoadError} + loaded={mvLoaded} + provideChildrenPadding + > + {mv !== null && } + + ); +}; + +export default ModelVersionsDetails; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx new file mode 100644 index 00000000..e969c36f --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsHeaderActions.tsx @@ -0,0 +1,86 @@ +import * as React from 'react'; +import { Dropdown, DropdownList, MenuToggle, DropdownItem } from '@patternfly/react-core'; +import { useNavigate } from 'react-router'; +import { ModelState, ModelVersion } from '~/app/types'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import { ArchiveModelVersionModal } from '~/app/pages/modelRegistry/screens/components/ArchiveModelVersionModal'; +import { modelVersionArchiveDetailsUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; + +interface ModelVersionsDetailsHeaderActionsProps { + mv: ModelVersion; + refresh: () => void; +} + +const ModelVersionsDetailsHeaderActions: React.FC = ({ + mv, +}) => { + const { apiState } = React.useContext(ModelRegistryContext); + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + + const navigate = useNavigate(); + const [isOpenActionDropdown, setOpenActionDropdown] = React.useState(false); + const [isArchiveModalOpen, setIsArchiveModalOpen] = React.useState(false); + const tooltipRef = React.useRef(null); + + return ( + <> + setOpenActionDropdown(false)} + onOpenChange={(open) => setOpenActionDropdown(open)} + popperProps={{ position: 'right' }} + toggle={(toggleRef) => ( + setOpenActionDropdown(!isOpenActionDropdown)} + isExpanded={isOpenActionDropdown} + aria-label="Model version details action toggle" + data-testid="model-version-details-action-button" + > + Actions + + )} + > + + setIsArchiveModalOpen(true)} + ref={tooltipRef} + > + Archive version + + + + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchModelVersion( + {}, + { + state: ModelState.ARCHIVED, + }, + mv.id, + ) + .then(() => + navigate( + modelVersionArchiveDetailsUrl( + mv.id, + mv.registeredModelId, + preferredModelRegistry?.name, + ), + ), + ) + } + isOpen={isArchiveModalOpen} + modelVersionName={mv.name} + /> + + ); +}; + +export default ModelVersionsDetailsHeaderActions; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx new file mode 100644 index 00000000..2747ce08 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs.tsx @@ -0,0 +1,52 @@ +import * as React from 'react'; +import { useNavigate } from 'react-router-dom'; +import { PageSection, Tab, Tabs, TabTitleText } from '@patternfly/react-core'; +import { ModelVersion } from '~/app/types'; +import { ModelVersionDetailsTabTitle, ModelVersionDetailsTab } from './const'; +import ModelVersionDetailsView from './ModelVersionDetailsView'; + +type ModelVersionDetailTabsProps = { + tab: ModelVersionDetailsTab; + modelVersion: ModelVersion; + isArchiveVersion?: boolean; + refresh: () => void; +}; + +const ModelVersionDetailsTabs: React.FC = ({ + tab, + modelVersion: mv, + isArchiveVersion, + refresh, +}) => { + const navigate = useNavigate(); + return ( + navigate(`../${eventKey}`, { relative: 'path' })} + > + {ModelVersionDetailsTabTitle.DETAILS}} + aria-label="Model versions details tab" + data-testid="model-versions-details-tab" + > + + + + + + ); +}; + +export default ModelVersionDetailsTabs; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsView.tsx new file mode 100644 index 00000000..c541ea4f --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsView.tsx @@ -0,0 +1,193 @@ +import * as React from 'react'; +import { DescriptionList, Flex, FlexItem, ContentVariants, Title } from '@patternfly/react-core'; +import DashboardDescriptionListGroup from '~/components/DashboardDescriptionListGroup'; +import EditableTextDescriptionListGroup from '~/components/EditableTextDescriptionListGroup'; +import EditableLabelsDescriptionListGroup from '~/components/EditableLabelsDescriptionListGroup'; +import { ModelVersion } from '~/app/types'; +import useModelArtifactsByVersionId from '~/app/hooks/useModelArtifactsByVersionId'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import InlineTruncatedClipboardCopy from '~/components/InlineTruncatedClipboardCopy'; +import DashboardHelpTooltip from '~/components/DashboardHelpTooltip'; +import { + getLabels, + mergeUpdatedLabels, + uriToObjectStorageFields, +} from '~/app/pages/modelRegistry/screens/utils'; +import ModelPropertiesDescriptionListGroup from '~/app/pages/modelRegistry/screens/ModelPropertiesDescriptionListGroup'; +import ModelTimestamp from '~/app/pages/modelRegistry/screens/components/ModelTimestamp'; + +type ModelVersionDetailsViewProps = { + modelVersion: ModelVersion; + isArchiveVersion?: boolean; + refresh: () => void; +}; + +const ModelVersionDetailsView: React.FC = ({ + modelVersion: mv, + isArchiveVersion, + refresh, +}) => { + const [modelArtifact] = useModelArtifactsByVersionId(mv.id); + const { apiState } = React.useContext(ModelRegistryContext); + const storageFields = uriToObjectStorageFields(modelArtifact.items[0]?.uri || ''); + + return ( + + + + + apiState.api + .patchModelVersion( + {}, + { + description: value, + }, + mv.id, + ) + .then(refresh) + } + /> + + apiState.api + .patchModelVersion( + {}, + { + customProperties: mergeUpdatedLabels(mv.customProperties, editedLabels), + }, + mv.id, + ) + .then(refresh) + } + /> + + apiState.api + .patchModelVersion({}, { customProperties: editedProperties }, mv.id) + .then(refresh) + } + /> + + + + + + + + + + Model location + + + {storageFields && ( + <> + + + + + + + + + + + + + + )} + {!storageFields && ( + <> + + + + + )} + + {modelArtifact.items[0]?.modelFormatName} + + + } + > + {mv.author} + + + + + + + + + + + ); +}; +export default ModelVersionDetailsView; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx new file mode 100644 index 00000000..119b9a84 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionSelector.tsx @@ -0,0 +1,111 @@ +import * as React from 'react'; +import { + HelperText, + HelperTextItem, + Menu, + MenuContainer, + MenuContent, + MenuItem, + MenuList, + MenuSearch, + MenuSearchInput, + MenuToggle, + SearchInput, +} from '@patternfly/react-core'; +import { ModelVersion } from '~/app/types'; +import useModelVersionsByRegisteredModel from '~/app/hooks/useModelVersionsByRegisteredModel'; + +type ModelVersionSelectorProps = { + rmId?: string; + selection: ModelVersion; + onSelect: (versionId: string) => void; +}; + +const ModelVersionSelector: React.FC = ({ + rmId, + selection, + onSelect, +}) => { + const [isOpen, setOpen] = React.useState(false); + const [input, setInput] = React.useState(''); + + const toggleRef = React.useRef(null); + const menuRef = React.useRef(null); + + const [modelVersions] = useModelVersionsByRegisteredModel(rmId); + + const menuListItems = modelVersions.items + .filter((item) => !input || item.name.toLowerCase().includes(input.toString().toLowerCase())) + .map((mv, index) => ( + + {mv.name} + + )); + + if (input && modelVersions.size === 0) { + menuListItems.push( + + No results found + , + ); + } + + const menu = ( + { + if (typeof itemId === 'string') { + onSelect(itemId); + setOpen(false); + } + }} + data-id="model-version-selector-menu" + ref={menuRef} + isScrollable + activeItemId={selection.id} + > + + + + setInput(value)} + /> + + + + {`Type a name to search your ${modelVersions.size} versions.`} + + + + {menuListItems} + + + ); + + return ( + setOpen(!isOpen)} + isExpanded={isOpen} + isFullWidth + data-testid="model-version-toggle-button" + > + {selection.name} + + } + menu={menu} + menuRef={menuRef} + popperProps={{ maxWidth: 'trigger' }} + onOpenChange={(open) => setOpen(open)} + /> + ); +}; + +export default ModelVersionSelector; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts new file mode 100644 index 00000000..ded505d1 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionDetails/const.ts @@ -0,0 +1,7 @@ +export enum ModelVersionDetailsTab { + DETAILS = 'details', +} + +export enum ModelVersionDetailsTabTitle { + DETAILS = 'Details', +} diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx index ec145465..f0360589 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelDetailsView.tsx @@ -12,9 +12,14 @@ import ModelTimestamp from '~/app/pages/modelRegistry/screens/components/ModelTi type ModelDetailsViewProps = { registeredModel: RegisteredModel; refresh: () => void; + isArchiveModel?: boolean; }; -const ModelDetailsView: React.FC = ({ registeredModel: rm, refresh }) => { +const ModelDetailsView: React.FC = ({ + registeredModel: rm, + refresh, + isArchiveModel, +}) => { const { apiState } = React.useContext(ModelRegistryContext); return ( = ({ registeredModel: rm @@ -42,6 +48,7 @@ const ModelDetailsView: React.FC = ({ registeredModel: rm /> apiState.api @@ -56,6 +63,7 @@ const ModelDetailsView: React.FC = ({ registeredModel: rm } /> apiState.api diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx index 0f9db46c..4373e935 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionListView.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { + Alert, Button, Dropdown, DropdownItem, @@ -35,12 +36,14 @@ import SimpleSelect from '~/app/components/SimpleSelect'; type ModelVersionListViewProps = { modelVersions: ModelVersion[]; registeredModel?: RegisteredModel; + isArchiveModel?: boolean; refresh: () => void; }; const ModelVersionListView: React.FC = ({ modelVersions: unfilteredModelVersions, registeredModel: rm, + isArchiveModel, refresh, }) => { const navigate = useNavigate(); @@ -55,8 +58,24 @@ const ModelVersionListView: React.FC = ({ React.useState(false); const filteredModelVersions = filterModelVersions(unfilteredModelVersions, search, searchType); + const date = rm?.lastUpdateTimeSinceEpoch && new Date(parseInt(rm.lastUpdateTimeSinceEpoch)); if (unfilteredModelVersions.length === 0) { + if (isArchiveModel) { + return ( + ( + missing version + )} + description={`${rm?.name} has no registered versions.`} + /> + ); + } return ( = ({ } return ( - setSearch('')} - modelVersions={sortModelVersionsByCreateTime(filteredModelVersions)} - toolbarContent={ - - } breakpoint="xl"> - - setSearch('')} - deleteLabelGroup={() => setSearch('')} - categoryName={searchType} - > - ({ - key, - label: key, - }))} - value={searchType} - onChange={(newSearchType) => { - const enumMember = asEnumMember(newSearchType, SearchType); - if (enumMember !== null) { - setSearchType(enumMember); - } - }} - icon={} - /> - - - { - setSearch(searchValue); - }} - onClear={() => setSearch('')} - style={{ minWidth: '200px' }} - data-testid="model-versions-table-search" - /> - - - - - - - - setIsArchivedModelVersionKebabOpen(false)} - onOpenChange={(isOpen: boolean) => setIsArchivedModelVersionKebabOpen(isOpen)} - toggle={(tr: React.Ref) => ( - - setIsArchivedModelVersionKebabOpen(!isArchivedModelVersionKebabOpen) - } - isExpanded={isArchivedModelVersionKebabOpen} - aria-label="View archived versions" + <> + {isArchiveModel && ( + + )} + setSearch('')} + modelVersions={sortModelVersionsByCreateTime(filteredModelVersions)} + toolbarContent={ + + } breakpoint="xl"> + + setSearch('')} + deleteLabelGroup={() => setSearch('')} + categoryName={searchType} > - - - )} - shouldFocusToggleOnSelect - > - - - navigate(modelVersionArchiveUrl(rm?.id, preferredModelRegistry?.name)) - } - > - View archived versions - - - - - - } - /> + ({ + key, + label: key, + }))} + value={searchType} + onChange={(newSearchType) => { + const enumMember = asEnumMember(newSearchType, SearchType); + if (enumMember !== null) { + setSearchType(enumMember); + } + }} + icon={} + /> + + + { + setSearch(searchValue); + }} + onClear={() => setSearch('')} + style={{ minWidth: '200px' }} + data-testid="model-versions-table-search" + /> + + + + {!isArchiveModel && ( + <> + + + + + setIsArchivedModelVersionKebabOpen(false)} + onOpenChange={(isOpen: boolean) => setIsArchivedModelVersionKebabOpen(isOpen)} + toggle={(tr: React.Ref) => ( + + setIsArchivedModelVersionKebabOpen(!isArchivedModelVersionKebabOpen) + } + isExpanded={isArchivedModelVersionKebabOpen} + aria-label="View archived versions" + > + + + )} + shouldFocusToggleOnSelect + > + + + navigate(modelVersionArchiveUrl(rm?.id, preferredModelRegistry?.name)) + } + > + View archived versions + + + + + + )} + + } + /> + ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx index 9e471aca..1e1d4872 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersions.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { useParams } from 'react-router'; +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; import { Breadcrumb, BreadcrumbItem, Truncate } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; import { ModelVersionsTab } from '~/app/pages/modelRegistry/screens/ModelVersions/const'; @@ -9,6 +9,8 @@ import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; import { filterLiveVersions } from '~/app/pages/modelRegistry/screens/utils'; import ModelVersionsHeaderActions from '~/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsHeaderActions'; +import { ModelState } from '~/app/types'; +import { registeredModelArchiveDetailsUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; import ModelVersionsTabs from './ModelVersionsTabs'; type ModelVersionsProps = { @@ -25,6 +27,13 @@ const ModelVersions: React.FC = ({ tab, ...pageProps }) => { const [rm, rmLoaded, rmLoadError, rmRefresh] = useRegisteredModelById(rmId); const loadError = mvLoadError || rmLoadError; const loaded = mvLoaded && rmLoaded; + const navigate = useNavigate(); + + useEffect(() => { + if (rm?.state === ModelState.ARCHIVED) { + navigate(registeredModelArchiveDetailsUrl(rm.id, preferredModelRegistry?.name)); + } + }, [rm?.state, rm?.id, preferredModelRegistry?.name, navigate]); return ( void; modelVersions: ModelVersion[]; + isArchiveModel?: boolean; refresh: () => void; } & Partial, 'toolbarContent'>>; @@ -15,6 +16,7 @@ const ModelVersionsTable: React.FC = ({ clearFilters, modelVersions, toolbarContent, + isArchiveModel, refresh, }) => ( = ({ enablePagination emptyTableView={} rowRenderer={(mv) => ( - + )} /> ); diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx index 55a4d85b..743adc4c 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow.tsx @@ -1,11 +1,12 @@ import * as React from 'react'; -import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; +import { ActionsColumn, IAction, Td, Tr } from '@patternfly/react-table'; import { Content, ContentVariants, Truncate, FlexItem } from '@patternfly/react-core'; import { Link, useNavigate } from 'react-router-dom'; import { ModelState, ModelVersion } from '~/app/types'; import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; import { + archiveModelVersionDetailsUrl, modelVersionArchiveDetailsUrl, modelVersionUrl, } from '~/app/pages/modelRegistry/screens/routeUtils'; @@ -17,12 +18,14 @@ import { RestoreModelVersionModal } from '~/app/pages/modelRegistry/screens/comp type ModelVersionsTableRowProps = { modelVersion: ModelVersion; isArchiveRow?: boolean; + isArchiveModel?: boolean; refresh: () => void; }; const ModelVersionsTableRow: React.FC = ({ modelVersion: mv, isArchiveRow, + isArchiveModel, refresh, }) => { const navigate = useNavigate(); @@ -31,7 +34,7 @@ const ModelVersionsTableRow: React.FC = ({ const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); const { apiState } = React.useContext(ModelRegistryContext); - const actions = isArchiveRow + const actions: IAction[] = isArchiveRow ? [ { title: 'Restore version', @@ -39,10 +42,6 @@ const ModelVersionsTableRow: React.FC = ({ }, ] : [ - { - title: 'Deploy', - onClick: () => setIsDeployModalOpen(true), - }, { title: 'Archive model version', onClick: () => setIsArchiveModalOpen(true), @@ -56,13 +55,19 @@ const ModelVersionsTableRow: React.FC = ({ @@ -82,45 +87,47 @@ const ModelVersionsTableRow: React.FC = ({ - + {!isArchiveModel && ( + + )} ); }; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTabs.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTabs.tsx index 7460e663..2bf57bd0 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTabs.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTabs.tsx @@ -13,6 +13,7 @@ type ModelVersionsTabProps = { tab: ModelVersionsTab; registeredModel: RegisteredModel; modelVersions: ModelVersion[]; + isArchiveModel?: boolean; refresh: () => void; mvRefresh: () => void; }; @@ -22,6 +23,7 @@ const ModelVersionsTabs: React.FC = ({ registeredModel: rm, modelVersions, refresh, + isArchiveModel, mvRefresh, }) => { const navigate = useNavigate(); @@ -41,6 +43,7 @@ const ModelVersionsTabs: React.FC = ({ > = ({ data-testid="model-details-tab" > - + diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ArchiveModelVersionDetails.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ArchiveModelVersionDetails.tsx new file mode 100644 index 00000000..8a595c47 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ArchiveModelVersionDetails.tsx @@ -0,0 +1,84 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { Button, Flex, FlexItem, Label, Content, Tooltip, Truncate } from '@patternfly/react-core'; + +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; +import useModelVersionById from '~/app/hooks/useModelVersionById'; +import { ModelState } from '~/app/types'; +import { modelVersionUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; +import ModelVersionDetailsTabs from '~/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs'; +import { ModelVersionDetailsTab } from '~/app/pages/modelRegistry/screens/ModelVersionDetails/const'; +import ArchiveModelVersionDetailsBreadcrumb from './ArchiveModelVersionDetailsBreadcrumb'; + +type ArchiveModelVersionDetailsProps = { + tab: ModelVersionDetailsTab; +} & Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'description' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const ArchiveModelVersionDetails: React.FC = ({ + tab, + ...pageProps +}) => { + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const { modelVersionId: mvId, registeredModelId: rmId } = useParams(); + const [rm] = useRegisteredModelById(rmId); + const [mv, mvLoaded, mvLoadError, refreshModelVersion] = useModelVersionById(mvId); + const navigate = useNavigate(); + + useEffect(() => { + if (rm?.state === ModelState.LIVE && mv?.id) { + navigate(modelVersionUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name)); + } + }, [rm?.state, mv?.id, mv?.registeredModelId, preferredModelRegistry?.name, navigate]); + + return ( + + } + title={ + mv && ( + + + {mv.name} + + + + + + ) + } + headerAction={ + + + + } + description={} + loadError={mvLoadError} + loaded={mvLoaded} + provideChildrenPadding + > + {mv !== null && ( + + )} + + ); +}; + +export default ArchiveModelVersionDetails; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ArchiveModelVersionDetailsBreadcrumb.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ArchiveModelVersionDetailsBreadcrumb.tsx new file mode 100644 index 00000000..8356589b --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ArchiveModelVersionDetailsBreadcrumb.tsx @@ -0,0 +1,41 @@ +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { RegisteredModel } from '~/app/types'; +import { + registeredModelArchiveDetailsUrl, + registeredModelArchiveUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; + +type ArchiveModelVersionDetailsBreadcrumbProps = { + preferredModelRegistry?: string; + registeredModel: RegisteredModel | null; + modelVersionName?: string; +}; + +const ArchiveModelVersionDetailsBreadcrumb: React.FC = ({ + preferredModelRegistry, + registeredModel, + modelVersionName, +}) => ( + + Model registry - {preferredModelRegistry}} + /> + ( + Archived models + )} + /> + ( + + {registeredModel?.name || 'Loading...'} + + )} + /> + {modelVersionName || 'Loading...'} + +); + +export default ArchiveModelVersionDetailsBreadcrumb; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx new file mode 100644 index 00000000..fdb1f80f --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetails.tsx @@ -0,0 +1,114 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { Button, Flex, FlexItem, Label, Content, Truncate } from '@patternfly/react-core'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; +import useModelVersionById from '~/app/hooks/useModelVersionById'; +import { ModelState } from '~/app/types'; +import { + archiveModelVersionDetailsUrl, + modelVersionUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; +import ModelVersionDetailsTabs from '~/app/pages/modelRegistry/screens/ModelVersionDetails/ModelVersionDetailsTabs'; +import { RestoreModelVersionModal } from '~/app/pages/modelRegistry/screens/components/RestoreModelVersionModal'; +import { ModelVersionDetailsTab } from '~/app/pages/modelRegistry/screens/ModelVersionDetails/const'; +import ModelVersionArchiveDetailsBreadcrumb from './ModelVersionArchiveDetailsBreadcrumb'; + +type ModelVersionsArchiveDetailsProps = { + tab: ModelVersionDetailsTab; +} & Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'description' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const ModelVersionsArchiveDetails: React.FC = ({ + tab, + ...pageProps +}) => { + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + const { apiState } = React.useContext(ModelRegistryContext); + + const navigate = useNavigate(); + + const { modelVersionId: mvId, registeredModelId: rmId } = useParams(); + const [rm] = useRegisteredModelById(rmId); + const [mv, mvLoaded, mvLoadError, refreshModelVersion] = useModelVersionById(mvId); + const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); + + useEffect(() => { + if (rm?.state === ModelState.ARCHIVED && mv?.id) { + navigate( + archiveModelVersionDetailsUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name), + ); + } else if (mv?.state === ModelState.LIVE) { + navigate(modelVersionUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name)); + } + }, [rm?.state, mv?.state, mv?.id, mv?.registeredModelId, preferredModelRegistry?.name, navigate]); + + return ( + <> + + } + title={ + mv && ( + + + {mv.name} + + + + + + ) + } + headerAction={ + + } + description={} + loadError={mvLoadError} + loaded={mvLoaded} + provideChildrenPadding + > + {mv !== null && ( + + )} + + {mv !== null && ( + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchModelVersion( + {}, + { + state: ModelState.LIVE, + }, + mv.id, + ) + .then(() => navigate(modelVersionUrl(mv.id, rm?.id, preferredModelRegistry?.name))) + } + isOpen={isRestoreModalOpen} + modelVersionName={mv.name} + /> + )} + + ); +}; + +export default ModelVersionsArchiveDetails; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetailsBreadcrumb.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetailsBreadcrumb.tsx new file mode 100644 index 00000000..c706fef0 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionArchiveDetailsBreadcrumb.tsx @@ -0,0 +1,43 @@ +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import React from 'react'; +import { Link } from 'react-router-dom'; +import { RegisteredModel } from '~/app/types'; +import { + modelVersionArchiveUrl, + registeredModelUrl, +} from '~/app/pages/modelRegistry/screens/routeUtils'; + +type ModelVersionArchiveDetailsBreadcrumbProps = { + preferredModelRegistry?: string; + registeredModel: RegisteredModel | null; + modelVersionName?: string; +}; + +const ModelVersionArchiveDetailsBreadcrumb: React.FC = ({ + preferredModelRegistry, + registeredModel, + modelVersionName, +}) => ( + + Model registry - {preferredModelRegistry}} + /> + ( + + {registeredModel?.name || 'Loading...'} + + )} + /> + ( + + Archived versions + + )} + /> + {modelVersionName || 'Loading...'} + +); + +export default ModelVersionArchiveDetailsBreadcrumb; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx new file mode 100644 index 00000000..2245187a --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchive.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { useParams } from 'react-router'; +import { Breadcrumb, BreadcrumbItem } from '@patternfly/react-core'; +import { Link } from 'react-router-dom'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; +import useModelVersionsByRegisteredModel from '~/app/hooks/useModelVersionsByRegisteredModel'; +import { registeredModelUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; +import { filterArchiveVersions } from '~/app/pages/modelRegistry/screens/utils'; +import ModelVersionsArchiveListView from './ModelVersionsArchiveListView'; + +type ModelVersionsArchiveProps = Omit< + React.ComponentProps, + 'breadcrumb' | 'title' | 'description' | 'loadError' | 'loaded' | 'provideChildrenPadding' +>; + +const ModelVersionsArchive: React.FC = ({ ...pageProps }) => { + const { preferredModelRegistry } = React.useContext(ModelRegistrySelectorContext); + + const { registeredModelId: rmId } = useParams(); + const [rm] = useRegisteredModelById(rmId); + const [modelVersions, mvLoaded, mvLoadError, refresh] = useModelVersionsByRegisteredModel(rmId); + + return ( + + ( + Model registry - {preferredModelRegistry?.name} + )} + /> + ( + + {rm?.name || 'Loading...'} + + )} + /> + + Archived versions + + + } + title={rm ? `Archived versions of ${rm.name}` : 'Archived versions'} + loadError={mvLoadError} + loaded={mvLoaded} + provideChildrenPadding + > + + + ); +}; + +export default ModelVersionsArchive; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx new file mode 100644 index 00000000..b8e419e4 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveListView.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { + SearchInput, + ToolbarContent, + ToolbarFilter, + ToolbarGroup, + ToolbarItem, + ToolbarToggleGroup, +} from '@patternfly/react-core'; +import { FilterIcon } from '@patternfly/react-icons'; +import { ModelVersion } from '~/app/types'; +import { SearchType } from '~/app/components/DashboardSearchField'; +import SimpleSelect from '~/app/components/SimpleSelect'; +import { asEnumMember } from '~/app/utils'; +import { filterModelVersions } from '~/app/pages/modelRegistry/screens/utils'; +import EmptyModelRegistryState from '~/app/pages/modelRegistry/screens/components/EmptyModelRegistryState'; +import ModelVersionsArchiveTable from './ModelVersionsArchiveTable'; + +type ModelVersionsArchiveListViewProps = { + modelVersions: ModelVersion[]; + refresh: () => void; +}; + +const ModelVersionsArchiveListView: React.FC = ({ + modelVersions: unfilteredmodelVersions, + refresh, +}) => { + const [searchType, setSearchType] = React.useState(SearchType.KEYWORD); + const [search, setSearch] = React.useState(''); + + const searchTypes = [SearchType.KEYWORD, SearchType.AUTHOR]; + + const filteredModelVersions = filterModelVersions(unfilteredmodelVersions, search, searchType); + + if (unfilteredmodelVersions.length === 0) { + return ( + + ); + } + + return ( + setSearch('')} + modelVersions={filteredModelVersions} + toolbarContent={ + + } breakpoint="xl"> + + setSearch('')} + deleteLabelGroup={() => setSearch('')} + categoryName="Keyword" + > + ({ + key, + label: key, + }))} + value={searchType} + onChange={(newSearchType) => { + const enumMember = asEnumMember(newSearchType, SearchType); + if (enumMember) { + setSearchType(enumMember); + } + }} + icon={} + /> + + + { + setSearch(searchValue); + }} + onClear={() => setSearch('')} + style={{ minWidth: '200px' }} + data-testid="model-versions-archive-table-search" + /> + + + + + } + /> + ); +}; + +export default ModelVersionsArchiveListView; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx new file mode 100644 index 00000000..c611ede0 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/ModelVersionsArchive/ModelVersionsArchiveTable.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import { Table } from '~/app/components/table'; +import { ModelVersion } from '~/app/types'; +import DashboardEmptyTableView from '~/app/components/DashboardEmptyTableView'; +import ModelVersionsTableRow from '~/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableRow'; +import { mvColumns } from '~/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTableColumns'; + +type ModelVersionsArchiveTableProps = { + clearFilters: () => void; + modelVersions: ModelVersion[]; + refresh: () => void; +} & Partial, 'toolbarContent'>>; + +const ModelVersionsArchiveTable: React.FC = ({ + clearFilters, + modelVersions, + toolbarContent, + refresh, +}) => ( +
- - setIsArchiveModalOpen(false)} - onSubmit={() => - apiState.api - .patchModelVersion( - {}, - { - state: ModelState.ARCHIVED, - }, - mv.id, - ) - .then(refresh) - } - isOpen={isArchiveModalOpen} - modelVersionName={mv.name} - /> - setIsRestoreModalOpen(false)} - onSubmit={() => - apiState.api - .patchModelVersion( - {}, - { - state: ModelState.LIVE, - }, - mv.id, - ) - .then(() => - navigate( - modelVersionUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name), - ), - ) - } - isOpen={isRestoreModalOpen} - modelVersionName={mv.name} - /> - + + setIsArchiveModalOpen(false)} + onSubmit={() => + apiState.api + .patchModelVersion( + {}, + { + state: ModelState.ARCHIVED, + }, + mv.id, + ) + .then(refresh) + } + isOpen={isArchiveModalOpen} + modelVersionName={mv.name} + /> + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchModelVersion( + {}, + { + state: ModelState.LIVE, + }, + mv.id, + ) + .then(() => + navigate( + modelVersionUrl(mv.id, mv.registeredModelId, preferredModelRegistry?.name), + ), + ) + } + isOpen={isRestoreModalOpen} + modelVersionName={mv.name} + /> +
} + defaultSortColumn={1} + rowRenderer={(mv: ModelVersion) => ( + + )} + /> +); + +export default ModelVersionsArchiveTable; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx index d2a6367d..52f676fe 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModels/RegisteredModelListView.tsx @@ -89,7 +89,7 @@ const RegisteredModelListView: React.FC = ({ icon={} /> - + = ({ const [isRestoreModalOpen, setIsRestoreModalOpen] = React.useState(false); const rmUrl = registeredModelUrl(rm.id, preferredModelRegistry?.name); - const actions = [ + const actions: IAction[] = [ { title: 'View details', - // eslint-disable-next-line @typescript-eslint/no-empty-function - onClick: () => navigate(`${rmUrl}/${ModelVersionsTab.DETAILS}`), + onClick: () => { + if (isArchiveRow) { + navigate( + `${registeredModelArchiveUrl(preferredModelRegistry?.name)}/${rm.id}/${ + ModelVersionsTab.DETAILS + }`, + ); + } else { + navigate(`${rmUrl}/${ModelVersionsTab.DETAILS}`); + } + }, }, isArchiveRow ? { diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetails.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetails.tsx new file mode 100644 index 00000000..711469e5 --- /dev/null +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetails.tsx @@ -0,0 +1,109 @@ +import React, { useEffect } from 'react'; +import { useNavigate, useParams } from 'react-router'; +import { Button, Flex, FlexItem, Label, Content, Truncate } from '@patternfly/react-core'; +import ApplicationsPage from '~/app/components/ApplicationsPage'; +import { ModelRegistrySelectorContext } from '~/app/context/ModelRegistrySelectorContext'; +import { ModelRegistryContext } from '~/app/context/ModelRegistryContext'; +import useRegisteredModelById from '~/app/hooks/useRegisteredModelById'; +import useModelVersionsByRegisteredModel from '~/app/hooks/useModelVersionsByRegisteredModel'; +import { ModelState } from '~/app/types'; +import { ModelVersionsTab } from '~/app/pages/modelRegistry/screens/ModelVersions/const'; +import ModelVersionsTabs from '~/app/pages/modelRegistry/screens/ModelVersions/ModelVersionsTabs'; +import { RestoreRegisteredModelModal } from '~/app/pages/modelRegistry/screens/components/RestoreRegisteredModel'; +import { registeredModelUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; +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); + + useEffect(() => { + if (rm?.state === ModelState.LIVE) { + navigate(registeredModelUrl(rm.id, preferredModelRegistry?.name)); + } + }, [rm?.state, preferredModelRegistry?.name, rm?.id, navigate]); + + return ( + <> + + } + title={ + rm && ( + + + {rm.name} + + + + + + ) + } + headerAction={ + + } + description={} + loadError={rmLoadError} + loaded={rmLoaded} + provideChildrenPadding + > + {rm !== null && mvLoaded && !mvLoadError && ( + + )} + + + {rm !== null && ( + setIsRestoreModalOpen(false)} + onSubmit={() => + apiState.api + .patchRegisteredModel( + {}, + { + state: ModelState.LIVE, + }, + rm.id, + ) + .then(() => navigate(registeredModelUrl(rm.id, preferredModelRegistry?.name))) + } + isOpen={isRestoreModalOpen} + registeredModelName={rm.name} + /> + )} + + ); +}; + +export default RegisteredModelsArchiveDetails; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetailsBreadcrumb.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelArchiveDetailsBreadcrumb.tsx new file mode 100644 index 00000000..e72161ec --- /dev/null +++ b/clients/ui/frontend/src/app/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 '~/app/types'; +import { registeredModelArchiveUrl } from '~/app/pages/modelRegistry/screens/routeUtils'; + +type RegisteredModelArchiveDetailsBreadcrumbProps = { + preferredModelRegistry?: string; + registeredModel: RegisteredModel | null; +}; + +const RegisteredModelArchiveDetailsBreadcrumb: React.FC< + RegisteredModelArchiveDetailsBreadcrumbProps +> = ({ preferredModelRegistry, registeredModel }) => ( + + Model registry - {preferredModelRegistry}} + /> + ( + Archived models + )} + /> + {registeredModel?.name || 'Loading...'} + +); + +export default RegisteredModelArchiveDetailsBreadcrumb; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx index cd419d03..d3a8acda 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/RegisteredModelsArchive/RegisteredModelsArchiveListView.tsx @@ -77,7 +77,7 @@ const RegisteredModelsArchiveListView: React.FC} /> - + `${registeredModelUrl(rmId, preferredModelRegistry)}/registerVersion`; -export const modelVersionDeploymentsUrl = ( +export const archiveModelVersionListUrl = ( + rmId?: string, + preferredModelRegistry?: string, +): string => `${registeredModelArchiveDetailsUrl(rmId, preferredModelRegistry)}/versions`; + +export const archiveModelVersionDetailsUrl = ( mvId: string, rmId?: string, preferredModelRegistry?: string, -): string => `${modelVersionUrl(mvId, rmId, preferredModelRegistry)}/deployments`; +): string => `${archiveModelVersionListUrl(rmId, preferredModelRegistry)}/${mvId}`; diff --git a/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts b/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts index ff6d4ef4..6c04ab1d 100644 --- a/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts +++ b/clients/ui/frontend/src/app/pages/modelRegistry/screens/utils.ts @@ -9,6 +9,13 @@ import { } from '~/app/types'; import { KeyValuePair } from '~/types'; +export type ObjectStorageFields = { + endpoint: string; + bucket: string; + region?: string; + path: string; +}; + // Retrieves the labels from customProperties that have non-empty string_value. export const getLabels = (customProperties: T): string[] => Object.keys(customProperties).filter((key) => { @@ -148,3 +155,23 @@ export const filterArchiveModels = (registeredModels: RegisteredModel[]): Regist export const filterLiveModels = (registeredModels: RegisteredModel[]): RegisteredModel[] => registeredModels.filter((rm) => rm.state === ModelState.LIVE); + +export const uriToObjectStorageFields = (uri: string): ObjectStorageFields | null => { + try { + const urlObj = new URL(uri); + // Some environments include the first token after the protocol (our bucket) in the pathname and some have it as the hostname + const [bucket, ...pathSplit] = `${urlObj.hostname}/${urlObj.pathname}` + .split('/') + .filter(Boolean); + const path = pathSplit.join('/'); + const searchParams = new URLSearchParams(urlObj.search); + const endpoint = searchParams.get('endpoint'); + const region = searchParams.get('defaultRegion'); + if (endpoint && bucket && path) { + return { endpoint, bucket, region: region || undefined, path }; + } + return null; + } catch { + return null; + } +}; diff --git a/clients/ui/frontend/src/components/DashboardDescriptionListGroup.tsx b/clients/ui/frontend/src/components/DashboardDescriptionListGroup.tsx index 4216c86b..7fb6ae63 100644 --- a/clients/ui/frontend/src/components/DashboardDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/components/DashboardDescriptionListGroup.tsx @@ -110,7 +110,9 @@ const DashboardDescriptionListGroup: React.FC )} - + {/* The text color below is a hack for a11y. + PF6 team needs to update their disabled color to work for white backgrounds */} + {isEditing ? contentWhenEditing : isEmpty ? contentWhenEmpty : children} diff --git a/clients/ui/frontend/src/components/DashboardHelpTooltip.tsx b/clients/ui/frontend/src/components/DashboardHelpTooltip.tsx new file mode 100644 index 00000000..033d1d65 --- /dev/null +++ b/clients/ui/frontend/src/components/DashboardHelpTooltip.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import { Tooltip } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; + +type DashboardHelpTooltipProps = { + content: React.ReactNode; +}; + +const DashboardHelpTooltip: React.FC = ({ content }) => ( + + + +); + +export default DashboardHelpTooltip; diff --git a/clients/ui/frontend/src/components/EditableLabelsDescriptionListGroup.tsx b/clients/ui/frontend/src/components/EditableLabelsDescriptionListGroup.tsx index 42cc9091..095bfdd7 100644 --- a/clients/ui/frontend/src/components/EditableLabelsDescriptionListGroup.tsx +++ b/clients/ui/frontend/src/components/EditableLabelsDescriptionListGroup.tsx @@ -22,6 +22,7 @@ type EditableTextDescriptionListGroupProps = Partial< labels: string[]; saveEditedLabels: (labels: string[]) => Promise; allExistingKeys?: string[]; + isArchive?: boolean; }; const EditableLabelsDescriptionListGroup: React.FC = ({ @@ -29,6 +30,7 @@ const EditableLabelsDescriptionListGroup: React.FC { const [isEditing, setIsEditing] = React.useState(false); @@ -98,7 +100,7 @@ const EditableLabelsDescriptionListGroup: React.FC Promise; testid?: string; + isArchive?: boolean; }; const EditableTextDescriptionListGroup: React.FC = ({ title, contentWhenEmpty, value, + isArchive, saveEditedValue, testid, }) => { @@ -29,7 +31,7 @@ const EditableTextDescriptionListGroup: React.FC = ({ textToCopy, testId }) => ( + // @ts-expect-error ClipboardCopy expects children of type string in PF v6 + { + navigator.clipboard.writeText(textToCopy); + }} + data-testid={testId} + > + + +); + +export default InlineTruncatedClipboardCopy;