diff --git a/frontend/src/__mocks__/mockImageStreamK8sResource.ts b/frontend/src/__mocks__/mockImageStreamK8sResource.ts new file mode 100644 index 0000000000..ba26f61dcf --- /dev/null +++ b/frontend/src/__mocks__/mockImageStreamK8sResource.ts @@ -0,0 +1,84 @@ +import _ from 'lodash'; +import { ImageStreamKind } from '~/k8sTypes'; +import { RecursivePartial } from '~/typeHelpers'; + +type MockResourceConfigType = { + name?: string; + namespace?: string; + displayName?: string; + opts?: RecursivePartial; +}; + +export const mockImageStreamK8sResource = ({ + name = 'test-imagestream', + namespace = 'test-project', + displayName = 'Test Image', + opts = {}, +}: MockResourceConfigType): ImageStreamKind => + _.merge( + { + apiVersion: 'image.openshift.io/v1', + kind: 'ImageStream', + metadata: { + name: name, + namespace: namespace, + uid: 'd6a75af7-f215-47d1-a167-e1c1e78d465c', + resourceVersion: '1579802', + generation: 2, + creationTimestamp: '2023-06-30T15:07:35Z', + labels: { + 'component.opendatahub.io/name': 'notebooks', + 'opendatahub.io/component': 'true', + 'opendatahub.io/notebook-image': 'true', + }, + annotations: { + 'kfctl.kubeflow.io/kfdef-instance': 'opendatahub.opendatahub', + 'opendatahub.io/notebook-image-desc': + 'Jupyter notebook image with minimal dependency set to start experimenting with Jupyter environment.', + 'opendatahub.io/notebook-image-name': displayName, + 'opendatahub.io/notebook-image-order': '1', + 'opendatahub.io/notebook-image-url': + 'https://github.com//opendatahub-io/notebooks/tree/main/jupyter/minimal', + 'openshift.io/image.dockerRepositoryCheck': '2023-06-30T15:07:36Z', + }, + }, + spec: { + lookupPolicy: { + local: true, + }, + tags: [ + { + name: '1.2', + annotations: { + 'opendatahub.io/notebook-python-dependencies': + '[{"name":"JupyterLab","version": "3.2"}, {"name": "Notebook","version": "6.4"}]', + 'opendatahub.io/notebook-software': '[{"name":"Python","version":"v3.8"}]', + }, + from: { + kind: 'DockerImage', + name: 'quay.io/opendatahub/notebooks@sha256:a138838e1c9acd7708462e420bf939e03296b97e9cf6c0aa0fd9a5d20361ab75', + }, + }, + ], + }, + status: { + dockerImageRepository: + 'image-registry.openshift-image-registry.svc:5000/opendatahub/jupyter-minimal-notebook', + tags: [ + { + tag: '1.2', + items: [ + { + created: '2023-06-30T15:07:36Z', + dockerImageReference: + 'quay.io/opendatahub/notebooks@sha256:a138838e1c9acd7708462e420bf939e03296b97e9cf6c0aa0fd9a5d20361ab75', + image: 'sha256:a138838e1c9acd7708462e420bf939e03296b97e9cf6c0aa0fd9a5d20361ab75', + generation: 2, + }, + ], + }, + ], + }, + } as ImageStreamKind, + opts, + ); diff --git a/frontend/src/__mocks__/mockNotebookK8sResource.ts b/frontend/src/__mocks__/mockNotebookK8sResource.ts index 37a6497e72..f8cf9d8c8f 100644 --- a/frontend/src/__mocks__/mockNotebookK8sResource.ts +++ b/frontend/src/__mocks__/mockNotebookK8sResource.ts @@ -1,7 +1,9 @@ +import _ from 'lodash'; import { KnownLabels, NotebookKind } from '~/k8sTypes'; import { DEFAULT_NOTEBOOK_SIZES } from '~/pages/projects/screens/spawner/const'; import { ContainerResources } from '~/types'; import { genUID } from '~/__mocks__/mockUtils'; +import { RecursivePartial } from '~/typeHelpers'; type MockResourceConfigType = { name?: string; @@ -10,6 +12,7 @@ type MockResourceConfigType = { user?: string; description?: string; resources?: ContainerResources; + opts?: RecursivePartial; }; export const mockNotebookK8sResource = ({ @@ -19,276 +22,257 @@ export const mockNotebookK8sResource = ({ user = 'test-user', description = '', resources = DEFAULT_NOTEBOOK_SIZES[0].resources, -}: MockResourceConfigType): NotebookKind => ({ - apiVersion: 'kubeflow.org/v1', - kind: 'Notebook', - metadata: { - annotations: { - 'notebooks.kubeflow.org/last-activity': '2023-02-14T21:45:14Z', - 'notebooks.opendatahub.io/inject-oauth': 'true', - 'notebooks.opendatahub.io/last-image-selection': 's2i-minimal-notebook:py3.8-v1', - 'notebooks.opendatahub.io/last-size-selection': 'Small', - 'notebooks.opendatahub.io/oauth-logout-url': - 'http://localhost:4010/projects/project?notebookLogout=workbench', - 'opendatahub.io/username': user, - 'openshift.io/description': description, - 'openshift.io/display-name': displayName, - }, - creationTimestamp: '2023-02-14T21:44:13Z', - generation: 4, - labels: { - app: name, - [KnownLabels.DASHBOARD_RESOURCE]: 'true', - 'opendatahub.io/odh-managed': 'true', - 'opendatahub.io/user': user, - }, - managedFields: [], - name: name, - namespace: namespace, - resourceVersion: '4800689', - uid: genUID('notebook'), - }, - spec: { - template: { + opts = {}, +}: MockResourceConfigType): NotebookKind => + _.merge( + { + apiVersion: 'kubeflow.org/v1', + kind: 'Notebook', + metadata: { + annotations: { + 'notebooks.kubeflow.org/last-activity': '2023-02-14T21:45:14Z', + 'notebooks.opendatahub.io/inject-oauth': 'true', + 'notebooks.opendatahub.io/last-image-selection': 's2i-minimal-notebook:py3.8-v1', + 'notebooks.opendatahub.io/last-size-selection': 'Small', + 'notebooks.opendatahub.io/oauth-logout-url': + 'http://localhost:4010/projects/project?notebookLogout=workbench', + 'opendatahub.io/username': user, + 'openshift.io/description': description, + 'openshift.io/display-name': displayName, + }, + creationTimestamp: '2023-02-14T21:44:13Z', + generation: 4, + labels: { + app: name, + [KnownLabels.DASHBOARD_RESOURCE]: 'true', + 'opendatahub.io/odh-managed': 'true', + 'opendatahub.io/user': user, + }, + managedFields: [], + name: name, + namespace: namespace, + resourceVersion: '4800689', + uid: genUID('notebook'), + }, spec: { - affinity: { - nodeAffinity: { - preferredDuringSchedulingIgnoredDuringExecution: [ - { - preference: { - matchExpressions: [ - { - key: 'nvidia.com/gpu.present', - operator: 'NotIn', - values: ['true'], + template: { + spec: { + affinity: { + nodeAffinity: { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + preference: { + matchExpressions: [ + { + key: 'nvidia.com/gpu.present', + operator: 'NotIn', + values: ['true'], + }, + ], }, - ], - }, - weight: 1, - }, - ], - }, - }, - containers: [ - { - env: [ - { - name: 'NOTEBOOK_ARGS', - value: - '--ServerApp.port=8888\n --ServerApp.token=\'\'\n --ServerApp.password=\'\'\n --ServerApp.base_url=/notebook/project/workbench\n --ServerApp.quit_button=False\n --ServerApp.tornado_settings={"user":"user","hub_host":"http://localhost:4010","hub_prefix":"/projects/project"}', + weight: 1, + }, + ], }, + }, + containers: [ { - name: 'JUPYTER_IMAGE', - value: + env: [ + { + name: 'NOTEBOOK_ARGS', + value: + '--ServerApp.port=8888\n --ServerApp.token=\'\'\n --ServerApp.password=\'\'\n --ServerApp.base_url=/notebook/project/workbench\n --ServerApp.quit_button=False\n --ServerApp.tornado_settings={"user":"user","hub_host":"http://localhost:4010","hub_prefix":"/projects/project"}', + }, + { + name: 'JUPYTER_IMAGE', + value: + 'image-registry.openshift-image-registry.svc:5000/redhat-ods-applications/s2i-minimal-notebook:py3.8-v1', + }, + ], + envFrom: [ + { + secretRef: { + name: 'aws-connection-db-1', + }, + }, + ], + image: 'image-registry.openshift-image-registry.svc:5000/redhat-ods-applications/s2i-minimal-notebook:py3.8-v1', - }, - ], - envFrom: [ - { - secretRef: { - name: 'aws-connection-db-1', + imagePullPolicy: 'Always', + livenessProbe: { + failureThreshold: 3, + httpGet: { + path: '/notebook/project/workbench/api', + port: 'notebook-port', + scheme: 'HTTP', + }, + initialDelaySeconds: 10, + periodSeconds: 5, + successThreshold: 1, + timeoutSeconds: 1, }, - }, - ], - image: - 'image-registry.openshift-image-registry.svc:5000/redhat-ods-applications/s2i-minimal-notebook:py3.8-v1', - imagePullPolicy: 'Always', - livenessProbe: { - failureThreshold: 3, - httpGet: { - path: '/notebook/project/workbench/api', - port: 'notebook-port', - scheme: 'HTTP', - }, - initialDelaySeconds: 10, - periodSeconds: 5, - successThreshold: 1, - timeoutSeconds: 1, - }, - name: name, - ports: [ - { - containerPort: 8888, - name: 'notebook-port', - protocol: 'TCP', - }, - ], - readinessProbe: { - failureThreshold: 3, - httpGet: { - path: '/notebook/project/workbench/api', - port: 'notebook-port', - scheme: 'HTTP', - }, - initialDelaySeconds: 10, - periodSeconds: 5, - successThreshold: 1, - timeoutSeconds: 1, - }, - resources, - volumeMounts: [ - { - mountPath: '/opt/app-root/src', name: name, + ports: [ + { + containerPort: 8888, + name: 'notebook-port', + protocol: 'TCP', + }, + ], + readinessProbe: { + failureThreshold: 3, + httpGet: { + path: '/notebook/project/workbench/api', + port: 'notebook-port', + scheme: 'HTTP', + }, + initialDelaySeconds: 10, + periodSeconds: 5, + successThreshold: 1, + timeoutSeconds: 1, + }, + resources, + volumeMounts: [ + { + mountPath: '/opt/app-root/src', + name: name, + }, + ], + workingDir: '/opt/app-root/src', }, - ], - workingDir: '/opt/app-root/src', - }, - { - args: [ - '--provider=openshift', - '--https-address=:8443', - '--http-address=', - '--openshift-service-account=workbench', - '--cookie-secret-file=/etc/oauth/config/cookie_secret', - '--cookie-expire=24h0m0s', - '--tls-cert=/etc/tls/private/tls.crt', - '--tls-key=/etc/tls/private/tls.key', - '--upstream=http://localhost:8888', - '--upstream-ca=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt', - '--skip-auth-regex=^(?:/notebook/$(NAMESPACE)/workbench)?/api$', - '--email-domain=*', - '--skip-provider-button', - '--openshift-sar={"verb":"get","resource":"notebooks","resourceAPIGroup":"kubeflow.org","resourceName":"workbench","namespace":"$(NAMESPACE)"}', - '--logout-url=http://localhost:4010/projects/project?notebookLogout=workbench', - ], - env: [ { - name: 'NAMESPACE', - valueFrom: { - fieldRef: { - fieldPath: 'metadata.namespace', + env: [ + { + name: 'NAMESPACE', + valueFrom: { + fieldRef: { + fieldPath: 'metadata.namespace', + }, + }, }, + ], + image: + 'registry.redhat.io/openshift4/ose-oauth-proxy@sha256:4bef31eb993feb6f1096b51b4876c65a6fb1f4401fee97fa4f4542b6b7c9bc46', + imagePullPolicy: 'Always', + livenessProbe: { + failureThreshold: 3, + httpGet: { + path: '/oauth/healthz', + port: 'oauth-proxy', + scheme: 'HTTPS', + }, + initialDelaySeconds: 30, + periodSeconds: 5, + successThreshold: 1, + timeoutSeconds: 1, + }, + name: 'oauth-proxy', + ports: [ + { + containerPort: 8443, + name: 'oauth-proxy', + protocol: 'TCP', + }, + ], + readinessProbe: { + failureThreshold: 3, + httpGet: { + path: '/oauth/healthz', + port: 'oauth-proxy', + scheme: 'HTTPS', + }, + initialDelaySeconds: 5, + periodSeconds: 5, + successThreshold: 1, + timeoutSeconds: 1, }, + resources: { + limits: { + cpu: '100m', + memory: '64Mi', + }, + requests: { + cpu: '100m', + memory: '64Mi', + }, + }, + volumeMounts: [ + { + mountPath: '/etc/oauth/config', + name: 'oauth-config', + }, + { + mountPath: '/etc/tls/private', + name: 'tls-certificates', + }, + ], }, ], - image: - 'registry.redhat.io/openshift4/ose-oauth-proxy@sha256:4bef31eb993feb6f1096b51b4876c65a6fb1f4401fee97fa4f4542b6b7c9bc46', - imagePullPolicy: 'Always', - livenessProbe: { - failureThreshold: 3, - httpGet: { - path: '/oauth/healthz', - port: 'oauth-proxy', - scheme: 'HTTPS', - }, - initialDelaySeconds: 30, - periodSeconds: 5, - successThreshold: 1, - timeoutSeconds: 1, - }, - name: 'oauth-proxy', - ports: [ + enableServiceLinks: false, + tolerations: [ { - containerPort: 8443, - name: 'oauth-proxy', - protocol: 'TCP', + effect: 'NoSchedule', + key: 'NotebooksOnlyChange', + operator: 'Exists', }, ], - readinessProbe: { - failureThreshold: 3, - httpGet: { - path: '/oauth/healthz', - port: 'oauth-proxy', - scheme: 'HTTPS', - }, - initialDelaySeconds: 5, - periodSeconds: 5, - successThreshold: 1, - timeoutSeconds: 1, - }, - resources: { - limits: { - cpu: '100m', - memory: '64Mi', - }, - requests: { - cpu: '100m', - memory: '64Mi', + volumes: [ + { + name: name, + persistentVolumeClaim: { + claimName: name, + }, }, - }, - volumeMounts: [ { - mountPath: '/etc/oauth/config', name: 'oauth-config', + secret: { + secretName: 'workbench-oauth-config', + }, }, { - mountPath: '/etc/tls/private', name: 'tls-certificates', + secret: { + secretName: 'workbench-tls', + }, }, ], }, - ], - enableServiceLinks: false, - serviceAccountName: name, - tolerations: [ + }, + }, + status: { + conditions: [ { - effect: 'NoSchedule', - key: 'NotebooksOnlyChange', - operator: 'Exists', + lastProbeTime: '2023-02-14T22:06:54Z', + type: 'Running', }, - ], - volumes: [ { - name: name, - persistentVolumeClaim: { - claimName: name, - }, + lastProbeTime: '2023-02-14T22:06:44Z', + message: 'Completed', + reason: 'Completed', + type: 'Terminated', }, { - name: 'oauth-config', - secret: { - defaultMode: 420, - secretName: 'workbench-oauth-config', - }, + lastProbeTime: '2023-02-14T22:05:53Z', + type: 'Running', }, { - name: 'tls-certificates', - secret: { - defaultMode: 420, - secretName: 'workbench-tls', - }, + lastProbeTime: '2023-02-14T22:05:48Z', + reason: 'ContainerCreating', + type: 'Waiting', + }, + { + lastProbeTime: '2023-02-14T21:44:27Z', + type: 'Running', + }, + { + lastProbeTime: '2023-02-14T21:44:24Z', + reason: 'ContainerCreating', + type: 'Waiting', }, ], + containerState: {}, + readyReplicas: 1, }, - }, - }, - status: { - conditions: [ - { - lastProbeTime: '2023-02-14T22:06:54Z', - type: 'Running', - }, - { - lastProbeTime: '2023-02-14T22:06:44Z', - message: 'Completed', - reason: 'Completed', - type: 'Terminated', - }, - { - lastProbeTime: '2023-02-14T22:05:53Z', - type: 'Running', - }, - { - lastProbeTime: '2023-02-14T22:05:48Z', - reason: 'ContainerCreating', - type: 'Waiting', - }, - { - lastProbeTime: '2023-02-14T21:44:27Z', - type: 'Running', - }, - { - lastProbeTime: '2023-02-14T21:44:24Z', - reason: 'ContainerCreating', - type: 'Waiting', - }, - ], - containerState: { - running: { - startedAt: '2023-02-14T22:06:52Z', - }, - }, - readyReplicas: 1, - }, -}); + } as NotebookKind, + opts, + ); diff --git a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts index a89ca6bf86..76535889ec 100644 --- a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts +++ b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts @@ -24,5 +24,35 @@ test('Non-empty project', async ({ page }) => { expect(await page.locator('[data-id="details-page-section-divider"]').all()).toHaveLength(0); // check the x-small size shown correctly - expect(await page.getByText('XSmall')).toBeTruthy(); + expect(page.getByText('Small')).toBeTruthy(); +}); + +test('Notebook with deleted image', async ({ page }) => { + await page.goto(navigateToStory('pages-projects-projectdetails', 'deleted-image')); + + // wait for page to load + await page.waitForSelector('text=Test Notebook'); + + await expect(page.getByText('Deleted')).toHaveCount(1); + await expect(page.getByText('Test Image')).toHaveCount(1); +}); + +test('Notebook with disabled image', async ({ page }) => { + await page.goto(navigateToStory('pages-projects-projectdetails', 'disabled-image')); + + // wait for page to load + await page.waitForSelector('text=Test Notebook'); + + await expect(page.getByText('Disabled', { exact: true })).toHaveCount(1); + await expect(page.getByText('Test Image')).toHaveCount(1); +}); + +test('Notebook with unknown image', async ({ page }) => { + await page.goto(navigateToStory('pages-projects-projectdetails', 'unknown-image')); + + // wait for page to load + await page.waitForSelector('text=Test Notebook'); + + await expect(page.getByText('Deleted')).toHaveCount(1); + await expect(page.getByText('Unknown', { exact: true })).toHaveCount(1); }); diff --git a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx index 747c391d33..1c0d4cf8e7 100644 --- a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx +++ b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.stories.tsx @@ -25,8 +25,39 @@ import { mockStatus } from '~/__mocks__/mockStatus'; import { mockTemplateK8sResource } from '~/__mocks__/mockServingRuntimeTemplateK8sResource'; import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; import ProjectDetails from '~/pages/projects/screens/detail/ProjectDetails'; +import { mockImageStreamK8sResource } from '~/__mocks__/mockImageStreamK8sResource'; const handlers = (isEmpty: boolean): RestHandler>[] => [ + rest.get( + '/api/k8s/apis/image.openshift.io/v1/namespaces/opendatahub/imagestreams', + (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([ + mockImageStreamK8sResource({ + name: 'test-image', + displayName: 'Test image', + opts: { + spec: { + tags: [ + { + name: 'latest', + }, + ], + }, + status: { + tags: [ + { + tag: 'latest', + }, + ], + }, + }, + }), + ]), + ), + ), + ), rest.get('/api/status', (req, res, ctx) => res(ctx.json(mockStatus()))), rest.get('/api/k8s/api/v1/namespaces/test-project/pods', (req, res, ctx) => res(ctx.json(mockK8sResourceList(isEmpty ? [] : [mockPodK8sResource({})]))), @@ -46,13 +77,26 @@ const handlers = (isEmpty: boolean): RestHandler> isEmpty ? [] : [ - mockNotebookK8sResource({}), mockNotebookK8sResource({ - name: 'test-size', - displayName: 'Test Size X-small', - resources: { - limits: { cpu: '500m', memory: '500Mi' }, - requests: { cpu: '100m', memory: '100Mi' }, + opts: { + spec: { + template: { + spec: { + containers: [ + { + name: 'test-notebook', + image: 'test-image:latest', + }, + ], + }, + }, + }, + metadata: { + name: 'test-notebook', + annotations: { + 'opendatahub.io/image-display-name': 'Test image', + }, + }, }, }), ], @@ -175,3 +219,142 @@ export const EmptyDetailsPage: StoryObj = { await canvas.findByText('No model servers', undefined, { timeout: 5000 }); }, }; + +export const DisabledImage: StoryObj = { + render: Template, + + parameters: { + msw: { + handlers: [ + rest.get( + '/api/k8s/apis/image.openshift.io/v1/namespaces/opendatahub/imagestreams', + (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([ + mockImageStreamK8sResource({ + name: 'test-image', + displayName: 'Test image', + opts: { + metadata: { + labels: { + 'opendatahub.io/notebook-image': 'false', + }, + }, + spec: { + tags: [ + { + name: 'latest', + }, + ], + }, + status: { + tags: [ + { + tag: 'latest', + }, + ], + }, + }, + }), + ]), + ), + ), + ), + ...handlers(false), + ], + }, + }, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Test Notebook', undefined, { timeout: 5000 }); + }, +}; + +export const DeletedImage: StoryObj = { + render: Template, + + parameters: { + msw: { + handlers: [ + rest.get( + '/api/k8s/apis/image.openshift.io/v1/namespaces/opendatahub/imagestreams', + (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([ + mockImageStreamK8sResource({ + name: 'test-image', + displayName: 'Test image', + }), + ]), + ), + ), + ), + ...handlers(false), + ], + }, + }, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Test Notebook', undefined, { timeout: 5000 }); + }, +}; + +export const UnknownImage: StoryObj = { + render: Template, + + parameters: { + msw: { + handlers: [ + rest.get( + '/api/k8s/apis/image.openshift.io/v1/namespaces/opendatahub/imagestreams', + (req, res, ctx) => res(ctx.json(mockK8sResourceList([]))), + ), + rest.get( + '/api/k8s/apis/kubeflow.org/v1/namespaces/test-project/notebooks', + (req, res, ctx) => + res( + ctx.json( + mockK8sResourceList([ + mockNotebookK8sResource({ + opts: { + spec: { + template: { + spec: { + containers: [ + { + name: 'test-notebook', + image: 'test-image:latest', + }, + ], + }, + }, + }, + metadata: { + name: 'test-notebook', + annotations: { + 'opendatahub.io/image-display-name': '', + }, + }, + }, + }), + ]), + ), + ), + ), + ...handlers(false), + ], + }, + }, + + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('Test Notebook', undefined, { timeout: 5000 }); + }, +}; diff --git a/frontend/src/api/k8s/imageStreams.ts b/frontend/src/api/k8s/imageStreams.ts index 60cacc96ba..e54dffded1 100644 --- a/frontend/src/api/k8s/imageStreams.ts +++ b/frontend/src/api/k8s/imageStreams.ts @@ -2,11 +2,16 @@ import { k8sListResourceItems } from '@openshift/dynamic-plugin-sdk-utils'; import { ImageStreamModel } from '~/api/models'; import { ImageStreamKind } from '~/k8sTypes'; -export const getNotebookImageStreams = (namespace: string): Promise => +export const getNotebookImageStreams = ( + namespace: string, + includeDisabled?: boolean, +): Promise => k8sListResourceItems({ model: ImageStreamModel, queryOptions: { ns: namespace, - queryParams: { labelSelector: 'opendatahub.io/notebook-image=true' }, + queryParams: { + labelSelector: includeDisabled ? undefined : 'opendatahub.io/notebook-image=true', + }, }, }); diff --git a/frontend/src/api/k8s/notebooks.ts b/frontend/src/api/k8s/notebooks.ts index 3fd58721a8..3b92374ee0 100644 --- a/frontend/src/api/k8s/notebooks.ts +++ b/frontend/src/api/k8s/notebooks.ts @@ -25,6 +25,7 @@ import { getPipelineVolumePatch, } from '~/concepts/pipelines/elyra/utils'; import { Volume, VolumeMount } from '~/types'; +import { getImageStreamDisplayName } from '~/pages/projects/screens/spawner/spawnerUtils'; import { assemblePodSpecOptions, getshmVolume, getshmVolumeMount } from './utils'; const assembleNotebook = ( @@ -87,7 +88,7 @@ const assembleNotebook = ( volumeMounts.push(getshmVolumeMount()); } - return { + const resource: NotebookKind = { apiVersion: 'kubeflow.org/v1', kind: 'Notebook', metadata: { @@ -178,6 +179,15 @@ const assembleNotebook = ( }, }, }; + + // set image display name + if (image.imageStream && resource.metadata.annotations) { + resource.metadata.annotations['opendatahub.io/image-display-name'] = getImageStreamDisplayName( + image.imageStream, + ); + } + + return resource; }; const getStopPatchDataString = (): string => new Date().toISOString().replace(/\.\d{3}Z/i, 'Z'); diff --git a/frontend/src/k8sTypes.ts b/frontend/src/k8sTypes.ts index 733304a0ee..4c6484436a 100644 --- a/frontend/src/k8sTypes.ts +++ b/frontend/src/k8sTypes.ts @@ -74,6 +74,7 @@ export type NotebookAnnotations = Partial<{ 'notebooks.opendatahub.io/last-image-selection': string; // the last image they selected 'notebooks.opendatahub.io/last-size-selection': string; // the last notebook size they selected 'opendatahub.io/accelerator-name': string; // the accelerator attached to the notebook + 'opendatahub.io/image-display-name': string; // the display name of the image }>; export type DashboardLabels = { @@ -259,10 +260,10 @@ export type PersistentVolumeClaimKind = K8sResourceCommon & { export type NotebookKind = K8sResourceCommon & { metadata: { - annotations: DisplayNameAnnotations & NotebookAnnotations; + annotations?: DisplayNameAnnotations & NotebookAnnotations; name: string; namespace: string; - labels: Partial<{ + labels?: Partial<{ 'opendatahub.io/user': string; // translated username -- see translateUsername }>; }; diff --git a/frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx b/frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx index 22e98ecb5c..c0ac8f062a 100644 --- a/frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx +++ b/frontend/src/pages/projects/notebook/NotebookStatusToggle.tsx @@ -17,12 +17,14 @@ type NotebookStatusToggleProps = { notebookState: NotebookState; doListen: boolean; enablePipelines?: boolean; + isDisabled?: boolean; }; const NotebookStatusToggle: React.FC = ({ notebookState, doListen, enablePipelines, + isDisabled, }) => { const { notebook, isStarting, isRunning, isStopping, refresh } = notebookState; const [acceleratorData] = useNotebookAccelerators(notebook); @@ -87,7 +89,7 @@ const NotebookStatusToggle: React.FC = ({ { diff --git a/frontend/src/pages/projects/screens/detail/notebooks/NotebookImageDisplayName.tsx b/frontend/src/pages/projects/screens/detail/notebooks/NotebookImageDisplayName.tsx new file mode 100644 index 0000000000..ea9e816f7b --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/notebooks/NotebookImageDisplayName.tsx @@ -0,0 +1,153 @@ +import { + Popover, + Spinner, + Flex, + FlexItem, + Label, + Text, + Alert, + AlertProps, + LabelProps, + TextVariants, + HelperText, + HelperTextItem, +} from '@patternfly/react-core'; +import React from 'react'; +import { ExclamationCircleIcon, InfoCircleIcon } from '@patternfly/react-icons'; +import { NotebookImageAvailability } from './const'; +import { NotebookImage } from './types'; + +type NotebookImageDisplayNameProps = { + notebookImage: NotebookImage | null; + loaded: boolean; + loadError?: Error; + isExpanded?: boolean; +}; + +export const NotebookImageDisplayName = ({ + notebookImage, + loaded, + loadError, + isExpanded, +}: NotebookImageDisplayNameProps) => { + // if there was an error loading the image, display unknown WITHOUT a label + if (loadError) { + return ( + + Unknown + + ); + } + + // If the image is not loaded, display a spinner + if (!loaded || !notebookImage) { + return ; + } + + // helper function to get the popover variant and text + const getNotebookImagePopoverText = (): + | Record + | { + title: string; + body: React.ReactNode; + variant: AlertProps['variant']; + } => { + if (notebookImage.imageAvailability === NotebookImageAvailability.DISABLED) { + return { + title: 'Notebook image disabled', + body: ( +

+ The {notebookImage.imageDisplayName} notebook image has been disabled. This + workbench can continue using the image, but disabled images are not available for + selection when creating new workbenches. +

+ ), + variant: 'info', + }; + } else if (notebookImage.imageAvailability === NotebookImageAvailability.DELETED) { + const unknownBody = + 'An unknown notebook image has been deleted. To run this workbench, select a new notebook image.'; + const knownBody = ( +

+ The {notebookImage.imageDisplayName} notebook image has been deleted. To run this + workbench, select a new notebook image. +

+ ); + return { + title: 'Notebook image deleted', + body: notebookImage.imageDisplayName ? knownBody : unknownBody, + variant: 'danger', + }; + } + + return {}; + }; + + // helper function to get the label color + const getNotebookImageLabelColor = (): LabelProps['color'] => { + switch (notebookImage.imageAvailability) { + case NotebookImageAvailability.DISABLED: + return 'grey'; + case NotebookImageAvailability.DELETED: + return 'red'; + default: + return undefined; + } + }; + + // helper function to get the label icon + const getNotebookImageIcon = (): LabelProps['icon'] => { + switch (notebookImage.imageAvailability) { + case NotebookImageAvailability.DISABLED: + return ; + case NotebookImageAvailability.DELETED: + return ; + default: + return undefined; + } + }; + + // If the image is enabled, just display the name, no label is needed + if (notebookImage.imageAvailability === NotebookImageAvailability.ENABLED) { + return ( + <> + + {notebookImage.imageDisplayName} + + {isExpanded && {notebookImage.tagSoftware}} + + ); + } + + // get the popover title, body, and variant based on the image availability + const { title, body, variant } = getNotebookImagePopoverText(); + + // otherwise, return the popover with the label as the trigger + return ( + <> + + + + + {notebookImage.imageDisplayName || 'Unknown'} + + + + + } + bodyContent={body} + > + + + + + {isExpanded && notebookImage.imageAvailability !== NotebookImageAvailability.DELETED && ( + {notebookImage.tagSoftware} + )} + + ); +}; diff --git a/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx b/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx index 82d1fbc6d8..58b042dd3a 100644 --- a/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx +++ b/frontend/src/pages/projects/screens/detail/notebooks/NotebookTableRow.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ActionsColumn, ExpandableRowContent, Tbody, Td, Tr } from '@patternfly/react-table'; -import { Flex, FlexItem, Icon, Spinner, Text, TextVariants, Tooltip } from '@patternfly/react-core'; +import { Flex, FlexItem, Icon, Tooltip } from '@patternfly/react-core'; import { useNavigate } from 'react-router-dom'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { NotebookState } from '~/pages/projects/notebook/types'; @@ -15,6 +15,8 @@ import useNotebookDeploymentSize from './useNotebookDeploymentSize'; import useNotebookImage from './useNotebookImage'; import NotebookSizeDetails from './NotebookSizeDetails'; import NotebookStorageBars from './NotebookStorageBars'; +import { NotebookImageDisplayName } from './NotebookImageDisplayName'; +import { NotebookImageAvailability } from './const'; type NotebookTableRowProps = { obj: NotebookState; @@ -35,7 +37,7 @@ const NotebookTableRow: React.FC = ({ const navigate = useNavigate(); const [isExpanded, setExpanded] = React.useState(false); const { size: notebookSize, error: sizeError } = useNotebookDeploymentSize(obj.notebook); - const [notebookImage, loaded] = useNotebookImage(obj.notebook); + const [notebookImage, loaded, loadError] = useNotebookImage(obj.notebook); return ( @@ -56,14 +58,12 @@ const NotebookTableRow: React.FC = ({ /> - {!loaded ? ( - - ) : ( - {notebookImage?.imageName ?? 'Unknown'} - )} - {isExpanded && notebookImage?.tagSoftware && ( - {notebookImage.tagSoftware} - )} + = ({ notebookState={obj} doListen={false} enablePipelines={canEnablePipelines} + isDisabled={ + notebookImage?.imageAvailability === NotebookImageAvailability.DELETED && + !obj.isRunning + } /> @@ -121,7 +125,8 @@ const NotebookTableRow: React.FC = ({ - {notebookImage ? ( + {notebookImage && + notebookImage.imageAvailability !== NotebookImageAvailability.DELETED ? ( ) : ( 'Unknown package info' diff --git a/frontend/src/pages/projects/screens/detail/notebooks/const.ts b/frontend/src/pages/projects/screens/detail/notebooks/const.ts new file mode 100644 index 0000000000..98a1af9084 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/notebooks/const.ts @@ -0,0 +1,5 @@ +export enum NotebookImageAvailability { + DISABLED = 'Disabled', + ENABLED = 'Enabled', + DELETED = 'Deleted', +} diff --git a/frontend/src/pages/projects/screens/detail/notebooks/types.ts b/frontend/src/pages/projects/screens/detail/notebooks/types.ts new file mode 100644 index 0000000000..47aad09803 --- /dev/null +++ b/frontend/src/pages/projects/screens/detail/notebooks/types.ts @@ -0,0 +1,42 @@ +import { ImageVersionDependencyType } from '~/pages/projects/screens/spawner/types'; +import { ImageStreamKind, ImageStreamSpecTagType } from '~/k8sTypes'; +import { NotebookImageAvailability } from './const'; + +export type NotebookImage = + | { + imageAvailability: Exclude; + imageDisplayName: string; + tagSoftware: string; + dependencies: ImageVersionDependencyType[]; + } + | { + imageAvailability: NotebookImageAvailability.DELETED; + imageDisplayName?: string; + }; + +export type ImageData = { + imageStream: ImageStreamKind; + imageVersion: ImageStreamSpecTagType; +}; + +export type NotebookImageData = + | [data: null, loaded: false, loadError: undefined] + | [data: null, loaded: false, loadError: Error] + | [ + data: { + imageAvailability: NotebookImageAvailability.DELETED; + imageDisplayName?: string; + }, + loaded: true, + loadError: undefined, + ] + | [ + data: { + imageAvailability: Exclude; + imageDisplayName: string; + imageStream: ImageStreamKind; + imageVersion: ImageStreamSpecTagType; + }, + loaded: true, + loadError: undefined, + ]; diff --git a/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImage.ts b/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImage.ts index e68509d05f..5257fe2183 100644 --- a/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImage.ts +++ b/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImage.ts @@ -2,35 +2,48 @@ import { NotebookKind } from '~/k8sTypes'; import { getImageStreamDisplayName, getImageVersionDependencies, - getRelatedVersionDescription, + getImageVersionSoftwareString, } from '~/pages/projects/screens/spawner/spawnerUtils'; -import { ImageVersionDependencyType } from '~/pages/projects/screens/spawner/types'; import useNotebookImageData from './useNotebookImageData'; - -export type NotebookImage = { - imageName: string; - tagSoftware?: string; - dependencies: ImageVersionDependencyType[]; -}; +import { NotebookImageAvailability } from './const'; +import { NotebookImage } from './types'; const useNotebookImage = ( notebook: NotebookKind, -): [notebookImage: NotebookImage | null, loaded: boolean] => { - const [imageData, loaded] = useNotebookImageData(notebook); +): + | [notebookImage: null, loaded: false, loadError?: Error] + | [notebookImage: NotebookImage, loaded: true, loadError: undefined] => { + const [data, loaded, loadError] = useNotebookImageData(notebook); + + if (!notebook || !loaded) { + return [null, false, loadError]; + } + + const { imageDisplayName, imageAvailability } = data; - if (!imageData) { - return [null, loaded]; + // if the image is deleted, return the image name if it is available (based on notebook annotations) + if (imageAvailability === NotebookImageAvailability.DELETED) { + return [ + { + imageDisplayName, + imageAvailability, + }, + true, + undefined, + ]; } - const { imageStream, imageVersion } = imageData; + const { imageStream, imageVersion } = data; return [ { - imageName: getImageStreamDisplayName(imageStream), - tagSoftware: getRelatedVersionDescription(imageStream), + imageDisplayName: getImageStreamDisplayName(imageStream), + tagSoftware: getImageVersionSoftwareString(imageVersion), dependencies: getImageVersionDependencies(imageVersion, false), + imageAvailability, }, - loaded, + true, + undefined, ]; }; diff --git a/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImageData.ts b/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImageData.ts index 716ca584b4..fe2270898d 100644 --- a/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImageData.ts +++ b/frontend/src/pages/projects/screens/detail/notebooks/useNotebookImageData.ts @@ -1,25 +1,19 @@ import * as React from 'react'; -import { ImageStreamKind, ImageStreamSpecTagType, NotebookKind } from '~/k8sTypes'; +import { NotebookKind } from '~/k8sTypes'; import useNamespaces from '~/pages/notebookController/useNamespaces'; import useImageStreams from '~/pages/projects/screens/spawner/useImageStreams'; import { NotebookContainer } from '~/types'; +import { getImageStreamDisplayName } from '~/pages/projects/screens/spawner/spawnerUtils'; +import { NotebookImageAvailability } from './const'; +import { NotebookImageData } from './types'; -const useNotebookImageData = ( - notebook?: NotebookKind, -): [ - data: { imageStream: ImageStreamKind; imageVersion: ImageStreamSpecTagType } | null, - loaded: boolean, -] => { +const useNotebookImageData = (notebook?: NotebookKind): NotebookImageData => { const { dashboardNamespace } = useNamespaces(); - const [images, loaded] = useImageStreams(dashboardNamespace); + const [images, loaded, loadError] = useImageStreams(dashboardNamespace, true); return React.useMemo(() => { - if (!notebook) { - return [null, false]; - } - - if (!loaded) { - return [null, false]; + if (!loaded || !notebook) { + return [null, false, loadError]; } const container: NotebookContainer | undefined = notebook.spec.template.spec.containers.find( @@ -27,26 +21,68 @@ const useNotebookImageData = ( ); const imageTag = container?.image.split('/').at(-1)?.split(':'); + // if image could not be parsed from the container, consider it deleted because the image tag is invalid if (!imageTag || imageTag.length < 2 || !container) { - return [null, true]; + return [ + { + imageAvailability: NotebookImageAvailability.DELETED, + }, + true, + undefined, + ]; } const [imageName, versionName] = imageTag; const imageStream = images.find((image) => image.metadata.name === imageName); + // if the image stream is not found, consider it deleted if (!imageStream) { - return [null, true]; + // Get the image display name from the notebook metadata if we can't find the image stream. (this is a fallback and could still be undefined) + const imageDisplayName = notebook.metadata.annotations?.['opendatahub.io/image-display-name']; + + return [ + { + imageAvailability: NotebookImageAvailability.DELETED, + imageDisplayName, + }, + true, + undefined, + ]; } const versions = imageStream.spec.tags || []; const imageVersion = versions.find((version) => version.name === versionName); + // because the image stream was found, get its display name + const imageDisplayName = getImageStreamDisplayName(imageStream); + + // if the image version is not found, consider the image stream deleted if (!imageVersion) { - return [null, true]; + return [ + { + imageAvailability: NotebookImageAvailability.DELETED, + imageDisplayName, + }, + true, + undefined, + ]; } - return [{ imageStream, imageVersion }, loaded]; - }, [images, loaded, notebook]); + // if the image stream exists and the image version exists, return the image data + return [ + { + imageStream, + imageVersion, + imageAvailability: + imageStream.metadata.labels?.['opendatahub.io/notebook-image'] === 'true' + ? NotebookImageAvailability.ENABLED + : NotebookImageAvailability.DISABLED, + imageDisplayName, + }, + true, + undefined, + ]; + }, [images, notebook, loaded, loadError]); }; export default useNotebookImageData; diff --git a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx index d7ae0600f3..06fcfc9ccd 100644 --- a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx +++ b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx @@ -29,6 +29,7 @@ import useWillNotebooksRestart from '~/pages/projects/notebook/useWillNotebooksR import CanEnableElyraPipelinesCheck from '~/concepts/pipelines/elyra/CanEnableElyraPipelinesCheck'; import AcceleratorSelectField from '~/pages/notebookController/screens/server/AcceleratorSelectField'; import useNotebookAccelerator from '~/pages/projects/screens/detail/notebooks/useNotebookAccelerator'; +import { NotebookImageAvailability } from '~/pages/projects/screens/detail/notebooks/const'; import { SpawnerPageSectionID } from './types'; import { ScrollableSelectorID, SpawnerPageSectionTitles } from './const'; import SpawnerFooter from './SpawnerFooter'; @@ -86,13 +87,15 @@ const SpawnerPage: React.FC = ({ existingNotebook }) => { } }, [existingNotebook, setStorageData]); - const [data, loaded] = useNotebookImageData(existingNotebook); + const [data, loaded, loadError] = useNotebookImageData(existingNotebook); React.useEffect(() => { - const { imageStream, imageVersion } = data || {}; - if (loaded && imageStream && imageVersion) { - setSelectedImage({ imageStream, imageVersion }); + if (loaded) { + if (data.imageAvailability === NotebookImageAvailability.ENABLED) { + const { imageStream, imageVersion } = data; + setSelectedImage({ imageStream, imageVersion }); + } } - }, [data, loaded]); + }, [data, loaded, loadError]); const { size: notebookSize } = useNotebookDeploymentSize(existingNotebook); React.useEffect(() => { diff --git a/frontend/src/pages/projects/screens/spawner/imageSelector/ImageSelectorField.tsx b/frontend/src/pages/projects/screens/spawner/imageSelector/ImageSelectorField.tsx index 883908472b..0a430fa9dc 100644 --- a/frontend/src/pages/projects/screens/spawner/imageSelector/ImageSelectorField.tsx +++ b/frontend/src/pages/projects/screens/spawner/imageSelector/ImageSelectorField.tsx @@ -52,40 +52,38 @@ const ImageSelectorField: React.FC = ({ }); }; + if (error) { + return ( + + {error.message} + + ); + } + if (!loaded) { return ; } return ( <> - {error ? ( - - {error.message} - - ) : ( - <> - !isInvalidBYONImageStream(imageStream), - )} - buildStatuses={buildStatuses} - onImageStreamSelect={onImageStreamSelect} - selectedImageStream={selectedImage.imageStream} - compatibleAccelerator={compatibleAccelerator} - /> - - setSelectedImage((oldSelectedImage) => ({ - ...oldSelectedImage, - imageVersion: selection, - })) - } - selectedImageVersion={selectedImage.imageVersion} - /> - - - )} + !isInvalidBYONImageStream(imageStream))} + buildStatuses={buildStatuses} + onImageStreamSelect={onImageStreamSelect} + selectedImageStream={selectedImage.imageStream} + compatibleAccelerator={compatibleAccelerator} + /> + + setSelectedImage((oldSelectedImage) => ({ + ...oldSelectedImage, + imageVersion: selection, + })) + } + selectedImageVersion={selectedImage.imageVersion} + /> + ); }; diff --git a/frontend/src/pages/projects/screens/spawner/useImageStreams.ts b/frontend/src/pages/projects/screens/spawner/useImageStreams.ts index ba640a1a33..b3c14d74ae 100644 --- a/frontend/src/pages/projects/screens/spawner/useImageStreams.ts +++ b/frontend/src/pages/projects/screens/spawner/useImageStreams.ts @@ -1,29 +1,18 @@ import * as React from 'react'; import { ImageStreamKind } from '~/k8sTypes'; import { getNotebookImageStreams } from '~/api'; +import useFetchState, { FetchState } from '~/utilities/useFetchState'; const useImageStreams = ( - namespace?: string, -): [imageStreams: ImageStreamKind[], loaded: boolean, loadError: Error | undefined] => { - const [imageStreams, setImageStreams] = React.useState([]); - const [loaded, setLoaded] = React.useState(false); - const [loadError, setLoadError] = React.useState(undefined); + namespace: string, + includeDisabled?: boolean, +): FetchState => { + const getImages = React.useCallback( + () => getNotebookImageStreams(namespace, includeDisabled), + [namespace, includeDisabled], + ); - React.useEffect(() => { - if (namespace) { - getNotebookImageStreams(namespace) - .then((imageStreams) => { - setImageStreams(imageStreams); - setLoaded(true); - }) - .catch((e) => { - setLoadError(e); - setLoaded(true); - }); - } - }, [namespace]); - - return [imageStreams, loaded, loadError]; + return useFetchState(getImages, []); }; export default useImageStreams; diff --git a/frontend/src/typeHelpers.ts b/frontend/src/typeHelpers.ts index 65026cdb5f..6374da555e 100644 --- a/frontend/src/typeHelpers.ts +++ b/frontend/src/typeHelpers.ts @@ -12,9 +12,11 @@ export type AnyObject = Record; * * TODO: Implement the SDK & Patch logic -- this should stop being needed as things will be defined as Patches */ -export type RecursivePartial = { - [P in keyof T]?: RecursivePartial; -}; +export type RecursivePartial = T extends object + ? { + [P in keyof T]?: RecursivePartial; + } + : T; /** * Partial only some properties.