diff --git a/frontend/src/__mocks__/mockConnectionType.ts b/frontend/src/__mocks__/mockConnectionType.ts new file mode 100644 index 0000000000..9731c3ad9b --- /dev/null +++ b/frontend/src/__mocks__/mockConnectionType.ts @@ -0,0 +1,393 @@ +import { + ConnectionTypeConfigMap, + ConnectionTypeConfigMapObj, + ConnectionTypeField, +} from '~/concepts/connectionTypes/types'; +import { toConnectionTypeConfigMap } from '~/concepts/connectionTypes/utils'; + +type MockConnectionTypeConfigMap = { + name?: string; + namespace?: string; + displayName?: string; + description?: string; + enabled?: boolean; + username?: string; + preInstalled?: boolean; + fields?: ConnectionTypeField[]; +}; + +export const mockConnectionTypeConfigMap = ( + options: MockConnectionTypeConfigMap, +): ConnectionTypeConfigMap => toConnectionTypeConfigMap(mockConnectionTypeConfigMapObj(options)); + +export const mockConnectionTypeConfigMapObj = ({ + name = 'connection-type-sample', + namespace = 'opendatahub', + displayName = name, + description = 'Connection type description', + enabled = true, + username = 'dashboard-admin', + preInstalled = false, + ...rest +}: MockConnectionTypeConfigMap): ConnectionTypeConfigMapObj => ({ + kind: 'ConfigMap', + apiVersion: 'v1', + metadata: { + name, + namespace, + resourceVersion: '173155965', + creationTimestamp: '2024-08-29T00:00:00Z', + labels: { 'opendatahub.io/dashboard': 'true', 'opendatahub.io/connection-type': 'true' }, + annotations: { + 'openshift.io/display-name': displayName, + 'openshift.io/description': description, + 'opendatahub.io/enabled': enabled ? 'true' : 'false', + 'opendatahub.io/username': username || '', + }, + ...(preInstalled + ? { + ownerReferences: [ + { + apiVersion: 'datasciencecluster.opendatahub.io/v1', + kind: 'DataScienceCluster', + name: 'default-dsc', + uid: '06dd5a40-8473-4d5f-8afa-36885aa26ca9', + controller: true, + blockOwnerDeletion: true, + }, + ], + } + : undefined), + }, + data: { + fields: 'fields' in rest ? rest.fields : mockFields, + }, +}); + +const mockFields: ConnectionTypeField[] = [ + { + type: 'section', + name: 'Short text', + description: 'This section contains short text fields.', + }, + { + type: 'short-text', + name: 'Short text 1', + description: 'Test short text', + envVar: 'short-text-1', + required: false, + properties: {}, + }, + { + type: 'short-text', + name: 'Short text 2', + description: 'Test short text with default value', + envVar: 'short-text-2', + required: true, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: false, + }, + }, + { + type: 'short-text', + name: 'Short text 3', + description: 'Test short text with default value and read only', + envVar: 'short-text-1', + required: false, + properties: { + defaultValue: 'This is the default value and is read only', + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'Paragraph', + description: 'This section contains paragraph fields.', + }, + { + type: 'paragraph', + name: 'Paragraph 1', + description: 'Test paragraph', + envVar: 'paragraph-1', + required: false, + properties: {}, + }, + { + type: 'paragraph', + name: 'Paragraph 2', + description: 'Test paragraph with default value', + envVar: 'paragraph-2', + required: true, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: false, + }, + }, + { + type: 'paragraph', + name: 'Paragraph 3', + description: 'Test paragraph with default value and read only', + envVar: 'paragraph-3', + required: false, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'Hidden', + description: 'This section contains hidden fields.', + }, + { + type: 'hidden', + name: 'Hidden 1', + description: 'Test hidden', + envVar: 'hidden-1', + required: false, + properties: {}, + }, + { + type: 'hidden', + name: 'Hidden 2', + description: 'Test hidden with default value', + envVar: 'hidden-2', + required: true, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: false, + }, + }, + { + type: 'hidden', + name: 'Hidden 3', + description: 'Test hidden with default value and read only', + envVar: 'hidden-3', + required: false, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'URI', + description: 'This section contains URI fields.', + }, + { + type: 'uri', + name: 'URI 1', + description: 'Test URI', + envVar: 'uri-1', + required: false, + properties: {}, + }, + { + type: 'uri', + name: 'URI 2', + description: 'Test URI with default value', + envVar: 'uri-2', + required: true, + properties: { + defaultValue: 'https://www.redhat.com', + defaultReadOnly: false, + }, + }, + { + type: 'uri', + name: 'URI 3', + description: 'Test URI with default value and read only', + envVar: 'uri-3', + required: false, + properties: { + defaultValue: 'https://www.redhat.com', + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'File', + description: 'This section contains file fields.', + }, + { + type: 'file', + name: 'File 1', + description: 'Test file', + envVar: 'file-1', + required: false, + properties: {}, + }, + { + type: 'file', + name: 'File 2', + description: 'Test file with default value', + envVar: 'file-2', + required: true, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: false, + }, + }, + { + type: 'file', + name: 'File 3', + description: 'Test file with default value and read only', + envVar: 'file-3', + required: false, + properties: { + defaultValue: 'This is the default value', + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'Boolean', + description: 'This section contains boolean fields.', + }, + { + type: 'boolean', + name: 'Boolean 1', + description: 'Test boolean', + envVar: 'boolean-1', + required: false, + properties: {}, + }, + { + type: 'boolean', + name: 'Boolean 2', + description: 'Test boolean with default value', + envVar: 'boolean-2', + required: true, + properties: { + label: 'Input label', + defaultValue: true, + defaultReadOnly: false, + }, + }, + { + type: 'boolean', + name: 'Boolean 3', + description: 'Test boolean with default value and read only', + envVar: 'boolean-3', + required: false, + properties: { + label: 'Input label', + defaultValue: false, + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'Numeric', + description: 'This section contains numeric fields.', + }, + { + type: 'numeric', + name: 'Numeric 1', + description: 'Test numeric', + envVar: 'numeric-1', + required: false, + properties: {}, + }, + { + type: 'numeric', + name: 'Numeric 2', + description: 'Test numeric with default value', + envVar: 'numeric-2', + required: true, + properties: { + defaultValue: 2, + defaultReadOnly: false, + }, + }, + { + type: 'numeric', + name: 'Numeric 3', + description: 'Test numeric with default value and read only', + envVar: 'numeric-3', + required: false, + properties: { + defaultValue: 3, + defaultReadOnly: true, + }, + }, + + { + type: 'section', + name: 'Dropdown', + description: 'This section contains dropdown fields.', + }, + { + type: 'dropdown', + name: 'Dropdown 1', + description: 'Test dropdown single variant', + envVar: 'dropdown-1', + required: false, + properties: { + variant: 'single', + items: [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, + { value: '3', label: 'Three' }, + { value: '4', label: 'Four' }, + ], + }, + }, + { + type: 'dropdown', + name: 'Dropdown 2', + description: 'Test dropdown single variant with default value', + envVar: 'dropdown-2', + required: true, + properties: { + variant: 'single', + items: [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, + { value: '3', label: 'Three' }, + { value: '4', label: 'Four' }, + ], + defaultValue: ['3'], + }, + }, + { + type: 'dropdown', + name: 'Dropdown 3', + description: 'Test dropdown multi variant', + envVar: 'dropdown-3', + required: false, + properties: { + variant: 'multi', + items: [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, + { value: '3', label: 'Three' }, + { value: '4', label: 'Four' }, + ], + }, + }, + { + type: 'dropdown', + name: 'Dropdown 4', + description: 'Test dropdown multi variant with default values', + envVar: 'dropdown-4', + required: false, + properties: { + variant: 'multi', + items: [ + { value: '1', label: 'One' }, + { value: '2', label: 'Two' }, + { value: '3', label: 'Three' }, + { value: '4', label: 'Four' }, + ], + defaultValue: ['2', '3'], + }, + }, +]; diff --git a/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts b/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts new file mode 100644 index 0000000000..cbb9557561 --- /dev/null +++ b/frontend/src/concepts/connectionTypes/__tests__/utils.spec.ts @@ -0,0 +1,21 @@ +import { mockConnectionTypeConfigMapObj } from '~/__mocks__/mockConnectionType'; +import { + toConnectionTypeConfigMap, + toConnectionTypeConfigMapObj, +} from '~/concepts/connectionTypes/utils'; + +describe('utils', () => { + it('should serialize / deserialize connection type fields', () => { + const ct = mockConnectionTypeConfigMapObj({}); + const configMap = toConnectionTypeConfigMap(ct); + expect(typeof configMap.data.fields).toBe('string'); + expect(ct).toEqual(toConnectionTypeConfigMapObj(toConnectionTypeConfigMap(ct))); + }); + + it('should serialize / deserialize connection type with missing fields', () => { + const ct = mockConnectionTypeConfigMapObj({ fields: undefined }); + const configMap = toConnectionTypeConfigMap(ct); + expect(configMap.data.fields).toBeUndefined(); + expect(ct).toEqual(toConnectionTypeConfigMapObj(configMap)); + }); +}); diff --git a/frontend/src/concepts/connectionTypes/types.ts b/frontend/src/concepts/connectionTypes/types.ts new file mode 100644 index 0000000000..289643f57d --- /dev/null +++ b/frontend/src/concepts/connectionTypes/types.ts @@ -0,0 +1,108 @@ +import { K8sResourceCommon } from '@openshift/dynamic-plugin-sdk-utils'; +import { DashboardLabels, DisplayNameAnnotations } from '~/k8sTypes'; + +export enum ConnectionTypeFieldType { + Boolean = 'boolean', + Dropdown = 'dropdown', + File = 'file', + Hidden = 'hidden', + Numeric = 'numeric', + Paragraph = 'paragraph', + Section = 'section', + ShortText = 'short-text', + URI = 'uri', +} + +// exclude 'section' +export const connectionTypeDataFields = [ + ConnectionTypeFieldType.Boolean, + ConnectionTypeFieldType.Dropdown, + ConnectionTypeFieldType.File, + ConnectionTypeFieldType.Hidden, + ConnectionTypeFieldType.Numeric, + ConnectionTypeFieldType.Paragraph, + ConnectionTypeFieldType.ShortText, + ConnectionTypeFieldType.URI, +]; + +type Field = { + type: T; + name: string; + description?: string; +}; + +type DataField = Field & { + envVar: string; + required?: boolean; + properties: P; +}; + +type TextProps = { + defaultValue?: string; + defaultReadOnly?: boolean; +}; + +export type SectionField = Field; + +export type HiddenField = DataField; +export type ParagraphField = DataField; +export type FileField = DataField; +export type ShortTextField = DataField; +export type UriField = DataField; +export type BooleanField = DataField< + ConnectionTypeFieldType.Boolean | 'boolean', + { + label?: string; + defaultValue?: boolean; + defaultReadOnly?: boolean; + } +>; +export type DropdownField = DataField< + ConnectionTypeFieldType.Dropdown | 'dropdown', + { + variant: 'single' | 'multi'; + items: { label: string; value: string }[]; + defaultValue?: string[]; + } +>; +export type NumericField = DataField< + ConnectionTypeFieldType.Numeric | 'numeric', + { + defaultValue?: number; + defaultReadOnly?: boolean; + } +>; + +export type ConnectionTypeField = + | BooleanField + | DropdownField + | FileField + | HiddenField + | NumericField + | ParagraphField + | SectionField + | ShortTextField + | UriField; + +export type ConnectionTypeConfigMap = K8sResourceCommon & { + metadata: { + name: string; + annotations: DisplayNameAnnotations & { + 'opendatahub.io/enabled'?: 'true' | 'false'; + 'opendatahub.io/username'?: string; + }; + labels: DashboardLabels & { + 'opendatahub.io/connection-type': 'true'; + }; + }; + data: { + // JSON of type ConnectionTypeField + fields?: string; + }; +}; + +export type ConnectionTypeConfigMapObj = Omit & { + data: { + fields?: ConnectionTypeField[]; + }; +}; diff --git a/frontend/src/concepts/connectionTypes/utils.ts b/frontend/src/concepts/connectionTypes/utils.ts new file mode 100644 index 0000000000..7fb9121a2f --- /dev/null +++ b/frontend/src/concepts/connectionTypes/utils.ts @@ -0,0 +1,18 @@ +import { + ConnectionTypeConfigMap, + ConnectionTypeConfigMapObj, +} from '~/concepts/connectionTypes/types'; + +export const toConnectionTypeConfigMapObj = ( + configMap: ConnectionTypeConfigMap, +): ConnectionTypeConfigMapObj => ({ + ...configMap, + data: { fields: configMap.data.fields ? JSON.parse(configMap.data.fields) : undefined }, +}); + +export const toConnectionTypeConfigMap = ( + obj: ConnectionTypeConfigMapObj, +): ConnectionTypeConfigMap => ({ + ...obj, + data: { fields: obj.data.fields ? JSON.stringify(obj.data.fields) : undefined }, +}); diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 4805772c98..5c5b918ffc 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -39,7 +39,7 @@ export type K8sVerb = * Annotations that we will use to allow the user flexibility in describing items outside of the * k8s structure. */ -type DisplayNameAnnotations = Partial<{ +export type DisplayNameAnnotations = Partial<{ 'openshift.io/description': string; // the description provided by the user 'openshift.io/display-name': string; // the name provided by the user }>; diff --git a/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx b/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx index 6a7e84615c..cd75a344f5 100644 --- a/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx +++ b/frontend/src/pages/modelRegistrySettings/ModelRegistriesTableRow.tsx @@ -5,6 +5,7 @@ import { ModelRegistryKind } from '~/k8sTypes'; import ResourceNameTooltip from '~/components/ResourceNameTooltip'; import ViewDatabaseConfigModal from './ViewDatabaseConfigModal'; import DeleteModelRegistryModal from './DeleteModelRegistryModal'; +import { ModelRegistryTableRowStatus } from './ModelRegistryTableRowStatus'; type ModelRegistriesTableRowProps = { modelRegistry: ModelRegistryKind; @@ -30,6 +31,9 @@ const ModelRegistriesTableRow: React.FC = ({

{mr.metadata.annotations['openshift.io/description']}

)} + + + = ({ + conditions, +}) => { + const conditionsMap = + conditions?.reduce((acc: Record, condition) => { + acc[condition.type] = condition; + return acc; + }, {}) ?? {}; + let statusLabel: string = ModelRegistryStatusLabel.Progressing; + let icon = ; + let color: React.ComponentProps['color'] = 'blue'; + let popoverMessages: string[] = []; + let popoverTitle = ''; + + if (Object.values(conditionsMap).length) { + const { + [ModelRegistryStatus.Available]: availableCondition, + [ModelRegistryStatus.Progressing]: progressCondition, + [ModelRegistryStatus.Degraded]: degradedCondition, + } = conditionsMap; + const lastAvailableConditionTime = new Date( + availableCondition?.lastTransitionTime ?? '', + ).getTime(); + + popoverMessages = + availableCondition?.status === ConditionStatus.False + ? Object.values(conditionsMap).reduce((messages: string[], condition) => { + if (condition?.status === ConditionStatus.False && condition.message) { + messages.push(condition.message); + } + + return messages; + }, []) + : []; + + // Available + if (availableCondition?.status === ConditionStatus.True) { + statusLabel = ModelRegistryStatusLabel.Available; + icon = ; + color = 'green'; + } + // Progressing + else if ( + progressCondition?.status === ConditionStatus.True && + lastAvailableConditionTime < new Date(progressCondition.lastTransitionTime ?? '').getTime() + ) { + statusLabel = ModelRegistryStatusLabel.Progressing; + icon = ; + color = 'blue'; + } + // Degrading + else if ( + degradedCondition?.status === ConditionStatus.True && + lastAvailableConditionTime < new Date(degradedCondition.lastTransitionTime ?? '').getTime() + ) { + statusLabel = ModelRegistryStatusLabel.Degrading; + icon = ; + color = 'gold'; + popoverTitle = 'Service is degrading'; + } + // Unavailable + else { + statusLabel = ModelRegistryStatusLabel.Unavailable; + icon = ; + color = 'red'; + + const { + [ModelRegistryStatus.IstioAvailable]: istioAvailableCondition, + [ModelRegistryStatus.GatewayAvailable]: gatewayAvailableCondition, + } = conditionsMap; + + if ( + istioAvailableCondition?.status === ConditionStatus.False && + gatewayAvailableCondition?.status === ConditionStatus.False + ) { + popoverTitle = 'Istio resources and Istio Gateway resources are both unavailable'; + } else if (istioAvailableCondition?.status === ConditionStatus.False) { + popoverTitle = 'Istio resources are unavailable'; + } else if (gatewayAvailableCondition?.status === ConditionStatus.False) { + popoverTitle = 'Istio Gateway resources are unavailable'; + } else if ( + istioAvailableCondition?.status === ConditionStatus.True && + gatewayAvailableCondition?.status === ConditionStatus.True + ) { + popoverTitle = 'Deployment is unavailable'; + } else { + popoverTitle = 'Service is unavailable'; + } + } + } + + const label = ( + + ); + + return popoverTitle && popoverMessages.length ? ( + , + } + : { alertSeverityVariant: 'danger', headerIcon: })} + bodyContent={ + + {popoverMessages.map((message, index) => ( + {message} + ))} + + } + > + {label} + + ) : ( + label + ); +}; diff --git a/frontend/src/pages/modelRegistrySettings/__tests__/ModelRegistryTableRowStatus.spec.tsx b/frontend/src/pages/modelRegistrySettings/__tests__/ModelRegistryTableRowStatus.spec.tsx new file mode 100644 index 0000000000..969a24a313 --- /dev/null +++ b/frontend/src/pages/modelRegistrySettings/__tests__/ModelRegistryTableRowStatus.spec.tsx @@ -0,0 +1,389 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; +import '@testing-library/jest-dom'; + +import { ModelRegistryTableRowStatus } from '~/pages/modelRegistrySettings/ModelRegistryTableRowStatus'; + +describe('ModelRegistryTableRowStatus', () => { + it('renders "Istio resources and Istio Gateway resources are both unavailable" as popover title', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByText('Unavailable')); + expect( + screen.getByRole('heading', { + name: 'danger alert: Istio resources and Istio Gateway resources are both unavailable', + }), + ).toBeVisible(); + }); + + it('renders "Istio resources are unavailable" as popover title', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByText('Unavailable')); + expect( + screen.getByRole('heading', { name: 'danger alert: Istio resources are unavailable' }), + ).toBeVisible(); + }); + + it('renders "Istio Gateway resources are unavailable" as popover title', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByText('Unavailable')); + expect( + screen.getByRole('heading', { + name: 'danger alert: Istio Gateway resources are unavailable', + }), + ).toBeVisible(); + }); + + it('renders "Deployment is unavailable" as popover title', async () => { + const user = userEvent.setup(); + + render( + , + ); + + await user.click(screen.getByText('Unavailable')); + expect( + screen.getByRole('heading', { name: 'danger alert: Deployment is unavailable' }), + ).toBeVisible(); + }); + + it('renders "Available" status', () => { + render( + , + ); + expect(screen.getByText('Available')).toBeVisible(); + }); + + it('renders "Progressing" status when conditions are empty', () => { + render(); + expect(screen.getByText('Progressing')).toBeVisible(); + }); + + it('renders "Progressing" status when conditions are undefined', () => { + render(); + expect(screen.getByText('Progressing')).toBeVisible(); + }); + + it('renders "Unavailable" status when the last "Progressing" time is less than the latest "Available" time', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Unavailable'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'danger alert: Service is unavailable' }), + ).toBeVisible(); + expect(screen.getByText('Some unavailable message')).toBeVisible(); + }); + + it('renders "Progressing" status when the last "Progressing" time is greater than the latest "Available" time', () => { + render( + , + ); + + expect(screen.getByText('Progressing')).toBeVisible(); + }); + + it('renders "Unavailable" status when the last "Progressing" time is equal to the latest "Available" time', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Unavailable'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'danger alert: Service is unavailable' }), + ).toBeVisible(); + expect(screen.getByText('Some unavailable message')).toBeVisible(); + }); + + it('renders "Unavailable" status when the last "Degraded" time is less than the latest "Available" time', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Unavailable'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'danger alert: Service is unavailable' }), + ).toBeVisible(); + expect(screen.getByText('Some unavailable message')).toBeVisible(); + }); + + it('renders "Degrading" status when the last "Degraded" time is greater than the latest "Available" time', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Degrading'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'warning alert: Service is degrading' }), + ).toBeVisible(); + expect(screen.getByText('Some unavailable message')).toBeVisible(); + }); + + it('renders "Unavailable" status when the last "Degraded" time is equal to the latest "Available" time', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Unavailable'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'danger alert: Service is unavailable' }), + ).toBeVisible(); + expect(screen.getByText('Some unavailable message')).toBeVisible(); + }); + + it('renders "Unavailable" with multiple messages in popover', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Unavailable'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'danger alert: Service is unavailable' }), + ).toBeVisible(); + expect(screen.getByText('Some unavailable message 1')).toBeVisible(); + expect(screen.getByText('Some unavailable message 2')).toBeVisible(); + expect(screen.getByText('Some unavailable message 3')).toBeVisible(); + }); + + it('renders "Unavailable" with an unknown status', async () => { + const user = userEvent.setup(); + + render( + , + ); + + const label = screen.getByText('Unavailable'); + expect(label).toBeVisible(); + + await user.click(label); + + expect( + screen.getByRole('heading', { name: 'danger alert: Service is unavailable' }), + ).toBeVisible(); + expect(screen.getByText('Some unknown status message')).toBeVisible(); + }); +}); diff --git a/frontend/src/pages/modelRegistrySettings/columns.ts b/frontend/src/pages/modelRegistrySettings/columns.ts index e9711b1633..4362dd020c 100644 --- a/frontend/src/pages/modelRegistrySettings/columns.ts +++ b/frontend/src/pages/modelRegistrySettings/columns.ts @@ -8,6 +8,11 @@ export const modelRegistryColumns: SortableData[] = [ sortable: (a, b) => a.metadata.name.localeCompare(b.metadata.name), width: 30, }, + { + field: 'status', + label: 'Status', + sortable: false, + }, { field: 'manage permissions', label: '',