From 0c15247dfd91ba509cddc1e9fd42b35e5cd71232 Mon Sep 17 00:00:00 2001 From: Andrew Ballantyne <8126518+andrewballantyne@users.noreply.github.com> Date: Fri, 11 Oct 2024 15:13:28 -0400 Subject: [PATCH] Support for Trusty DB fields (#3305) * Support for Trusty DB fields * Added granularity to the type of error & useDebounce func * Fix tests & add a DBConnecting state check * unsecret-ify the mock secret * Reduce already installed creationTimestamp for trusty tests * Remove odd cancel in debounce logic --- .../src/__mocks__/mockSecretK8sResource.ts | 62 +++++-- .../mockTrustyAIServiceK8sResource.ts | 13 +- .../pages/components/TrustyAICRState.ts | 87 +++++++++ .../cypress/cypress/pages/projects.ts | 35 +--- .../mocked/modelServing/modelMetrics.cy.ts | 169 ++++++++---------- frontend/src/api/trustyai/k8s.ts | 26 ++- .../components/FieldGroupHelpLabelIcon.tsx | 16 ++ .../k8s/ResourceNameDefinitionTooltip.tsx | 13 +- frontend/src/concepts/k8s/utils.ts | 15 +- frontend/src/concepts/trustyai/const.ts | 7 +- .../content/InstallTrustyAICheckbox.tsx | 62 ------- .../trustyai/content/InstallTrustyModal.tsx | 113 ++++++++++++ .../content/TrustyAIServerTimedOutError.tsx | 42 ----- .../content/TrustyAIServiceControl.tsx | 69 ------- .../content/TrustyAIServiceNotification.tsx | 74 -------- .../content/TrustyDBExistingSecretField.tsx | 82 +++++++++ .../trustyai/content/TrustyDBSecretFields.tsx | 100 +++++++++++ .../statusStates/TrustyAIInstalledState.tsx | 32 ++++ .../statusStates/TrustyAIUninstalledState.tsx | 40 +++++ .../content/useTrustyBrowserStorage.ts | 58 ++++++ .../trustyai/content/useTrustyCRState.tsx | 96 ++++++++++ .../content/useTrustyInstallModalData.tsx | 135 ++++++++++++++ .../trustyai/context/TrustyAIContext.tsx | 36 ++-- .../context/useDoesTrustyAICRExist.ts | 5 +- .../trustyai/context/useModelBiasData.ts | 18 +- frontend/src/concepts/trustyai/types.ts | 35 ++++ .../concepts/trustyai/useManageTrustyAICR.ts | 110 +++++++----- .../trustyai/useTrustyAINamespaceCR.ts | 42 ++--- frontend/src/concepts/trustyai/utils.ts | 82 ++++++++- frontend/src/k8sTypes.ts | 17 +- .../screens/metrics/MetricsPage.tsx | 8 +- .../screens/metrics/MetricsPageTabs.tsx | 5 +- .../BiasConfigurationPage.tsx | 10 +- .../metrics/bias/BiasMetricConfigSelector.tsx | 8 +- .../screens/metrics/bias/BiasTab.tsx | 9 +- .../projectSettings/ModelBiasSettingsCard.tsx | 40 +++-- .../pages/projects/projectSettings/const.ts | 5 - 37 files changed, 1188 insertions(+), 588 deletions(-) create mode 100644 frontend/src/__tests__/cypress/cypress/pages/components/TrustyAICRState.ts create mode 100644 frontend/src/components/FieldGroupHelpLabelIcon.tsx delete mode 100644 frontend/src/concepts/trustyai/content/InstallTrustyAICheckbox.tsx create mode 100644 frontend/src/concepts/trustyai/content/InstallTrustyModal.tsx delete mode 100644 frontend/src/concepts/trustyai/content/TrustyAIServerTimedOutError.tsx delete mode 100644 frontend/src/concepts/trustyai/content/TrustyAIServiceControl.tsx delete mode 100644 frontend/src/concepts/trustyai/content/TrustyAIServiceNotification.tsx create mode 100644 frontend/src/concepts/trustyai/content/TrustyDBExistingSecretField.tsx create mode 100644 frontend/src/concepts/trustyai/content/TrustyDBSecretFields.tsx create mode 100644 frontend/src/concepts/trustyai/content/statusStates/TrustyAIInstalledState.tsx create mode 100644 frontend/src/concepts/trustyai/content/statusStates/TrustyAIUninstalledState.tsx create mode 100644 frontend/src/concepts/trustyai/content/useTrustyBrowserStorage.ts create mode 100644 frontend/src/concepts/trustyai/content/useTrustyCRState.tsx create mode 100644 frontend/src/concepts/trustyai/content/useTrustyInstallModalData.tsx delete mode 100644 frontend/src/pages/projects/projectSettings/const.ts diff --git a/frontend/src/__mocks__/mockSecretK8sResource.ts b/frontend/src/__mocks__/mockSecretK8sResource.ts index 8d8fd3962c..5f2d7d0367 100644 --- a/frontend/src/__mocks__/mockSecretK8sResource.ts +++ b/frontend/src/__mocks__/mockSecretK8sResource.ts @@ -1,6 +1,41 @@ import { KnownLabels, SecretKind } from '~/k8sTypes'; import { genUID } from '~/__mocks__/mockUtils'; +type MockCustomSecretData = { + name: string; + namespace: string; + uid?: string; + labels?: Record; + annotations?: Record; + data: Record; +}; + +export const mockCustomSecretK8sResource = ({ + name, + namespace, + uid = 'some-test-uid', + labels = {}, + annotations = {}, + data, +}: MockCustomSecretData): SecretKind => ({ + kind: 'Secret', + apiVersion: 'route.openshift.io/v1', + metadata: { + name, + namespace, + uid, + resourceVersion: '5985371', + creationTimestamp: '2023-03-22T16:18:56Z', + labels: { + [KnownLabels.DASHBOARD_RESOURCE]: 'true', + ...labels, + }, + annotations, + }, + data, + type: 'Opaque', +}); + type MockResourceConfigType = { name?: string; namespace?: string; @@ -21,30 +56,23 @@ export const mockSecretK8sResource = ({ endPoint = 'aHR0cHM6Ly9zMy5hbWF6b25hd3MuY29tLw==', region = 'dXMtZWFzdC0x', uid = genUID('secret'), -}: MockResourceConfigType): SecretKind => ({ - kind: 'Secret', - apiVersion: 'route.openshift.io/v1', - metadata: { +}: MockResourceConfigType): SecretKind => + mockCustomSecretK8sResource({ name, namespace, uid, - resourceVersion: '5985371', - creationTimestamp: '2023-03-22T16:18:56Z', labels: { - [KnownLabels.DASHBOARD_RESOURCE]: 'true', [KnownLabels.DATA_CONNECTION_AWS]: 'true', }, annotations: { 'opendatahub.io/connection-type': connectionType, 'openshift.io/display-name': displayName, }, - }, - data: { - AWS_ACCESS_KEY_ID: 'c2RzZA==', - AWS_DEFAULT_REGION: region, - AWS_S3_BUCKET: s3Bucket, - AWS_S3_ENDPOINT: endPoint, - AWS_SECRET_ACCESS_KEY: 'c2RzZA==', - }, - type: 'Opaque', -}); + data: { + AWS_ACCESS_KEY_ID: 'c2RzZA==', + AWS_DEFAULT_REGION: region, + AWS_S3_BUCKET: s3Bucket, + AWS_S3_ENDPOINT: endPoint, + AWS_SECRET_ACCESS_KEY: 'c2RzZA==', + }, + }); diff --git a/frontend/src/__mocks__/mockTrustyAIServiceK8sResource.ts b/frontend/src/__mocks__/mockTrustyAIServiceK8sResource.ts index c51b5e3a98..2968c92b1d 100644 --- a/frontend/src/__mocks__/mockTrustyAIServiceK8sResource.ts +++ b/frontend/src/__mocks__/mockTrustyAIServiceK8sResource.ts @@ -6,7 +6,7 @@ type MockTrustyAIServiceK8sResourceOptions = { namespace?: string; }; -export const mockTrustyAIServiceK8sResource = ({ +export const mockTrustyAIServiceForDbK8sResource = ({ isAvailable = true, creationTimestamp = new Date().toISOString(), namespace = 'test-project', @@ -27,9 +27,8 @@ export const mockTrustyAIServiceK8sResource = ({ schedule: '5s', }, storage: { - folder: '/inputs', - format: 'PVC', - size: '1Gi', + format: 'DATABASE', + databaseConfigurations: 'test-secret', }, }, status: { @@ -43,10 +42,10 @@ export const mockTrustyAIServiceK8sResource = ({ }, { lastTransitionTime: '2024-01-11T18:29:06Z', - message: 'PersistentVolumeClaim found', - reason: 'PVCFound', + message: 'Database connected', + reason: 'DBConnected', status: 'True', - type: 'PVCAvailable', + type: 'DBAvailable', }, { lastTransitionTime: '2024-01-11T18:29:06Z', diff --git a/frontend/src/__tests__/cypress/cypress/pages/components/TrustyAICRState.ts b/frontend/src/__tests__/cypress/cypress/pages/components/TrustyAICRState.ts new file mode 100644 index 0000000000..96c6d9f8f2 --- /dev/null +++ b/frontend/src/__tests__/cypress/cypress/pages/components/TrustyAICRState.ts @@ -0,0 +1,87 @@ +import { DeleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; +import { Modal } from '~/__tests__/cypress/cypress/pages/components/Modal'; + +/** Duped to avoid importing code from the app into tests (and breaking webpack) */ +const TRUSTYAI_INSTALL_MODAL_TEST_ID = 'trusty-db-config'; + +export class TrustyAICRState { + configureModal = new TrustyAICRModal(); + + deleteModal = new TrustyAIUninstallModal(); + + findError(): Cypress.Chainable> { + return cy.findByTestId('trustyai-service-error'); + } + + findUninstallButton(): Cypress.Chainable> { + return cy.findByTestId('trustyai-uninstall-button'); + } + + findInstallButton(): Cypress.Chainable> { + return cy.findByTestId('trustyai-configure-button'); + } +} + +class TrustyAICRModal extends Modal { + constructor() { + super('Configure TrustyAI service'); + } + + findSubmitButton(): Cypress.Chainable> { + return cy.findByTestId('modal-submit-button'); + } + + private findField(fieldName: string): Cypress.Chainable> { + return cy.findByTestId(`${TRUSTYAI_INSTALL_MODAL_TEST_ID}-${fieldName}`); + } + + findExistingRadio(): Cypress.Chainable> { + return this.findField('radio-existing'); + } + + findNewRadio(): Cypress.Chainable> { + return this.findField('radio-new'); + } + + findExistingNameField(): Cypress.Chainable> { + return this.findField('existing-secret'); + } + + findNewKindField(): Cypress.Chainable> { + return this.findField('databaseKind'); + } + + findNewUsernameField(): Cypress.Chainable> { + return this.findField('databaseUsername'); + } + + findNewPasswordField(): Cypress.Chainable> { + return this.findField('databasePassword'); + } + + findNewServiceField(): Cypress.Chainable> { + return this.findField('databaseService'); + } + + findNewPortField(): Cypress.Chainable> { + return this.findField('databasePort'); + } + + findNewDbNameField(): Cypress.Chainable> { + return this.findField('databaseName'); + } + + findNewGenerationField(): Cypress.Chainable> { + return this.findField('databaseGeneration'); + } +} + +class TrustyAIUninstallModal extends DeleteModal { + constructor() { + super('Warning alert: Uninstall TrustyAI'); + } + + findSubmitButton() { + return this.findFooter().findByRole('button', { name: 'Uninstall' }); + } +} diff --git a/frontend/src/__tests__/cypress/cypress/pages/projects.ts b/frontend/src/__tests__/cypress/cypress/pages/projects.ts index 883200aab4..733e8e597c 100644 --- a/frontend/src/__tests__/cypress/cypress/pages/projects.ts +++ b/frontend/src/__tests__/cypress/cypress/pages/projects.ts @@ -3,6 +3,7 @@ import { appChrome } from '~/__tests__/cypress/cypress/pages/appChrome'; import { DeleteModal } from '~/__tests__/cypress/cypress/pages/components/DeleteModal'; import { Contextual } from '~/__tests__/cypress/cypress/pages/components/Contextual'; import { K8sNameDescriptionField } from '~/__tests__/cypress/cypress/pages/components/subComponents/K8sNameDescriptionField'; +import { TrustyAICRState } from '~/__tests__/cypress/cypress/pages/components/TrustyAICRState'; import { TableRow } from './components/table'; class ProjectListToolbar extends Contextual { @@ -300,43 +301,13 @@ class ProjectDetails { } class ProjectDetailsSettingsTab extends ProjectDetails { + trustyai = new TrustyAICRState(); + visit(project: string) { super.visit(project); this.findTab('Settings').click(); - - this.findTrustyAIInstallCheckbox(); cy.testA11y(); } - - findTrustyAIInstallCheckbox() { - return cy.findByTestId('trustyai-service-installation'); - } - - getTrustyAIUninstallModal() { - return new TrustyAIUninstallModal(); - } - - findTrustyAITimeoutError() { - return cy.findByTestId('trustyai-service-timeout-error'); - } - - findTrustyAIServiceError() { - return cy.findByTestId('trustyai-service-error'); - } - - findTrustyAISuccessAlert() { - return cy.findByTestId('trustyai-service-installed-alert'); - } -} - -class TrustyAIUninstallModal extends DeleteModal { - constructor() { - super('Warning alert: Uninstall TrustyAI'); - } - - findSubmitButton() { - return this.findFooter().findByRole('button', { name: 'Uninstall' }); - } } export const projectListPage = new ProjectListPage(); diff --git a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts index e344883480..39c34205ad 100644 --- a/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts +++ b/frontend/src/__tests__/cypress/cypress/tests/mocked/modelServing/modelMetrics.cy.ts @@ -3,7 +3,10 @@ import { mockDscStatus } from '~/__mocks__/mockDscStatus'; import { mockInferenceServiceK8sResource } from '~/__mocks__/mockInferenceServiceK8sResource'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; -import { mockSecretK8sResource } from '~/__mocks__/mockSecretK8sResource'; +import { + mockCustomSecretK8sResource, + mockSecretK8sResource, +} from '~/__mocks__/mockSecretK8sResource'; import { configureBiasMetricModal, modelMetricsBias, @@ -12,11 +15,16 @@ import { modelMetricsPerformance, serverMetrics, } from '~/__tests__/cypress/cypress/pages/modelMetrics'; -import type { InferenceServiceKind, ServingRuntimeKind } from '~/k8sTypes'; +import type { + InferenceServiceKind, + SecretKind, + ServingRuntimeKind, + TrustyAIKind, +} from '~/k8sTypes'; import { mockPrometheusServing } from '~/__mocks__/mockPrometheusServing'; import { mockPrometheusBias } from '~/__mocks__/mockPrometheusBias'; import { mockMetricsRequest } from '~/__mocks__/mockMetricsRequests'; -import { mockTrustyAIServiceK8sResource } from '~/__mocks__/mockTrustyAIServiceK8sResource'; +import { mockTrustyAIServiceForDbK8sResource } from '~/__mocks__/mockTrustyAIServiceK8sResource'; import { mockRouteK8sResource } from '~/__mocks__/mockRouteK8sResource'; import { projectDetailsSettingsTab } from '~/__tests__/cypress/cypress/pages/projects'; import { mockServingRuntimeK8sResource } from '~/__mocks__/mockServingRuntimeK8sResource'; @@ -56,6 +64,21 @@ type HandlersProps = { isTrustyAIInstalled?: boolean; }; +const mockTrustyDBSecret = (): SecretKind => + mockCustomSecretK8sResource({ + name: 'test-secret', + namespace: 'test-project', + data: { + databaseKind: 'mariadb', + databaseUsername: 'trustyaiUsername', + databasePassword: 'trustyaiPassword', + databaseService: 'mariadb', + databasePort: '3306', + databaseName: 'trustyai_db', + databaseGeneration: 'update', + }, + }); + const initIntercepts = ({ disablePerformanceMetrics, disableBiasMetrics, @@ -174,8 +197,10 @@ const initIntercepts = ({ name: 'trustyai-service', }, isTrustyAIInstalled - ? mockTrustyAIServiceK8sResource({ + ? mockTrustyAIServiceForDbK8sResource({ isAvailable: isTrustyAIAvailable, + // If you're already installed for the test, it doesn't matter when + creationTimestamp: new Date('1970-01-01').toISOString(), }) : { statusCode: 404, body: mock404Error({}) }, ); @@ -345,10 +370,7 @@ describe('Model Metrics', () => { }); projectDetailsSettingsTab.visit('test-project'); - projectDetailsSettingsTab - .findTrustyAIInstallCheckbox() - .should('be.enabled') - .should('be.checked'); + projectDetailsSettingsTab.trustyai.findUninstallButton().should('be.enabled'); // test disabling cy.interceptK8s( @@ -361,17 +383,10 @@ describe('Model Metrics', () => { {}, ).as('uninstallTrustyAI'); - projectDetailsSettingsTab.findTrustyAIInstallCheckbox().uncheck(); - projectDetailsSettingsTab - .getTrustyAIUninstallModal() - .findSubmitButton() - .should('not.be.enabled'); - projectDetailsSettingsTab.getTrustyAIUninstallModal().findInput().type('trustyai'); - projectDetailsSettingsTab - .getTrustyAIUninstallModal() - .findSubmitButton() - .should('be.enabled') - .click(); + projectDetailsSettingsTab.trustyai.findUninstallButton().click(); + projectDetailsSettingsTab.trustyai.deleteModal.findSubmitButton().should('not.be.enabled'); + projectDetailsSettingsTab.trustyai.deleteModal.findInput().type('trustyai'); + projectDetailsSettingsTab.trustyai.deleteModal.findSubmitButton().should('be.enabled').click(); cy.wait('@uninstallTrustyAI'); }); @@ -386,29 +401,45 @@ describe('Model Metrics', () => { }); projectDetailsSettingsTab.visit('test-project'); - projectDetailsSettingsTab - .findTrustyAIInstallCheckbox() - .should('be.enabled') - .should('not.be.checked'); + projectDetailsSettingsTab.trustyai.findInstallButton().should('be.enabled'); // test enabling cy.interceptK8s( 'POST', TrustyAIApplicationsModel, - mockTrustyAIServiceK8sResource({ isAvailable: true }), + mockTrustyAIServiceForDbK8sResource({ isAvailable: true }), ).as('installTrustyAI'); + cy.interceptK8s(SecretModel, mockTrustyDBSecret()).as('getSecret'); + cy.interceptK8s( TrustyAIApplicationsModel, - mockTrustyAIServiceK8sResource({ + mockTrustyAIServiceForDbK8sResource({ isAvailable: false, }), ).as('getTrustyAILoading'); - projectDetailsSettingsTab.findTrustyAIInstallCheckbox().check(); + projectDetailsSettingsTab.trustyai.findInstallButton().click(); + + projectDetailsSettingsTab.trustyai.configureModal.findSubmitButton().should('not.be.enabled'); + + projectDetailsSettingsTab.trustyai.configureModal + .findExistingNameField() + .type('test-secret') + .blur(); + + // Test we get the secret for validation + cy.wait('@getSecret').then((interception) => { + expect(interception.response?.body.kind).to.be.eq('Secret'); + }); + + projectDetailsSettingsTab.trustyai.configureModal + .findSubmitButton() + .should('be.enabled') + .click(); cy.wait('@installTrustyAI').then((interception) => { - expect(interception.request.body).to.be.eql({ + expect(interception.request.body).to.containSubset({ apiVersion: 'trustyai.opendatahub.io/v1alpha1', kind: 'TrustyAIService', metadata: { @@ -417,19 +448,14 @@ describe('Model Metrics', () => { }, spec: { storage: { - format: 'PVC', - folder: '/inputs', - size: '1Gi', - }, - data: { - filename: 'data.csv', - format: 'CSV', + format: 'DATABASE', + databaseConfigurations: 'test-secret', }, metrics: { schedule: '5s', }, }, - }); + } satisfies TrustyAIKind); }); }); @@ -443,15 +469,12 @@ describe('Model Metrics', () => { }); projectDetailsSettingsTab.visit('test-project'); - projectDetailsSettingsTab - .findTrustyAIInstallCheckbox() - .should('be.enabled') - .should('not.be.checked'); + projectDetailsSettingsTab.trustyai.findInstallButton().should('be.enabled'); cy.interceptK8s( 'POST', TrustyAIApplicationsModel, - mockTrustyAIServiceK8sResource({ isAvailable: true }), + mockTrustyAIServiceForDbK8sResource({ isAvailable: true }), ).as('installTrustyAI'); cy.interceptK8s( @@ -463,68 +486,26 @@ describe('Model Metrics', () => { { statusCode: 403, body: mock403Error({}) }, ).as('getTrustyAIError'); - projectDetailsSettingsTab.findTrustyAIInstallCheckbox().check(); + projectDetailsSettingsTab.trustyai.findInstallButton().click(); - cy.wait('@installTrustyAI'); + projectDetailsSettingsTab.trustyai.configureModal.findSubmitButton().should('not.be.enabled'); - // test service error - cy.wait('@getTrustyAIError'); - - projectDetailsSettingsTab.findTrustyAIServiceError().should('exist'); - }); + projectDetailsSettingsTab.trustyai.configureModal + .findExistingNameField() + .type('test-secret') + .blur(); - it('Trusty AI enable timeout error', () => { - initIntercepts({ - disableBiasMetrics: false, - disablePerformanceMetrics: false, - hasServingData: false, - hasBiasData: false, - isTrustyAIInstalled: false, - }); - - projectDetailsSettingsTab.visit('test-project'); - projectDetailsSettingsTab - .findTrustyAIInstallCheckbox() + projectDetailsSettingsTab.trustyai.configureModal + .findSubmitButton() .should('be.enabled') - .should('not.be.checked'); - - cy.interceptK8s( - 'POST', - TrustyAIApplicationsModel, - mockTrustyAIServiceK8sResource({ isAvailable: true }), - ).as('installTrustyAI'); - - cy.interceptK8s( - TrustyAIApplicationsModel, - mockTrustyAIServiceK8sResource({ - isAvailable: false, - creationTimestamp: new Date('2022-05-15T00:00:00.000Z').toISOString(), - }), - ).as('getTrustyAITimeout'); - - projectDetailsSettingsTab.findTrustyAIInstallCheckbox().check(); + .click(); cy.wait('@installTrustyAI'); - // test timeout - timeout is a timestamp after 5 min - cy.wait('@getTrustyAITimeout'); - - projectDetailsSettingsTab.findTrustyAITimeoutError().should('exist'); - }); - - it('Trusty AI not supported', () => { - initIntercepts({ - disableBiasMetrics: false, - disablePerformanceMetrics: false, - hasServingData: false, - hasBiasData: false, - inferenceServices: [], - servingRuntimes: [], - enableModelMesh: false, - }); + // test service error + cy.wait('@getTrustyAIError'); - projectDetailsSettingsTab.visit('test-project'); - projectDetailsSettingsTab.findTrustyAIInstallCheckbox().should('not.be.enabled'); + projectDetailsSettingsTab.trustyai.findError().should('exist'); }); it('Bias Metrics Show In Table', () => { diff --git a/frontend/src/api/trustyai/k8s.ts b/frontend/src/api/trustyai/k8s.ts index 91bd5b9e4d..9f9b745e8a 100644 --- a/frontend/src/api/trustyai/k8s.ts +++ b/frontend/src/api/trustyai/k8s.ts @@ -9,21 +9,6 @@ import { TRUSTYAI_DEFINITION_NAME } from '~/concepts/trustyai/const'; import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; import { TrustyAIApplicationsModel } from '~/api/models/trustyai'; -const trustyAIDefaultCRSpec: TrustyAIKind['spec'] = { - storage: { - format: 'PVC', - folder: '/inputs', - size: '1Gi', - }, - data: { - filename: 'data.csv', - format: 'CSV', - }, - metrics: { - schedule: '5s', - }, -}; - export const getTrustyAICR = async ( namespace: string, opts?: K8sAPIOptions, @@ -43,6 +28,7 @@ export const getTrustyAICR = async ( export const createTrustyAICR = async ( namespace: string, + secretName: string, opts?: K8sAPIOptions, ): Promise => { const resource: TrustyAIKind = { @@ -52,7 +38,15 @@ export const createTrustyAICR = async ( name: TRUSTYAI_DEFINITION_NAME, namespace, }, - spec: trustyAIDefaultCRSpec, + spec: { + storage: { + format: 'DATABASE', + databaseConfigurations: secretName, + }, + metrics: { + schedule: '5s', + }, + }, }; return k8sCreateResource( diff --git a/frontend/src/components/FieldGroupHelpLabelIcon.tsx b/frontend/src/components/FieldGroupHelpLabelIcon.tsx new file mode 100644 index 0000000000..24503a0286 --- /dev/null +++ b/frontend/src/components/FieldGroupHelpLabelIcon.tsx @@ -0,0 +1,16 @@ +import * as React from 'react'; +import { Popover } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; +import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; + +type FieldGroupHelpLabelIconProps = { + content: React.ComponentProps['bodyContent']; +}; + +const FieldGroupHelpLabelIcon: React.FC = ({ content }) => ( + + } aria-label="More info" /> + +); + +export default FieldGroupHelpLabelIcon; diff --git a/frontend/src/concepts/k8s/ResourceNameDefinitionTooltip.tsx b/frontend/src/concepts/k8s/ResourceNameDefinitionTooltip.tsx index d8d58577eb..1c596b7aa0 100644 --- a/frontend/src/concepts/k8s/ResourceNameDefinitionTooltip.tsx +++ b/frontend/src/concepts/k8s/ResourceNameDefinitionTooltip.tsx @@ -1,19 +1,16 @@ import * as React from 'react'; -import { Popover, Stack, StackItem } from '@patternfly/react-core'; -import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons'; -import DashboardPopupIconButton from '~/concepts/dashboard/DashboardPopupIconButton'; +import { Stack, StackItem } from '@patternfly/react-core'; +import FieldGroupHelpLabelIcon from '~/components/FieldGroupHelpLabelIcon'; const ResourceNameDefinitionTooltip: React.FC = () => ( - Resource names are what your resources are labeled in OpenShift. Resource names are not editable after creation. } - > - } aria-label="More info" /> - + /> ); export default ResourceNameDefinitionTooltip; diff --git a/frontend/src/concepts/k8s/utils.ts b/frontend/src/concepts/k8s/utils.ts index 6a710d1e34..5d4db8d69b 100644 --- a/frontend/src/concepts/k8s/utils.ts +++ b/frontend/src/concepts/k8s/utils.ts @@ -1,5 +1,5 @@ import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; -import { K8sDSGResource } from '~/k8sTypes'; +import { K8sCondition, K8sDSGResource } from '~/k8sTypes'; import { genRandomChars } from '~/utilities/string'; export const PreInstalledName = 'Pre-installed'; @@ -108,3 +108,16 @@ export const translateDisplayNameForK8s = ( export const isValidK8sName = (name?: string): boolean => name === undefined || (name.length > 0 && /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)); + +type ResourceWithConditions = K8sResourceCommon & { status?: { conditions?: K8sCondition[] } }; + +export const getConditionForType = ( + resource: ResourceWithConditions, + type: string, +): K8sCondition | undefined => resource.status?.conditions?.find((c) => c.type === type); + +export const isConditionInStatus = ( + resource: ResourceWithConditions, + type: string, + status: string, +): boolean => getConditionForType(resource, type)?.status === status; diff --git a/frontend/src/concepts/trustyai/const.ts b/frontend/src/concepts/trustyai/const.ts index fd807c97eb..c030852562 100644 --- a/frontend/src/concepts/trustyai/const.ts +++ b/frontend/src/concepts/trustyai/const.ts @@ -1,3 +1,6 @@ -export const TRUSTYAI_ROUTE_NAME = 'trustyai-service'; - export const TRUSTYAI_DEFINITION_NAME = 'trustyai-service'; + +export const TRUSTYAI_SECRET_NAME = 'trustyai-db-secret'; + +// Note: This is duped in the tests by the same name -- see it for more information +export const TRUSTYAI_INSTALL_MODAL_TEST_ID = 'trusty-db-config'; diff --git a/frontend/src/concepts/trustyai/content/InstallTrustyAICheckbox.tsx b/frontend/src/concepts/trustyai/content/InstallTrustyAICheckbox.tsx deleted file mode 100644 index c51553f6e1..0000000000 --- a/frontend/src/concepts/trustyai/content/InstallTrustyAICheckbox.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from 'react'; -import { Checkbox, HelperText, HelperTextItem } from '@patternfly/react-core'; -import { TRUSTYAI_TOOLTIP_TEXT } from '~/pages/projects/projectSettings/const'; -import DeleteTrustyAIModal from '~/concepts/trustyai/content/DeleteTrustyAIModal'; - -type InstallTrustyAICheckboxProps = { - isAvailable: boolean; - isProgressing: boolean; - onInstall: () => Promise; - onDelete: () => Promise; - disabled: boolean; - disabledReason?: string; -}; -const InstallTrustyAICheckbox: React.FC = ({ - isAvailable, - isProgressing, - onInstall, - onDelete, - disabled, - disabledReason, -}) => { - const [open, setOpen] = React.useState(false); - const [userHasChecked, setUserHasChecked] = React.useState(false); - - const helperText = disabled ? disabledReason : TRUSTYAI_TOOLTIP_TEXT; - - return ( - <> - - {helperText} - - } - isChecked={!disabled && isAvailable} - isDisabled={disabled || userHasChecked || isProgressing} - onChange={(e, checked) => { - if (checked) { - setUserHasChecked(true); - onInstall().finally(() => setUserHasChecked(false)); - } else { - setOpen(true); - } - }} - id="trustyai-service-installation" - data-testid="trustyai-service-installation" - name="TrustyAI service installation status" - /> - {open ? ( - { - setOpen(false); - }} - /> - ) : null} - - ); -}; - -export default InstallTrustyAICheckbox; diff --git a/frontend/src/concepts/trustyai/content/InstallTrustyModal.tsx b/frontend/src/concepts/trustyai/content/InstallTrustyModal.tsx new file mode 100644 index 0000000000..2db15906a9 --- /dev/null +++ b/frontend/src/concepts/trustyai/content/InstallTrustyModal.tsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import { Form, Modal, Radio } from '@patternfly/react-core'; +import DashboardModalFooter from '~/concepts/dashboard/DashboardModalFooter'; +import TrustyDBSecretFields from '~/concepts/trustyai/content/TrustyDBSecretFields'; +import useTrustyInstallModalData, { + TrustyInstallModalFormType, +} from '~/concepts/trustyai/content/useTrustyInstallModalData'; +import { UseManageTrustyAICRReturnType } from '~/concepts/trustyai/useManageTrustyAICR'; +import FieldGroupHelpLabelIcon from '~/components/FieldGroupHelpLabelIcon'; +import TrustyDBExistingSecretField from '~/concepts/trustyai/content/TrustyDBExistingSecretField'; +import { TRUSTYAI_INSTALL_MODAL_TEST_ID } from '~/concepts/trustyai/const'; + +type InstallTrustyModalProps = { + onClose: () => void; + namespace: string; + onInstallExistingDB: UseManageTrustyAICRReturnType['installCRForExistingDB']; + onInstallNewDB: UseManageTrustyAICRReturnType['installCRForNewDB']; +}; + +const InstallTrustyModal: React.FC = ({ + onClose, + namespace, + onInstallNewDB, + onInstallExistingDB, +}) => { + const [submitting, setSubmitting] = React.useState(false); + const [installError, setInstallError] = React.useState(); + const formData = useTrustyInstallModalData(namespace); + + return ( + { + let promise: Promise; + if (formData.type === TrustyInstallModalFormType.EXISTING) { + promise = onInstallExistingDB(formData.data); + } else { + promise = onInstallNewDB(formData.data); + } + setSubmitting(true); + setInstallError(undefined); + promise + .then(() => { + onClose(); + }) + .catch((e) => { + setInstallError(e); + }) + .finally(() => { + setSubmitting(false); + }); + }} + submitLabel="Configure" + isSubmitLoading={submitting} + isSubmitDisabled={!formData.canSubmit || submitting} + error={installError} + alertTitle="Install error" + /> + } + > +
{ + e.preventDefault(); + }} + > + + Specify an existing secret{' '} + + + } + name="secret-value" + isChecked={formData.type === TrustyInstallModalFormType.EXISTING} + onChange={() => formData.onModeChange(TrustyInstallModalFormType.EXISTING)} + body={ + formData.type === TrustyInstallModalFormType.EXISTING && ( + + ) + } + /> + + Create a new secret{' '} + + + } + name="secret-value" + isChecked={formData.type === TrustyInstallModalFormType.NEW} + onChange={() => formData.onModeChange(TrustyInstallModalFormType.NEW)} + body={ + formData.type === TrustyInstallModalFormType.NEW && ( + + ) + } + /> + +
+ ); +}; + +export default InstallTrustyModal; diff --git a/frontend/src/concepts/trustyai/content/TrustyAIServerTimedOutError.tsx b/frontend/src/concepts/trustyai/content/TrustyAIServerTimedOutError.tsx deleted file mode 100644 index a53d472cf9..0000000000 --- a/frontend/src/concepts/trustyai/content/TrustyAIServerTimedOutError.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from 'react'; -import { - Alert, - AlertActionCloseButton, - AlertActionLink, - Stack, - StackItem, -} from '@patternfly/react-core'; - -type TrustyAITimedOutErrorProps = { - ignoreTimedOut: () => void; - deleteCR: () => Promise; -}; -const TrustyAITimedOutError: React.FC = ({ - ignoreTimedOut, - deleteCR, -}) => ( - ignoreTimedOut()} />} - actionLinks={ - <> - deleteCR()}>Uninstall TrustyAI - ignoreTimedOut()}>Close - - } - > - - - An error occurred while installing or loading TrustyAI. To continue, uninstall and reinstall - TrustyAI. Uninstalling this service will delete all of its resources, including model bias - configurations. - - For help, contact your administrator. - - -); - -export default TrustyAITimedOutError; diff --git a/frontend/src/concepts/trustyai/content/TrustyAIServiceControl.tsx b/frontend/src/concepts/trustyai/content/TrustyAIServiceControl.tsx deleted file mode 100644 index 5f0fe2de14..0000000000 --- a/frontend/src/concepts/trustyai/content/TrustyAIServiceControl.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { Bullseye, Spinner, Stack, StackItem } from '@patternfly/react-core'; -import React from 'react'; -import useManageTrustyAICR from '~/concepts/trustyai/useManageTrustyAICR'; -import TrustyAIServiceNotification from '~/concepts/trustyai/content/TrustyAIServiceNotification'; -import InstallTrustyAICheckbox from './InstallTrustyAICheckbox'; - -type TrustyAIServiceControlProps = { - namespace: string; - disabled: boolean; - disabledReason?: string; -}; -const TrustyAIServiceControl: React.FC = ({ - namespace, - disabled, - disabledReason, -}) => { - const { - isAvailable, - isProgressing, - showSuccess, - installCR, - deleteCR, - error, - isSettled, - serverTimedOut, - ignoreTimedOut, - } = useManageTrustyAICR(namespace); - - const [userStartedInstall, setUserStartedInstall] = React.useState(false); - - if (!disabled && !isSettled) { - return ( - - - - ); - } - - return ( - - - { - setUserStartedInstall(true); - return installCR().finally(() => setUserStartedInstall(false)); - }} - onDelete={deleteCR} - /> - - - - - - ); -}; - -export default TrustyAIServiceControl; diff --git a/frontend/src/concepts/trustyai/content/TrustyAIServiceNotification.tsx b/frontend/src/concepts/trustyai/content/TrustyAIServiceNotification.tsx deleted file mode 100644 index dbe1e7f6ac..0000000000 --- a/frontend/src/concepts/trustyai/content/TrustyAIServiceNotification.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { Alert, AlertActionCloseButton, Bullseye, Spinner } from '@patternfly/react-core'; -import React from 'react'; -import TrustyAITimedOutError from '~/concepts/trustyai/content/TrustyAIServerTimedOutError'; - -type TrustyAIServiceNotificationProps = { - error?: Error; - isAvailable: boolean; - showSuccess: boolean; - loading: boolean; - timedOut: boolean; - ignoreTimedOut: () => void; - deleteCR: () => Promise; -}; - -const TrustyAIServiceNotification: React.FC = ({ - error, - isAvailable, - showSuccess, - loading, - timedOut, - ignoreTimedOut, - deleteCR, -}) => { - const [dismissSuccess, setDismissSuccess] = React.useState(false); - - React.useEffect(() => { - if (loading) { - setDismissSuccess(false); - } - }, [loading]); - - if (timedOut) { - return ; - } - - if (loading) { - return ( - - - - ); - } - - if (!dismissSuccess && showSuccess && isAvailable) { - return ( - setDismissSuccess(true)} />} - isLiveRegion - isInline - /> - ); - } - - if (error) { - return ( - - {error.message} - - ); - } - - return null; -}; - -export default TrustyAIServiceNotification; diff --git a/frontend/src/concepts/trustyai/content/TrustyDBExistingSecretField.tsx b/frontend/src/concepts/trustyai/content/TrustyDBExistingSecretField.tsx new file mode 100644 index 0000000000..08d0aa239a --- /dev/null +++ b/frontend/src/concepts/trustyai/content/TrustyDBExistingSecretField.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { HelperText, HelperTextItem, TextInput } from '@patternfly/react-core'; +import { + TrustyInstallModalFormExistingState, + UseTrustyInstallModalDataExisting, +} from '~/concepts/trustyai/content/useTrustyInstallModalData'; +import { TRUSTYAI_INSTALL_MODAL_TEST_ID } from '~/concepts/trustyai/const'; +import useDebounceCallback from '~/utilities/useDebounceCallback'; + +type TrustyDBExistingSecretFieldProps = { + formData: UseTrustyInstallModalDataExisting; +}; + +const TrustyDBExistingSecretField: React.FC = ({ + formData: { data, onCheckState, onDataChange, state }, +}) => { + const delayCheckState = useDebounceCallback(onCheckState, 3000); + + let helperText: + | { + message: string; + variant: React.ComponentProps['variant']; + } + | undefined; + let inputState: React.ComponentProps['validated']; + switch (state) { + case TrustyInstallModalFormExistingState.NOT_FOUND: + helperText = { + message: 'No match found. Check your entry and try again.', + variant: 'error', + }; + inputState = 'error'; + break; + case TrustyInstallModalFormExistingState.EXISTING: + inputState = 'success'; + break; + case TrustyInstallModalFormExistingState.CHECKING: + helperText = { + message: 'Checking for secret...', + variant: 'default', + }; + break; + case TrustyInstallModalFormExistingState.UNSURE: + helperText = { + message: 'Unable to validate the secret', + variant: 'warning', + }; + inputState = 'warning'; + break; + case TrustyInstallModalFormExistingState.UNKNOWN: + default: + inputState = 'default'; + } + + return ( + <> + { + delayCheckState(); + onDataChange(value); + }} + onBlur={() => { + delayCheckState.cancel(); + onCheckState(); + }} + validated={inputState} + /> + {helperText && ( + + {helperText.message} + + )} + + ); +}; + +export default TrustyDBExistingSecretField; diff --git a/frontend/src/concepts/trustyai/content/TrustyDBSecretFields.tsx b/frontend/src/concepts/trustyai/content/TrustyDBSecretFields.tsx new file mode 100644 index 0000000000..f126c0d9ef --- /dev/null +++ b/frontend/src/concepts/trustyai/content/TrustyDBSecretFields.tsx @@ -0,0 +1,100 @@ +import * as React from 'react'; +import { FormGroup, FormSection, TextInput } from '@patternfly/react-core'; +import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; +import { TrustyDBData } from '~/concepts/trustyai/types'; +import FieldGroupHelpLabelIcon from '~/components/FieldGroupHelpLabelIcon'; +import { TRUSTYAI_INSTALL_MODAL_TEST_ID } from '~/concepts/trustyai/const'; +import PasswordInput from '~/components/PasswordInput'; + +type TrustyDBSecretFieldsProps = { + data: TrustyDBData; + onDataChange: UpdateObjectAtPropAndValue; +}; + +const TrustyField: React.FC<{ + data: TrustyDBData; + id: keyof TrustyDBData; + label: string; + labelTooltip: string; + onChange: TrustyDBSecretFieldsProps['onDataChange']; +}> = ({ data, id, label, labelTooltip, onChange }) => { + const value = data[id]; + const Component = id === 'databasePassword' ? PasswordInput : TextInput; + + return ( + } + isRequired + fieldId={`${TRUSTYAI_INSTALL_MODAL_TEST_ID}-${id}`} + > + { + onChange(id, newValue); + }} + /> + + ); +}; + +const TrustyDBSecretFields: React.FC = ({ data, onDataChange }) => ( + + + + + + + + + +); + +export default TrustyDBSecretFields; diff --git a/frontend/src/concepts/trustyai/content/statusStates/TrustyAIInstalledState.tsx b/frontend/src/concepts/trustyai/content/statusStates/TrustyAIInstalledState.tsx new file mode 100644 index 0000000000..3b9ffeed9a --- /dev/null +++ b/frontend/src/concepts/trustyai/content/statusStates/TrustyAIInstalledState.tsx @@ -0,0 +1,32 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; +import DeleteTrustyAIModal from '~/concepts/trustyai/content/DeleteTrustyAIModal'; + +type TrustyAIInstalledStateProps = { + uninstalling?: boolean; + onDelete: () => Promise; +}; + +const TrustyAIInstalledState: React.FC = ({ + uninstalling, + onDelete, +}) => { + const [openModal, setOpenModal] = React.useState(false); + + return ( + <> + + {openModal && setOpenModal(false)} />} + + ); +}; + +export default TrustyAIInstalledState; diff --git a/frontend/src/concepts/trustyai/content/statusStates/TrustyAIUninstalledState.tsx b/frontend/src/concepts/trustyai/content/statusStates/TrustyAIUninstalledState.tsx new file mode 100644 index 0000000000..b982fb82bf --- /dev/null +++ b/frontend/src/concepts/trustyai/content/statusStates/TrustyAIUninstalledState.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { Button } from '@patternfly/react-core'; +import InstallTrustyModal from '~/concepts/trustyai/content/InstallTrustyModal'; +import { UseManageTrustyAICRReturnType } from '~/concepts/trustyai/useManageTrustyAICR'; + +type TrustyAIUninstalledStateProps = { + namespace: string; + onInstallExistingDB: UseManageTrustyAICRReturnType['installCRForExistingDB']; + onInstallNewDB: UseManageTrustyAICRReturnType['installCRForNewDB']; +}; + +const TrustyAIUninstalledState: React.FC = ({ + namespace, + onInstallExistingDB, + onInstallNewDB, +}) => { + const [openModal, setOpenModal] = React.useState(false); + + return ( + <> + + {openModal && ( + setOpenModal(false)} + /> + )} + + ); +}; + +export default TrustyAIUninstalledState; diff --git a/frontend/src/concepts/trustyai/content/useTrustyBrowserStorage.ts b/frontend/src/concepts/trustyai/content/useTrustyBrowserStorage.ts new file mode 100644 index 0000000000..a4c7a8e224 --- /dev/null +++ b/frontend/src/concepts/trustyai/content/useTrustyBrowserStorage.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; +import { useBrowserStorage } from '~/components/browserStorage'; +import { TrustyAIKind } from '~/k8sTypes'; + +/** { [namespace]: uid } */ +type TrustyStorageState = Record; + +export type UseTrustyBrowserStorage = { + showSuccess: boolean; + onDismissSuccess: () => void; +}; + +const useTrustyBrowserStorage = (cr?: TrustyAIKind | null): UseTrustyBrowserStorage => { + const [trustyStorageState, setTrustyStorageState] = useBrowserStorage( + 'odh.dashboard.project.trusty', + {}, + ); + const [showSuccess, setShowSuccess] = React.useState(false); + + const { namespace, uid } = cr?.metadata || {}; + + // Ignore watching this value for changes in the hooks + const storageRef = React.useRef(trustyStorageState); + storageRef.current = trustyStorageState; + + React.useEffect(() => { + if (namespace && uid) { + const matchingUID = storageRef.current[namespace]; + if (matchingUID !== uid) { + // Don't have a dismiss state or it does not match the current instance + setShowSuccess(true); + } else if (uid) { + // If somehow it's showing and dismissed, hide it + setShowSuccess(false); + } + } + }, [namespace, uid]); + + const onDismissSuccess = React.useCallback(() => { + // Immediate feedback + setShowSuccess(false); + + if (!namespace) { + // Likely improperly called -- shouldn't be able to dismiss a situation without namespace + return; + } + + // Update the state + setTrustyStorageState({ ...storageRef.current, [namespace]: uid }); + }, [namespace, setTrustyStorageState, uid]); + + return { + showSuccess, + onDismissSuccess, + }; +}; + +export default useTrustyBrowserStorage; diff --git a/frontend/src/concepts/trustyai/content/useTrustyCRState.tsx b/frontend/src/concepts/trustyai/content/useTrustyCRState.tsx new file mode 100644 index 0000000000..f8255643de --- /dev/null +++ b/frontend/src/concepts/trustyai/content/useTrustyCRState.tsx @@ -0,0 +1,96 @@ +import * as React from 'react'; +import { + Alert, + AlertActionCloseButton, + Skeleton, + Spinner, + Split, + SplitItem, +} from '@patternfly/react-core'; +import useManageTrustyAICR from '~/concepts/trustyai/useManageTrustyAICR'; +import { TrustyInstallState } from '~/concepts/trustyai/types'; +import TrustyAIInstalledState from '~/concepts/trustyai/content/statusStates/TrustyAIInstalledState'; +import TrustyAIUninstalledState from '~/concepts/trustyai/content/statusStates/TrustyAIUninstalledState'; +import { ProjectKind } from '~/k8sTypes'; + +type UseTrustyCRState = { + action: React.ReactNode; + status?: React.ReactNode; +}; + +const useTrustyCRState = (project: ProjectKind): UseTrustyCRState => { + const namespace = project.metadata.name; + const { statusState, installCRForExistingDB, installCRForNewDB, deleteCR } = + useManageTrustyAICR(namespace); + + let action: React.ReactNode; + let status: React.ReactNode; + switch (statusState.type) { + case TrustyInstallState.INFRA_ERROR: + case TrustyInstallState.CR_ERROR: + action = ; + status = ( + +

{statusState.message}

+

Uninstall and try again, or contact your administrator.

+
+ ); + break; + case TrustyInstallState.INSTALLED: + action = ; + status = statusState.showSuccess && ( + + ) + } + > + You can view TrustyAI metrics in the model metrics screen. If you need to make changes, + delete the deployment and start over. + + ); + break; + case TrustyInstallState.LOADING_INITIAL_STATE: + action = ; + break; + case TrustyInstallState.INSTALLING: + action = ; + status = ( + + + + + Installing TrustyAI... + + ); + break; + case TrustyInstallState.UNINSTALLING: + action = ; + break; + case TrustyInstallState.UNINSTALLED: + default: + action = ( + + ); + } + + return { action, status }; +}; + +export default useTrustyCRState; diff --git a/frontend/src/concepts/trustyai/content/useTrustyInstallModalData.tsx b/frontend/src/concepts/trustyai/content/useTrustyInstallModalData.tsx new file mode 100644 index 0000000000..5815c3b65e --- /dev/null +++ b/frontend/src/concepts/trustyai/content/useTrustyInstallModalData.tsx @@ -0,0 +1,135 @@ +import * as React from 'react'; +import useGenericObjectState from '~/utilities/useGenericObjectState'; +import { TrustyDBData } from '~/concepts/trustyai/types'; +import { UpdateObjectAtPropAndValue } from '~/pages/projects/types'; +import { getSecret } from '~/api'; + +export enum TrustyInstallModalFormType { + EXISTING = 'existing', + NEW = 'new', +} + +export enum TrustyInstallModalFormExistingState { + EXISTING = 'valid', + NOT_FOUND = 'invalid', + CHECKING = 'checking', + UNSURE = 'unsure', + UNKNOWN = 'unknown', +} +const EXISTING_NAME_FAIL_STATES = [ + TrustyInstallModalFormExistingState.NOT_FOUND, + TrustyInstallModalFormExistingState.CHECKING, + TrustyInstallModalFormExistingState.UNKNOWN, +]; + +type UseTrustyInstallModalDataBase = { + canSubmit: boolean; + onModeChange: (mode: TrustyInstallModalFormType) => void; +}; +export type UseTrustyInstallModalDataNew = UseTrustyInstallModalDataBase & { + type: TrustyInstallModalFormType.NEW; + data: TrustyDBData; + onDataChange: UpdateObjectAtPropAndValue; +}; +export type UseTrustyInstallModalDataExisting = UseTrustyInstallModalDataBase & { + type: TrustyInstallModalFormType.EXISTING; + data: string; + onDataChange: (newValue: string) => void; + state: TrustyInstallModalFormExistingState; + onCheckState: () => void; +}; + +type UseTrustyInstallModalData = UseTrustyInstallModalDataNew | UseTrustyInstallModalDataExisting; + +const useTrustyInstallModalData = (namespace: string): UseTrustyInstallModalData => { + const [mode, setMode] = React.useState( + TrustyInstallModalFormType.EXISTING, + ); + + const [existingValue, setExistingValue] = React.useState(''); + const [existingValid, setExistingValid] = React.useState( + TrustyInstallModalFormExistingState.UNKNOWN, + ); + const onExistingChange = React.useCallback( + (value) => { + setExistingValue(value); + setExistingValid(TrustyInstallModalFormExistingState.UNKNOWN); + }, + [], + ); + + // Prevent existing value from impacting usage of onCheckState + const existingValueRef = React.useRef(existingValue); + existingValueRef.current = existingValue; + // Allow to abort any existing on-going calls if needed + const abortRef = React.useRef(null); + const onCheckState = React.useCallback(() => { + const valueToSubmit = existingValueRef.current; + if (valueToSubmit) { + if (abortRef.current) { + // Existing abort controller, cancel it before making a new one + abortRef.current.abort(); + abortRef.current = null; + } + + const abortController = new AbortController(); + abortRef.current = abortController; + + setExistingValid(TrustyInstallModalFormExistingState.CHECKING); + getSecret(namespace, valueToSubmit, { signal: abortController.signal }) + .then(() => { + setExistingValid(TrustyInstallModalFormExistingState.EXISTING); + }) + .catch((e) => { + if (e.statusObject?.code === 404) { + setExistingValid(TrustyInstallModalFormExistingState.NOT_FOUND); + return; + } + + // eslint-disable-next-line no-console + console.error('TrustyAI: Unknown error while validating the secret', e); + setExistingValid(TrustyInstallModalFormExistingState.UNSURE); + }) + .finally(() => { + abortRef.current = null; + }); + } else { + setExistingValid(TrustyInstallModalFormExistingState.UNKNOWN); + } + }, [namespace]); + + const [dbFormData, onDbFormDataChange] = useGenericObjectState({ + databaseKind: 'mariadb', + databaseUsername: '', + databasePassword: '', + databaseService: 'mariadb', + databasePort: '3306', + databaseName: '', + databaseGeneration: 'update', + }); + + switch (mode) { + case TrustyInstallModalFormType.NEW: + return { + onModeChange: setMode, + canSubmit: Object.values(dbFormData).every((v) => !!v), + type: TrustyInstallModalFormType.NEW, + data: dbFormData, + onDataChange: onDbFormDataChange, + }; + case TrustyInstallModalFormType.EXISTING: + default: + return { + onModeChange: setMode, + canSubmit: + existingValue.trim().length > 0 && !EXISTING_NAME_FAIL_STATES.includes(existingValid), + type: TrustyInstallModalFormType.EXISTING, + data: existingValue, + onDataChange: onExistingChange, + state: existingValid, + onCheckState, + }; + } +}; + +export default useTrustyInstallModalData; diff --git a/frontend/src/concepts/trustyai/context/TrustyAIContext.tsx b/frontend/src/concepts/trustyai/context/TrustyAIContext.tsx index ba3e956397..6db6c9cd26 100644 --- a/frontend/src/concepts/trustyai/context/TrustyAIContext.tsx +++ b/frontend/src/concepts/trustyai/context/TrustyAIContext.tsx @@ -1,20 +1,16 @@ import React from 'react'; -import useTrustyAINamespaceCR, { - isTrustyAIAvailable, - taiHasServerTimedOut, -} from '~/concepts/trustyai/useTrustyAINamespaceCR'; +import useTrustyAINamespaceCR from '~/concepts/trustyai/useTrustyAINamespaceCR'; import useTrustyAIAPIState, { TrustyAPIState } from '~/concepts/trustyai/useTrustyAIAPIState'; import { TrustyAIContextData } from '~/concepts/trustyai/context/types'; import { DEFAULT_CONTEXT_DATA } from '~/concepts/trustyai/context/const'; import useFetchContextData from '~/concepts/trustyai/context/useFetchContextData'; +import { getTrustyStatusState } from '~/concepts/trustyai/utils'; +import { TrustyInstallState, TrustyStatusStates } from '~/concepts/trustyai/types'; +import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; type TrustyAIContextProps = { namespace: string; - hasCR: boolean; - crInitializing: boolean; - serverTimedOut: boolean; - serviceLoadError?: Error; - ignoreTimedOut: () => void; + statusState: TrustyStatusStates; refreshState: () => Promise; refreshAPIState: () => void; apiState: TrustyAPIState; @@ -23,10 +19,7 @@ type TrustyAIContextProps = { export const TrustyAIContext = React.createContext({ namespace: '', - hasCR: false, - crInitializing: false, - serverTimedOut: false, - ignoreTimedOut: () => undefined, + statusState: { type: TrustyInstallState.LOADING_INITIAL_STATE }, data: DEFAULT_CONTEXT_DATA, refreshState: async () => undefined, refreshAPIState: () => undefined, @@ -43,13 +36,8 @@ export const TrustyAIContextProvider: React.FC = ( namespace, }) => { const crState = useTrustyAINamespaceCR(namespace); - const [trustyNamespaceCR, crLoaded, crLoadError, refreshCR] = crState; - const isCRReady = isTrustyAIAvailable(crState); - const [disableTimeout, setDisableTimeout] = React.useState(false); - const serverTimedOut = !disableTimeout && taiHasServerTimedOut(crState, isCRReady); - const ignoreTimedOut = React.useCallback(() => { - setDisableTimeout(true); - }, []); + const [trustyNamespaceCR, crLoaded, , refreshCR] = crState; + const statusState = useDeepCompareMemoize(getTrustyStatusState(crState)); const taisName = trustyNamespaceCR?.metadata.name; @@ -66,9 +54,7 @@ export const TrustyAIContextProvider: React.FC = ( namespace, hasCR: !!trustyNamespaceCR, crInitializing: !crLoaded, - serverTimedOut, - ignoreTimedOut, - crLoadError, + statusState, refreshState, refreshAPIState, apiState, @@ -78,9 +64,7 @@ export const TrustyAIContextProvider: React.FC = ( namespace, trustyNamespaceCR, crLoaded, - serverTimedOut, - ignoreTimedOut, - crLoadError, + statusState, refreshState, refreshAPIState, apiState, diff --git a/frontend/src/concepts/trustyai/context/useDoesTrustyAICRExist.ts b/frontend/src/concepts/trustyai/context/useDoesTrustyAICRExist.ts index 3faac3cf49..f254fa0aa6 100644 --- a/frontend/src/concepts/trustyai/context/useDoesTrustyAICRExist.ts +++ b/frontend/src/concepts/trustyai/context/useDoesTrustyAICRExist.ts @@ -1,10 +1,13 @@ import React from 'react'; import { TrustyAIContext } from '~/concepts/trustyai/context/TrustyAIContext'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import { TRUSTY_CR_NOT_AVAILABLE_STATES } from '~/concepts/trustyai/types'; const useDoesTrustyAICRExist = (): boolean[] => { const trustyAIAreaAvailable = useIsAreaAvailable(SupportedArea.TRUSTY_AI).status; - const { hasCR } = React.useContext(TrustyAIContext); + const { statusState } = React.useContext(TrustyAIContext); + + const hasCR = !TRUSTY_CR_NOT_AVAILABLE_STATES.includes(statusState.type); return [trustyAIAreaAvailable && hasCR]; }; diff --git a/frontend/src/concepts/trustyai/context/useModelBiasData.ts b/frontend/src/concepts/trustyai/context/useModelBiasData.ts index 353c52f527..4649bee87d 100644 --- a/frontend/src/concepts/trustyai/context/useModelBiasData.ts +++ b/frontend/src/concepts/trustyai/context/useModelBiasData.ts @@ -1,25 +1,17 @@ import React from 'react'; import { useParams } from 'react-router-dom'; -import { BiasMetricConfig } from '~/concepts/trustyai/types'; +import { BiasMetricConfig, TrustyStatusStates } from '~/concepts/trustyai/types'; import { TrustyAIContext } from '~/concepts/trustyai/context/TrustyAIContext'; export type ModelBiasData = { biasMetricConfigs: BiasMetricConfig[]; - serviceStatus: { initializing: boolean; installed: boolean; timedOut: boolean }; - loaded: boolean; - loadError?: Error; + statusState: TrustyStatusStates; refresh: () => Promise; }; export const useModelBiasData = (): ModelBiasData => { const { inferenceService } = useParams(); - const { data, crInitializing, hasCR, serverTimedOut } = React.useContext(TrustyAIContext); - - const serviceStatus: ModelBiasData['serviceStatus'] = { - initializing: crInitializing, - installed: hasCR, - timedOut: serverTimedOut, - }; + const { data, statusState } = React.useContext(TrustyAIContext); const biasMetricConfigs = React.useMemo(() => { let configs: BiasMetricConfig[] = []; @@ -32,10 +24,8 @@ export const useModelBiasData = (): ModelBiasData => { }, [data.biasMetricConfigs, data.loaded, inferenceService]); return { - serviceStatus, + statusState, biasMetricConfigs, - loaded: data.loaded, - loadError: data.error, refresh: data.refresh, }; }; diff --git a/frontend/src/concepts/trustyai/types.ts b/frontend/src/concepts/trustyai/types.ts index 990a50f146..c3d599c82e 100644 --- a/frontend/src/concepts/trustyai/types.ts +++ b/frontend/src/concepts/trustyai/types.ts @@ -6,6 +6,41 @@ import { } from '~/api'; import { K8sAPIOptions } from '~/k8sTypes'; +export enum TrustyInstallState { + UNINSTALLING = 'uninstalling', + INSTALLED = 'installed', + INSTALLING = 'installing', + /** Unrelated to Trusty error / infra failed, network issue, etc */ + INFRA_ERROR = 'infra-error', + /** Specific error with the CR */ + CR_ERROR = 'error', + UNINSTALLED = 'uninstalled', + LOADING_INITIAL_STATE = 'unknown', +} +export const TRUSTY_CR_NOT_AVAILABLE_STATES = [ + TrustyInstallState.UNINSTALLED, + TrustyInstallState.LOADING_INITIAL_STATE, +]; + +export type TrustyStatusStates = + | { type: TrustyInstallState.CR_ERROR | TrustyInstallState.INFRA_ERROR; message: string } + | { type: TrustyInstallState.LOADING_INITIAL_STATE } + | { type: TrustyInstallState.INSTALLED; showSuccess: boolean; onDismissSuccess?: () => void } + | { type: TrustyInstallState.INSTALLING } + | { type: TrustyInstallState.UNINSTALLING } + | { type: TrustyInstallState.UNINSTALLED }; + +/** Structure matches K8s Secret structure */ +export type TrustyDBData = { + databaseKind: string; + databaseUsername: string; + databasePassword: string; + databaseService: string; + databasePort: string; + databaseName: string; + databaseGeneration: string; +}; + export type ListRequests = (opts: K8sAPIOptions) => Promise; export type ListSpdRequests = (opts: K8sAPIOptions) => Promise; export type ListDirRequests = (opts: K8sAPIOptions) => Promise; diff --git a/frontend/src/concepts/trustyai/useManageTrustyAICR.ts b/frontend/src/concepts/trustyai/useManageTrustyAICR.ts index e4034d397d..5d7e576e04 100644 --- a/frontend/src/concepts/trustyai/useManageTrustyAICR.ts +++ b/frontend/src/concepts/trustyai/useManageTrustyAICR.ts @@ -1,64 +1,78 @@ import React from 'react'; -import useTrustyAINamespaceCR, { - isTrustyAIAvailable, - taiHasServerTimedOut, -} from '~/concepts/trustyai/useTrustyAINamespaceCR'; -import { createTrustyAICR, deleteTrustyAICR } from '~/api'; +import useTrustyAINamespaceCR from '~/concepts/trustyai/useTrustyAINamespaceCR'; +import { + assembleSecret, + createSecret, + createTrustyAICR, + deleteSecret, + deleteTrustyAICR, +} from '~/api'; +import { getTrustyStatusState } from '~/concepts/trustyai/utils'; +import { TRUSTYAI_SECRET_NAME } from '~/concepts/trustyai/const'; +import useTrustyBrowserStorage from '~/concepts/trustyai/content/useTrustyBrowserStorage'; +import { TrustyDBData, TrustyStatusStates } from './types'; + +export type UseManageTrustyAICRReturnType = { + statusState: TrustyStatusStates; + installCRForNewDB: (secretData: TrustyDBData) => Promise; + installCRForExistingDB: (secretName: string) => Promise; + deleteCR: () => Promise; +}; const useManageTrustyAICR = (namespace: string): UseManageTrustyAICRReturnType => { const state = useTrustyAINamespaceCR(namespace); - const [cr, loaded, serviceError, refresh] = state; - - const [installReqError, setInstallReqError] = React.useState(); + const [cr, , , refresh] = state; + const successDetails = useTrustyBrowserStorage(cr); - const isAvailable = isTrustyAIAvailable(state); - const isProgressing = loaded && !!cr && !isAvailable; - const error = installReqError || serviceError; + const statusState = getTrustyStatusState(state, successDetails); - const [disableTimeout, setDisableTimeout] = React.useState(false); - const serverTimedOut = !disableTimeout && taiHasServerTimedOut(state, isAvailable); - const ignoreTimedOut = React.useCallback(() => { - setDisableTimeout(true); - }, []); + const installCRForExistingDB = React.useCallback< + UseManageTrustyAICRReturnType['installCRForExistingDB'] + >( + async (secretName) => { + await createTrustyAICR(namespace, secretName).then(refresh); + }, + [namespace, refresh], + ); + const installCRForNewDB = React.useCallback( + async (data) => { + const submitNewSecret = async (dryRun: boolean) => { + await Promise.all([ + createSecret(assembleSecret(namespace, data, 'generic', TRUSTYAI_SECRET_NAME), { + dryRun, + }), + createTrustyAICR(namespace, TRUSTYAI_SECRET_NAME, { dryRun }), + ]); + }; - const showSuccess = React.useRef(false); - if (isProgressing) { - showSuccess.current = true; - } + await submitNewSecret(true); + await submitNewSecret(false); + await refresh(); + }, + [namespace, refresh], + ); - const installCR = React.useCallback(async () => { - await createTrustyAICR(namespace) - .then(refresh) - .catch((e) => setInstallReqError(e)); - }, [namespace, refresh]); + const deleteCR = React.useCallback(async () => { + let deleteGeneratedSecret = false; + if (cr?.spec.storage.format === 'DATABASE') { + if (cr.spec.storage.databaseConfigurations === TRUSTYAI_SECRET_NAME) { + deleteGeneratedSecret = true; + } + } - const deleteCR = React.useCallback(async () => { - await deleteTrustyAICR(namespace).then(refresh); - }, [namespace, refresh]); + await deleteTrustyAICR(namespace); + if (deleteGeneratedSecret) { + await deleteSecret(namespace, TRUSTYAI_SECRET_NAME); + } + await refresh(); + }, [cr, namespace, refresh]); return { - error, - isProgressing, - isAvailable, - showSuccess: showSuccess.current, - isSettled: loaded, - serverTimedOut, - ignoreTimedOut, - installCR, + statusState, + installCRForExistingDB, + installCRForNewDB, deleteCR, }; }; export default useManageTrustyAICR; - -type UseManageTrustyAICRReturnType = { - error: Error | undefined; - isProgressing: boolean; - isAvailable: boolean; - showSuccess: boolean; - isSettled: boolean; - serverTimedOut: boolean; - ignoreTimedOut: () => void; - installCR: () => Promise; - deleteCR: () => Promise; -}; diff --git a/frontend/src/concepts/trustyai/useTrustyAINamespaceCR.ts b/frontend/src/concepts/trustyai/useTrustyAINamespaceCR.ts index f8d1182200..8c9b76514b 100644 --- a/frontend/src/concepts/trustyai/useTrustyAINamespaceCR.ts +++ b/frontend/src/concepts/trustyai/useTrustyAINamespaceCR.ts @@ -6,33 +6,13 @@ import useFetchState, { } from '~/utilities/useFetchState'; import { TrustyAIKind } from '~/k8sTypes'; import { getTrustyAICR } from '~/api'; -import { FAST_POLL_INTERVAL, SERVER_TIMEOUT } from '~/utilities/const'; +import { FAST_POLL_INTERVAL } from '~/utilities/const'; import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import { getTrustyStatusState } from '~/concepts/trustyai/utils'; +import { TrustyInstallState } from '~/concepts/trustyai/types'; type State = TrustyAIKind | null; -export const isTrustyCRStatusAvailable = (cr: TrustyAIKind): boolean => - !!cr.status?.conditions?.find((c) => c.type === 'Available' && c.status === 'True'); - -export const isTrustyAIAvailable = ([state, loaded]: FetchState): boolean => - loaded && !!state && isTrustyCRStatusAvailable(state); - -export const taiHasServerTimedOut = ( - [state, loaded]: FetchState, - isLoaded: boolean, -): boolean => { - if (!state || !loaded || isLoaded) { - return false; - } - - const createTime = state.metadata.creationTimestamp; - if (!createTime) { - return false; - } - // If we are here, and 5 mins have past, we are having issues - return Date.now() - new Date(createTime).getTime() > SERVER_TIMEOUT; -}; - const useTrustyAINamespaceCR = (namespace: string): FetchState => { const trustyAIAreaAvailable = useIsAreaAvailable(SupportedArea.TRUSTY_AI).status; @@ -53,18 +33,22 @@ const useTrustyAINamespaceCR = (namespace: string): FetchState => { [namespace, trustyAIAreaAvailable], ); - const [isStarting, setIsStarting] = React.useState(false); + const [needFastRefresh, setNeedFastRefresh] = React.useState(false); const state = useFetchState(callback, null, { initialPromisePurity: true, - refreshRate: isStarting ? FAST_POLL_INTERVAL : undefined, + refreshRate: needFastRefresh ? FAST_POLL_INTERVAL : undefined, }); - const resourceLoaded = state[1] && !!state[0]; - const hasStatus = isTrustyAIAvailable(state); + const installState = getTrustyStatusState(state); + const isProgressing = [ + TrustyInstallState.INSTALLING, + TrustyInstallState.UNINSTALLING, + TrustyInstallState.CR_ERROR, + ].includes(installState.type); React.useEffect(() => { - setIsStarting(resourceLoaded && !hasStatus); - }, [hasStatus, resourceLoaded]); + setNeedFastRefresh(isProgressing); + }, [isProgressing]); return state; }; diff --git a/frontend/src/concepts/trustyai/utils.ts b/frontend/src/concepts/trustyai/utils.ts index e160bd8e94..b3cba0eba2 100644 --- a/frontend/src/concepts/trustyai/utils.ts +++ b/frontend/src/concepts/trustyai/utils.ts @@ -1,5 +1,13 @@ import { BaseMetricListResponse } from '~/api'; -import { BiasMetricConfig } from '~/concepts/trustyai/types'; +import { + BiasMetricConfig, + TrustyInstallState, + TrustyStatusStates, +} from '~/concepts/trustyai/types'; +import { FetchState } from '~/utilities/useFetchState'; +import { TrustyAIKind } from '~/k8sTypes'; +import { getConditionForType } from '~/concepts/k8s/utils'; +import { UseTrustyBrowserStorage } from '~/concepts/trustyai/content/useTrustyBrowserStorage'; export const formatListResponse = (x: BaseMetricListResponse): BiasMetricConfig[] => x.requests.map((m) => ({ @@ -15,3 +23,75 @@ export const formatListResponse = (x: BaseMetricListResponse): BiasMetricConfig[ thresholdDelta: m.request.thresholdDelta, unprivilegedAttribute: m.request.unprivilegedAttribute.value, })); + +export const getTrustyStatusState = ( + crFetchState: FetchState, + successDetails?: UseTrustyBrowserStorage, +): TrustyStatusStates => { + const [cr, loaded, error] = crFetchState; + + if (error) { + return { type: TrustyInstallState.INFRA_ERROR, message: error.message }; + } + + if (!loaded) { + return { type: TrustyInstallState.LOADING_INITIAL_STATE }; + } + + if (!cr) { + // No CR, uninstalled + return { type: TrustyInstallState.UNINSTALLED }; + } + + /* Have CR, determine the state from it */ + + // If in the first 3 seconds, assume the CR is not settled + // TODO: Remove logic when the backend can provide a proper conditional check state at all times + const isInStartupGraceWindow = cr.metadata.creationTimestamp + ? Date.now() - new Date(cr.metadata.creationTimestamp).getTime() <= 3000 + : false; + if (isInStartupGraceWindow) { + return { type: TrustyInstallState.INSTALLING }; + } + + if (cr.metadata.deletionTimestamp) { + return { type: TrustyInstallState.UNINSTALLING }; + } + + const availableCondition = getConditionForType(cr, 'Available'); + if (availableCondition?.status === 'True' && cr.status?.phase === 'Ready') { + // Installed and good to go + return { + type: TrustyInstallState.INSTALLED, + showSuccess: !!successDetails?.showSuccess, + onDismissSuccess: successDetails?.onDismissSuccess, + }; + } + + const dbAvailableCondition = getConditionForType(cr, 'DBAvailable'); + if (dbAvailableCondition?.status === 'False') { + if (dbAvailableCondition.reason === 'DBConnecting') { + // DB is still being determined + return { type: TrustyInstallState.INSTALLING }; + } + + // Some sort of DB error -- try to show specifically what it is + return { + type: TrustyInstallState.CR_ERROR, + message: `${dbAvailableCondition.reason ?? 'Unknown reason'}: ${ + dbAvailableCondition.message ?? 'Unknown error' + }`, + }; + } + + if (availableCondition?.status === 'False') { + // Try to present the generic error as one last fallback + return { + type: TrustyInstallState.CR_ERROR, + message: availableCondition.message ?? availableCondition.reason ?? 'Unknown available error', + }; + } + + // Not ready -- installing? -- wait for next update + return { type: TrustyInstallState.INSTALLING }; +}; diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 4e9ecf35af..bcbab1b028 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -603,12 +603,17 @@ export type TrustyAIKind = K8sResourceCommon & { namespace: string; }; spec: { - storage: { - format: string; - folder: string; - size: string; - }; - data: { + storage: + | { + format: 'DATABASE'; + databaseConfigurations: string; + } + | { + format: 'PVC'; + folder: string; + size: string; + }; + data?: { filename: string; format: string; }; diff --git a/frontend/src/pages/modelServing/screens/metrics/MetricsPage.tsx b/frontend/src/pages/modelServing/screens/metrics/MetricsPage.tsx index ba4f0666a9..eddcf86fad 100644 --- a/frontend/src/pages/modelServing/screens/metrics/MetricsPage.tsx +++ b/frontend/src/pages/modelServing/screens/metrics/MetricsPage.tsx @@ -10,6 +10,7 @@ import { PerformanceMetricType } from '~/pages/modelServing/screens/types'; import { TrustyAIContext } from '~/concepts/trustyai/context/TrustyAIContext'; import ServerMetricsPage from '~/pages/modelServing/screens/metrics/performance/ServerMetricsPage'; import { InferenceServiceKind } from '~/k8sTypes'; +import { TrustyInstallState } from '~/concepts/trustyai/types'; import { getBreadcrumbItemComponents } from './utils'; type MetricsPageProps = { @@ -23,10 +24,7 @@ const MetricsPage: React.FC = ({ title, breadcrumbItems, type, const { tab } = useParams(); const navigate = useNavigate(); - const { - hasCR, - apiState: { apiAvailable }, - } = React.useContext(TrustyAIContext); + const { statusState } = React.useContext(TrustyAIContext); return ( = ({ title, breadcrumbItems, type, headerAction={ tab === MetricsTabKeys.BIAS && ( } - loaded={loaded} + loaded={isInstalled} provideChildrenPadding empty={biasMetricConfigs.length === 0} emptyStatePage={ diff --git a/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx b/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx index 1327da0275..c1931c9bbf 100644 --- a/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx +++ b/frontend/src/pages/modelServing/screens/metrics/bias/BiasMetricConfigSelector.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useModelBiasData } from '~/concepts/trustyai/context/useModelBiasData'; -import { BiasMetricConfig } from '~/concepts/trustyai/types'; +import { BiasMetricConfig, TrustyInstallState } from '~/concepts/trustyai/types'; import { BiasMetricType } from '~/api'; import { MultiSelection, SelectionOptions } from '~/components/MultiSelection'; @@ -13,7 +13,7 @@ const BiasMetricConfigSelector: React.FC = ({ onChange, initialSelections, }) => { - const { biasMetricConfigs, loaded } = useModelBiasData(); + const { biasMetricConfigs, statusState } = useModelBiasData(); const [uiSelections, setUISelections] = React.useState(); const [currentSelections, setCurrentSelections] = React.useState(); const elementId = React.useId(); @@ -74,7 +74,9 @@ const BiasMetricConfigSelector: React.FC = ({ selectionRequired noSelectedOptionsMessage="One or more groups must be seleted" placeholder="Select a metric" - isDisabled={!(loaded && biasMetricConfigs.length > 0)} + isDisabled={ + !(statusState.type === TrustyInstallState.INSTALLED && biasMetricConfigs.length > 0) + } id="bias-metric-config-selector" toggleId="bias-metric-config-selector" /> diff --git a/frontend/src/pages/modelServing/screens/metrics/bias/BiasTab.tsx b/frontend/src/pages/modelServing/screens/metrics/bias/BiasTab.tsx index 7a862fa7df..6dd8105adc 100644 --- a/frontend/src/pages/modelServing/screens/metrics/bias/BiasTab.tsx +++ b/frontend/src/pages/modelServing/screens/metrics/bias/BiasTab.tsx @@ -25,17 +25,18 @@ import DashboardExpandableSection from '~/concepts/dashboard/DashboardExpandable import useBiasChartSelections from '~/pages/modelServing/screens/metrics/bias/useBiasChartSelections'; import { ModelMetricType } from '~/pages/modelServing/screens/metrics/ModelServingMetricsContext'; import EnsureMetricsAvailable from '~/pages/modelServing/screens/metrics/EnsureMetricsAvailable'; +import { TrustyInstallState } from '~/concepts/trustyai/types'; const OPEN_WRAPPER_STORAGE_KEY_PREFIX = `odh.dashboard.xai.bias_metric_chart_wrapper_open`; const BiasTab: React.FC = () => { - const { biasMetricConfigs, loaded, loadError } = useModelBiasData(); + const { biasMetricConfigs, statusState } = useModelBiasData(); const [selectedBiasConfigs, setSelectedBiasConfigs, settled] = useBiasChartSelections(biasMetricConfigs); - const ready = loaded && settled; + const ready = statusState.type === TrustyInstallState.INSTALLED && settled; - if (loadError) { + if (statusState.type === TrustyInstallState.CR_ERROR) { return ( @@ -47,7 +48,7 @@ const BiasTab: React.FC = () => { We encountered an error accessing the TrustyAI service: - {loadError.message} + {statusState.message} diff --git a/frontend/src/pages/projects/projectSettings/ModelBiasSettingsCard.tsx b/frontend/src/pages/projects/projectSettings/ModelBiasSettingsCard.tsx index 4e16dbc1f3..a414fb7fc7 100644 --- a/frontend/src/pages/projects/projectSettings/ModelBiasSettingsCard.tsx +++ b/frontend/src/pages/projects/projectSettings/ModelBiasSettingsCard.tsx @@ -1,31 +1,39 @@ -import { Card, CardBody, CardHeader, CardTitle } from '@patternfly/react-core'; import React from 'react'; -import TrustyAIServiceControl from '~/concepts/trustyai/content/TrustyAIServiceControl'; -import { KnownLabels, ProjectKind } from '~/k8sTypes'; -import { TRUST_AI_NOT_SUPPORTED_TEXT } from '~/pages/projects/projectSettings/const'; +import { + Card, + CardBody, + CardFooter, + CardHeader, + CardTitle, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { ProjectKind } from '~/k8sTypes'; +import useTrustyCRState from '~/concepts/trustyai/content/useTrustyCRState'; type ModelBiasSettingsCardProps = { project: ProjectKind; }; const ModelBiasSettingsCard: React.FC = ({ project }) => { - const namespace = project.metadata.name; - - const isTrustySupported = project.metadata.labels?.[KnownLabels.MODEL_SERVING_PROJECT] === 'true'; - - const disabledReason = isTrustySupported ? undefined : TRUST_AI_NOT_SUPPORTED_TEXT; + const { action, status } = useTrustyCRState(project); return ( - + - Model bias + Model monitoring bias - + To ensure that machine-learning models are transparent, fair, and reliable, data scientists + can use TrustyAI to monitor their data science models. TrustyAI is an open-source AI + Explainability (XAI) Toolkit that offers comprehensive explanations of predictive models in + both enterprise and data science applications. + + + {action} + {status && {status}} + + ); }; diff --git a/frontend/src/pages/projects/projectSettings/const.ts b/frontend/src/pages/projects/projectSettings/const.ts deleted file mode 100644 index 9cee5909a6..0000000000 --- a/frontend/src/pages/projects/projectSettings/const.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const TRUSTYAI_TOOLTIP_TEXT = - 'Install TrustyAI, which uses data from ModelMesh to calculate and display model bias over time, in your namespace.'; - -export const TRUST_AI_NOT_SUPPORTED_TEXT = - 'Model bias monitoring is only available when a multi-model serving platform is enabled for the project.';