diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index bae338ceef..710fc1c2aa 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -23,7 +23,7 @@ body: options: - OpenDataHub core version (eg. `v1.6.0`) - Installing Dashboard directly (eg. `v2.12.0`, `commit xyz`, `branch name`) - - Downstream version (eg. `RHODS 1.29`) + - Downstream version (eg. `OpenShift AI 2.4`) validations: required: true - type: input @@ -32,7 +32,7 @@ body: label: Version description: | What was the version this was found on? - + eg. a branch name, a commit id, or a version number validations: required: true diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index d95072290d..2bd3bb70ed 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -1,8 +1,6 @@ name: Pull request on: pull_request: - branches: [ main, f/** ] - jobs: Tests: runs-on: ubuntu-latest diff --git a/backend/src/routes/api/namespaces/namespaceUtils.ts b/backend/src/routes/api/namespaces/namespaceUtils.ts index 05b1a7dc7f..c55387debb 100644 --- a/backend/src/routes/api/namespaces/namespaceUtils.ts +++ b/backend/src/routes/api/namespaces/namespaceUtils.ts @@ -65,7 +65,6 @@ export const applyNamespaceChange = async ( case NamespaceApplicationCase.DSG_CREATION: labels = { 'opendatahub.io/dashboard': 'true', - 'modelmesh-enabled': 'true', }; break; case NamespaceApplicationCase.MODEL_SERVING_PROMOTION: diff --git a/docs/process-definition/branches.md b/docs/process-definition/branches.md index 889edfb78f..77ab38363a 100644 --- a/docs/process-definition/branches.md +++ b/docs/process-definition/branches.md @@ -17,7 +17,7 @@ There are really two types of branches. - Core branches (like `main` and `incubation`) - [Bot branches](#bot-branches) -Every _new_ commit needs to come from a fork through a PR. We don't allow for pushing new content directly through our flows. New docs file, new code change, and even fixing a typo needs a PR from your fork to get into our repository. +Every _new_ commit needs to come from a fork through a PR. We don't allow for pushing new content directly through our flows. New docs file, new code change, and even fixing a typo needs a PR from your fork to get into our repository. This ensures automated tests pass before the change is merged. With that said, there are really 3 types of flows that utilize both fork branches and Upstream branches. @@ -33,6 +33,19 @@ There is only ever 1 `main` and 1 `incubation` branch. Feature branches start wi Read more on git tags & releases in our [release documentation]. +## Merging upstream branches + +Understanding the commit history on an upstream branch is important. Therefore when merging an upstream branch into another, your PR branch must follow the pattern `merge-`. + +For example when merging `f/some-feature` into `incubation`, name your branch `merge-f/some-feature`. This will result in a commit message on the `incubation` branch of `Merge pull request # from /merge-f/some-feature` when the PR is merged. + +Use the following steps to create a PR when merging upstream branches: + +- `git checkout -b merge- ` +- `git pull --no-rebase upstream ` +- Resolve all conflicts then post a PR. +- If the target branch is `main`, wait for the PR to be approved. For all other target branches, apply the `approved` and `lgtm` labels to the PR once all checks pass. + ## Main > aka "The Stable Branch" diff --git a/docs/process-definition/releases.md b/docs/process-definition/releases.md index 4f18b3982e..8aea3527de 100644 --- a/docs/process-definition/releases.md +++ b/docs/process-definition/releases.md @@ -25,7 +25,7 @@ There are two types of releases: ### Release of `main` -Every 3 weeks we release on a Friday. This is useful for any downstream products (like RHODS) to use our stable code. +Every 3 weeks we release on a Friday. This is useful for any downstream products (like Red Hat OpenShift AI) to use our stable code. ODH & other "bleeding edge" implementations may want to consider using `incubation`. Read more on [incubation]. diff --git a/frontend/.env b/frontend/.env index 3d07ff6faf..6fff56ba32 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,7 +1,7 @@ ODH_IS_PROJECT_ROOT_DIR=false ODH_PORT=${FRONTEND_PORT} -########## Change the following three variables for ODH/RHODS ########## +########## Change the following three variables for ODH/OpenShift AI ########## ODH_LOGO=odh-logo.svg ODH_PRODUCT_NAME=Open Data Hub ODH_FAVICON=odh-favicon.svg diff --git a/frontend/.eslintrc b/frontend/.eslintrc index 723794959b..b03965d33a 100755 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -69,6 +69,10 @@ "group": ["~/components/table/**", "!~/components/table/useTableColumnSort"], "message": "Read from '~/components/table' instead." }, + { + "group": ["~/concepts/area/**"], + "message": "Read from '~/concepts/area' instead." + }, { "group": ["~/components/table/useTableColumnSort"], "message": "The data will be sorted in the table, don't use this hook outside of '~/components/table' repo. For more information, please check the props of the Table component." diff --git a/frontend/src/__mocks__/mockDashboardConfig.ts b/frontend/src/__mocks__/mockDashboardConfig.ts index b388678b38..960f2ed437 100644 --- a/frontend/src/__mocks__/mockDashboardConfig.ts +++ b/frontend/src/__mocks__/mockDashboardConfig.ts @@ -1,5 +1,4 @@ -import { DashboardConfig } from '~/types'; -import { KnownLabels } from '~/k8sTypes'; +import { DashboardConfigKind, KnownLabels } from '~/k8sTypes'; type MockDashboardConfigType = { disableInfo?: boolean; @@ -11,6 +10,7 @@ type MockDashboardConfigType = { disableAppLauncher?: boolean; disableUserManagement?: boolean; disableProjects?: boolean; + disablePipelines?: boolean; disableModelServing?: boolean; disableCustomServingRuntimes?: boolean; }; @@ -27,7 +27,8 @@ export const mockDashboardConfig = ({ disableProjects = false, disableModelServing = false, disableCustomServingRuntimes = false, -}: MockDashboardConfigType): DashboardConfig => ({ + disablePipelines = false, +}: MockDashboardConfigType): DashboardConfigKind => ({ apiVersion: 'opendatahub.io/v1alpha', kind: 'OdhDashboardConfig', metadata: { @@ -51,13 +52,13 @@ export const mockDashboardConfig = ({ disableProjects, disableModelServing, disableCustomServingRuntimes, + disablePipelines, modelMetricsNamespace: 'test-project', - disablePipelines: false, disableProjectSharing: false, }, notebookController: { enabled: true, - notebookNamespace: 'rhods-notebooks', + notebookNamespace: 'openshift-ai-notebooks', notebookTolerationSettings: { enabled: true, key: 'NotebooksOnlyChange', @@ -65,7 +66,7 @@ export const mockDashboardConfig = ({ pvcSize: '20Gi', }, groupsConfig: { - adminGroups: 'rhods-admins', + adminGroups: 'openshift-ai-admins', allowedGroups: 'system:authenticated', }, modelServerSizes: [ diff --git a/frontend/src/__mocks__/mockDscStatus.ts b/frontend/src/__mocks__/mockDscStatus.ts new file mode 100644 index 0000000000..c3428b5b5c --- /dev/null +++ b/frontend/src/__mocks__/mockDscStatus.ts @@ -0,0 +1,20 @@ +import { DataScienceClusterKindStatus } from '~/k8sTypes'; +import { StackComponent } from '~/concepts/areas/types'; + +type MockDscStatus = { + installedComponents?: DataScienceClusterKindStatus['installedComponents']; +}; + +export const mockDscStatus = ({ + installedComponents, +}: MockDscStatus): DataScienceClusterKindStatus => ({ + conditions: [], + installedComponents: Object.values(StackComponent).reduce( + (acc, component) => ({ + ...acc, + [component]: installedComponents?.[component] ?? false, + }), + {}, + ), + phase: 'Ready', +}); 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/__mocks__/mockProjectK8sResource.ts b/frontend/src/__mocks__/mockProjectK8sResource.ts index 01373f114d..14033257d4 100644 --- a/frontend/src/__mocks__/mockProjectK8sResource.ts +++ b/frontend/src/__mocks__/mockProjectK8sResource.ts @@ -1,3 +1,4 @@ +import { K8sResourceListResult } from '@openshift/dynamic-plugin-sdk-utils'; import { KnownLabels, ProjectKind } from '~/k8sTypes'; type MockResourceConfigType = { @@ -6,6 +7,7 @@ type MockResourceConfigType = { description?: string; k8sName?: string; enableModelMesh?: boolean; + isDSProject?: boolean; }; export const mockProjectK8sResource = ({ @@ -14,6 +16,7 @@ export const mockProjectK8sResource = ({ k8sName = 'test-project', enableModelMesh = true, description = '', + isDSProject = true, }: MockResourceConfigType): ProjectKind => ({ kind: 'Project', apiVersion: 'project.openshift.io/v1', @@ -23,7 +26,7 @@ export const mockProjectK8sResource = ({ labels: { 'kubernetes.io/metadata.name': k8sName, [KnownLabels.MODEL_SERVING_PROJECT]: enableModelMesh ? 'true' : 'false', - [KnownLabels.DASHBOARD_RESOURCE]: 'true', + ...(isDSProject && { [KnownLabels.DASHBOARD_RESOURCE]: 'true' }), }, annotations: { 'openshift.io/description': description, @@ -35,3 +38,40 @@ export const mockProjectK8sResource = ({ phase: 'Active', }, }); + +export const mockProjectsK8sList = (): K8sResourceListResult => ({ + apiVersion: 'project.openshift.io/v1', + metadata: { continue: '', resourceVersion: '1462210' }, + items: [ + mockProjectK8sResource({ + k8sName: 'ds-project-1', + displayName: 'DS Project 1', + isDSProject: true, + }), + mockProjectK8sResource({ + k8sName: 'ds-project-2', + displayName: 'DS Project 2', + isDSProject: true, + }), + mockProjectK8sResource({ + k8sName: 'ds-project-3', + displayName: 'DS Project 3', + isDSProject: true, + }), + mockProjectK8sResource({ + k8sName: 'non-ds-project-1', + displayName: 'Non-DS Project 1', + isDSProject: false, + }), + mockProjectK8sResource({ + k8sName: 'non-ds-project-2', + displayName: 'Non-DS Project 2', + isDSProject: false, + }), + mockProjectK8sResource({ + k8sName: 'non-ds-project-3', + displayName: 'Non-DS Project 3', + isDSProject: false, + }), + ], +}); diff --git a/frontend/src/__mocks__/mockServingRuntimeK8sResource.ts b/frontend/src/__mocks__/mockServingRuntimeK8sResource.ts index 7f3581d73a..16ee95cd83 100644 --- a/frontend/src/__mocks__/mockServingRuntimeK8sResource.ts +++ b/frontend/src/__mocks__/mockServingRuntimeK8sResource.ts @@ -49,7 +49,7 @@ export const mockServingRuntimeK8sResourceLegacy = ({ '--rest_bind_address=127.0.0.1', ], image: - 'registry.redhat.io/rhods/odh-openvino-servingruntime-rhel8@sha256:8af20e48bb480a7ba1ee1268a3cf0a507e05b256c5fcf988f8e4a3de8b87edc6', + 'registry.redhat.io/openshift-ai/odh-openvino-servingruntime-rhel8@sha256:8af20e48bb480a7ba1ee1268a3cf0a507e05b256c5fcf988f8e4a3de8b87edc6', name: 'ovms', resources: { limits: { @@ -127,7 +127,7 @@ export const mockServingRuntimeK8sResource = ({ '--rest_bind_address=127.0.0.1', ], image: - 'registry.redhat.io/rhods/odh-openvino-servingruntime-rhel8@sha256:8af20e48bb480a7ba1ee1268a3cf0a507e05b256c5fcf988f8e4a3de8b87edc6', + 'registry.redhat.io/openshift-ai/odh-openvino-servingruntime-rhel8@sha256:8af20e48bb480a7ba1ee1268a3cf0a507e05b256c5fcf988f8e4a3de8b87edc6', name: 'ovms', resources: { limits: { diff --git a/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts b/frontend/src/__tests__/integration/pages/projects/ProjectDetails.spec.ts index a89ca6bf86..bfd39340dc 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/__tests__/integration/pages/projects/ProjectView.spec.ts b/frontend/src/__tests__/integration/pages/projects/ProjectView.spec.ts index 3ded1a4dd2..f0b329024a 100644 --- a/frontend/src/__tests__/integration/pages/projects/ProjectView.spec.ts +++ b/frontend/src/__tests__/integration/pages/projects/ProjectView.spec.ts @@ -1,6 +1,33 @@ import { test, expect } from '@playwright/test'; import { navigateToStory } from '~/__tests__/integration/utils'; +test('Project view page', async ({ page }) => { + await page.goto( + './iframe.html?id=tests-integration-pages-projects-projectview--default&viewMode=story', + ); + + // wait for page to load + await page.waitForSelector('text=Data science projects'); + + // Test that it only shows DS Projects at first + await expect(page.getByText('DS Project 1', { exact: true })).toBeVisible(); + await expect(page.getByText('DS Project 2', { exact: true })).toBeVisible(); + await expect(page.getByText('DS Project 3', { exact: true })).toBeVisible(); + await expect(page.getByText('Non-DS Project 1', { exact: true })).toBeHidden(); + await expect(page.getByText('Non-DS Project 2', { exact: true })).toBeHidden(); + await expect(page.getByText('Non-DS Project 3', { exact: true })).toBeHidden(); + + // Change the selection and make sure it shows all projects + await page.locator('#project-scope-selection').click(); + await page.getByText('All available projects', { exact: true }).click(); + await expect(page.getByText('DS Project 1', { exact: true })).toBeVisible(); + await expect(page.getByText('DS Project 2', { exact: true })).toBeVisible(); + await expect(page.getByText('DS Project 3', { exact: true })).toBeVisible(); + await expect(page.getByText('Non-DS Project 1', { exact: true })).toBeVisible(); + await expect(page.getByText('Non-DS Project 2', { exact: true })).toBeVisible(); + await expect(page.getByText('Non-DS Project 3', { exact: true })).toBeVisible(); +}); + test('Create project', async ({ page }) => { await page.goto(navigateToStory('pages-projects-projectview', 'create-project')); @@ -76,7 +103,7 @@ test('Delete project', async ({ page }) => { // Test that can submit on valid form await expect(page.getByRole('button', { name: 'Delete project' })).toBeDisabled(); - await page.getByRole('textbox', { name: 'Delete modal input' }).fill('Test Project'); + await page.getByRole('textbox', { name: 'Delete modal input' }).fill('DS Project 1'); await expect(page.getByRole('button', { name: 'Delete project' })).toBeEnabled(); // Test if error alert will pop up diff --git a/frontend/src/__tests__/integration/pages/projects/ProjectView.stories.tsx b/frontend/src/__tests__/integration/pages/projects/ProjectView.stories.tsx index 3015663db7..62f6939098 100644 --- a/frontend/src/__tests__/integration/pages/projects/ProjectView.stories.tsx +++ b/frontend/src/__tests__/integration/pages/projects/ProjectView.stories.tsx @@ -1,7 +1,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { rest } from 'msw'; import { within, userEvent } from '@storybook/testing-library'; -import { mockProjectK8sResource } from '~/__mocks__/mockProjectK8sResource'; +import { mockProjectsK8sList } from '~/__mocks__/mockProjectK8sResource'; import { mockNotebookK8sResource } from '~/__mocks__/mockNotebookK8sResource'; import { mockK8sResourceList } from '~/__mocks__/mockK8sResourceList'; import { mockPodK8sResource } from '~/__mocks__/mockPodK8sResource'; @@ -26,7 +26,7 @@ export default { (req, res, ctx) => res(ctx.json(mockK8sResourceList([mockNotebookK8sResource({})]))), ), rest.get('/api/k8s/apis/project.openshift.io/v1/projects', (req, res, ctx) => - res(ctx.json(mockK8sResourceList([mockProjectK8sResource({})]))), + res(ctx.json(mockProjectsK8sList())), ), rest.delete( '/api/k8s/apis/project.openshift.io/v1/projects/test-project', @@ -45,6 +45,14 @@ export default { }, } as Meta; +export const Default: StoryObj = { + play: async ({ canvasElement }) => { + // load page and wait until settled + const canvas = within(canvasElement); + await canvas.findByText('DS Project 1', undefined, { timeout: 5000 }); + }, +}; + export const EditProject: StoryObj = { parameters: { a11y: { @@ -56,10 +64,10 @@ export const EditProject: StoryObj = { play: async ({ canvasElement }) => { // load page and wait until settled const canvas = within(canvasElement); - await canvas.findByText('Test Project', undefined, { timeout: 5000 }); + await canvas.findByText('DS Project 1', undefined, { timeout: 5000 }); // user flow for editing a project - await userEvent.click(canvas.getByLabelText('Actions', { selector: 'button' })); + await userEvent.click(canvas.getAllByLabelText('Actions', { selector: 'button' })[0]); await userEvent.click(canvas.getByText('Edit project', { selector: 'button' })); }, }; @@ -74,10 +82,10 @@ export const DeleteProject: StoryObj = { play: async ({ canvasElement }) => { // load page and wait until settled const canvas = within(canvasElement); - await canvas.findByText('Test Project', undefined, { timeout: 5000 }); + await canvas.findByText('DS Project 1', undefined, { timeout: 5000 }); // user flow for deleting a project - await userEvent.click(canvas.getByLabelText('Actions', { selector: 'button' })); + await userEvent.click(canvas.getAllByLabelText('Actions', { selector: 'button' })[0]); await userEvent.click(canvas.getByText('Delete project', { selector: 'button' })); }, }; @@ -92,7 +100,7 @@ export const CreateProject: StoryObj = { play: async ({ canvasElement }) => { // load page and wait until settled const canvas = within(canvasElement); - await canvas.findByText('Test Project', undefined, { timeout: 5000 }); + await canvas.findByText('DS Project 1', undefined, { timeout: 5000 }); // user flow for deleting a project await userEvent.click(canvas.getByText('Create data science project', { selector: 'button' })); 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..ff47277c05 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'); @@ -372,6 +382,7 @@ export const attachNotebookPVC = ( namespace: string, pvcName: string, mountSuffix: string, + opts?: K8sAPIOptions, ): Promise => { const patches: Patch[] = [ { @@ -387,17 +398,20 @@ export const attachNotebookPVC = ( }, ]; - return k8sPatchResource({ - model: NotebookModel, - queryOptions: { name: notebookName, ns: namespace }, - patches, - }); + return k8sPatchResource( + applyK8sAPIOptions(opts, { + model: NotebookModel, + queryOptions: { name: notebookName, ns: namespace }, + patches, + }), + ); }; export const removeNotebookPVC = ( notebookName: string, namespace: string, pvcName: string, + opts?: K8sAPIOptions, ): Promise => new Promise((resolve, reject) => { getNotebook(notebookName, namespace) @@ -426,11 +440,13 @@ export const removeNotebookPVC = ( }, ]; - k8sPatchResource({ - model: NotebookModel, - queryOptions: { name: notebookName, ns: namespace }, - patches, - }) + k8sPatchResource( + applyK8sAPIOptions(opts, { + model: NotebookModel, + queryOptions: { name: notebookName, ns: namespace }, + patches, + }), + ) .then(resolve) .catch(reject); }) diff --git a/frontend/src/api/k8s/projects.ts b/frontend/src/api/k8s/projects.ts index 02224aa101..6426c79578 100644 --- a/frontend/src/api/k8s/projects.ts +++ b/frontend/src/api/k8s/projects.ts @@ -27,9 +27,6 @@ export const getProjects = (withLabel?: string): Promise => queryOptions: withLabel ? { queryParams: { labelSelector: withLabel } } : undefined, }).then((listResource) => listResource.items); -export const getDSGProjects = (): Promise => - getProjects(LABEL_SELECTOR_DASHBOARD_RESOURCE); - export const createProject = ( username: string, displayName: string, diff --git a/frontend/src/api/k8s/pvcs.ts b/frontend/src/api/k8s/pvcs.ts index 9fe9a16f96..62614c0f9f 100644 --- a/frontend/src/api/k8s/pvcs.ts +++ b/frontend/src/api/k8s/pvcs.ts @@ -1,47 +1,59 @@ +import * as _ from 'lodash'; import { k8sCreateResource, k8sDeleteResource, k8sGetResource, k8sListResourceItems, k8sPatchResource, + k8sUpdateResource, } from '@openshift/dynamic-plugin-sdk-utils'; -import { K8sStatus, KnownLabels, PersistentVolumeClaimKind } from '~/k8sTypes'; +import { K8sAPIOptions, K8sStatus, KnownLabels, PersistentVolumeClaimKind } from '~/k8sTypes'; import { PVCModel } from '~/api/models'; import { translateDisplayNameForK8s } from '~/pages/projects/utils'; import { LABEL_SELECTOR_DASHBOARD_RESOURCE } from '~/const'; +import { applyK8sAPIOptions } from '~/api/apiMergeUtils'; +import { CreatingStorageObject } from '~/pages/projects/types'; export const assemblePvc = ( - pvcName: string, - projectName: string, - description: string, - pvcSize: number, -): PersistentVolumeClaimKind => ({ - apiVersion: 'v1', - kind: 'PersistentVolumeClaim', - metadata: { - name: translateDisplayNameForK8s(pvcName), - namespace: projectName, - labels: { - [KnownLabels.DASHBOARD_RESOURCE]: 'true', - }, - annotations: { - 'openshift.io/display-name': pvcName.trim(), - 'openshift.io/description': description, + data: CreatingStorageObject, + namespace: string, + editName?: string, +): PersistentVolumeClaimKind => { + const { + nameDesc: { name: pvcName, description }, + size, + } = data; + + const name = editName || translateDisplayNameForK8s(pvcName); + + return { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { + name, + namespace, + labels: { + [KnownLabels.DASHBOARD_RESOURCE]: 'true', + }, + annotations: { + 'openshift.io/display-name': pvcName.trim(), + 'openshift.io/description': description, + }, }, - }, - spec: { - accessModes: ['ReadWriteOnce'], - resources: { - requests: { - storage: `${pvcSize}Gi`, + spec: { + accessModes: ['ReadWriteOnce'], + resources: { + requests: { + storage: size, + }, }, + volumeMode: 'Filesystem', + }, + status: { + phase: 'Pending', }, - volumeMode: 'Filesystem', - }, - status: { - phase: 'Pending', - }, -}); + }; +}; export const getPvc = (projectName: string, pvcName: string): Promise => k8sGetResource({ @@ -68,42 +80,30 @@ export const getAvailableMultiUsePvcs = ( }), ); -export const createPvc = (data: PersistentVolumeClaimKind): Promise => - k8sCreateResource({ model: PVCModel, resource: data }); - -export const updatePvcDisplayName = ( - pvcName: string, +export const createPvc = ( + data: CreatingStorageObject, namespace: string, - displayName: string, -): Promise => - k8sPatchResource({ - model: PVCModel, - queryOptions: { name: pvcName, ns: namespace }, - patches: [ - { - op: 'replace', - path: '/metadata/annotations/openshift.io~1display-name', - value: displayName, - }, - ], - }); + opts?: K8sAPIOptions, +): Promise => { + const pvc = assemblePvc(data, namespace); -export const updatePvcDescription = ( - pvcName: string, + return k8sCreateResource( + applyK8sAPIOptions(opts, { model: PVCModel, resource: pvc }), + ); +}; + +export const updatePvc = ( + data: CreatingStorageObject, + existingData: PersistentVolumeClaimKind, namespace: string, - description: string, -): Promise => - k8sPatchResource({ - model: PVCModel, - queryOptions: { name: pvcName, ns: namespace }, - patches: [ - { - op: 'replace', - path: '/metadata/annotations/openshift.io~1description', - value: description, - }, - ], - }); + opts?: K8sAPIOptions, +): Promise => { + const pvc = assemblePvc(data, namespace, existingData.metadata.name); + + return k8sUpdateResource( + applyK8sAPIOptions(opts, { model: PVCModel, resource: _.merge({}, existingData, pvc) }), + ); +}; export const deletePvc = (pvcName: string, namespace: string): Promise => k8sDeleteResource({ @@ -115,17 +115,20 @@ export const updatePvcSize = ( pvcName: string, namespace: string, size: string, + opts?: K8sAPIOptions, ): Promise => - k8sPatchResource({ - model: PVCModel, - queryOptions: { name: pvcName, ns: namespace }, - patches: [ - { - op: 'replace', - path: '/spec/resources/requests', - value: { - storage: size, + k8sPatchResource( + applyK8sAPIOptions(opts, { + model: PVCModel, + queryOptions: { name: pvcName, ns: namespace }, + patches: [ + { + op: 'replace', + path: '/spec/resources/requests', + value: { + storage: size, + }, }, - }, - ], - }); + ], + }), + ); diff --git a/frontend/src/app/App.scss b/frontend/src/app/App.scss index 5167531596..a0cdb25bfc 100644 --- a/frontend/src/app/App.scss +++ b/frontend/src/app/App.scss @@ -1,4 +1,6 @@ -html, body, #root { +html, +body, +#root { height: 100%; } @@ -14,19 +16,25 @@ html, body, #root { flex: 1; margin: var(--pf-global--spacer--md); } + .pf-c-app-launcher__group-title { font-size: 13px; } + .pf-c-app-launcher__menu-item-external-icon { opacity: 1; color: var(--pf-global--icon--Color--light); } + &__brand { height: 36px; } } + // specificity targeting form elements to override --pf-global--FontSize--md -.pf-c-page, .pf-c-modal-box { +.pf-c-page, +.pf-c-modal-box { + .pf-c-app-launcher, .pf-c-button, .pf-c-dropdown, @@ -41,3 +49,13 @@ html, body, #root { height: auto; } } + +// temp fix until https://github.com/patternfly/patternfly-react/issues/8028 is resolved +// Remove this file and its uses after bumping to PF5 +.checkbox-radio-fix-body-width { + + .pf-c-check__body, + .pf-c-radio__body { + width: 100%; + } +} \ No newline at end of file diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 2d8e49dfdf..7650a348b5 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -18,6 +18,7 @@ import { useUser } from '~/redux/selectors'; import { DASHBOARD_MAIN_CONTAINER_ID } from '~/utilities/const'; import useDetectUser from '~/utilities/useDetectUser'; import ProjectsContextProvider from '~/concepts/projects/ProjectsContext'; +import AreaContextProvider from '~/concepts/areas/AreaContext'; import Header from './Header'; import AppRoutes from './AppRoutes'; import NavSidebar from './NavSidebar'; @@ -89,25 +90,27 @@ const App: React.FC = () => { dashboardConfig, }} > - setNotificationsOpen(!notificationsOpen)} />} - sidebar={isAllowed ? : undefined} - notificationDrawer={ setNotificationsOpen(false)} />} - isNotificationDrawerExpanded={notificationsOpen} - mainContainerId={DASHBOARD_MAIN_CONTAINER_ID} - > - - - - - - - - - - + + setNotificationsOpen(!notificationsOpen)} />} + sidebar={isAllowed ? : undefined} + notificationDrawer={ setNotificationsOpen(false)} />} + isNotificationDrawerExpanded={notificationsOpen} + mainContainerId={DASHBOARD_MAIN_CONTAINER_ID} + > + + + + + + + + + + + ); }; diff --git a/frontend/src/app/AppContext.ts b/frontend/src/app/AppContext.ts index f3fddb7cc4..b3409ef535 100644 --- a/frontend/src/app/AppContext.ts +++ b/frontend/src/app/AppContext.ts @@ -1,15 +1,16 @@ import * as React from 'react'; -import { BuildStatus, DashboardConfig } from '~/types'; +import { DashboardConfigKind } from '~/k8sTypes'; +import { BuildStatus } from '~/types'; type AppContextProps = { buildStatuses: BuildStatus[]; - dashboardConfig: DashboardConfig; + dashboardConfig: DashboardConfigKind; }; const defaultAppContext: AppContextProps = { buildStatuses: [], // At runtime dashboardConfig is never null -- DO NOT DO THIS usually - dashboardConfig: null as unknown as DashboardConfig, + dashboardConfig: null as unknown as DashboardConfigKind, }; export const AppContext = React.createContext(defaultAppContext); diff --git a/frontend/src/app/NavSidebar.tsx b/frontend/src/app/NavSidebar.tsx index c2b9d4b189..e3e217b335 100644 --- a/frontend/src/app/NavSidebar.tsx +++ b/frontend/src/app/NavSidebar.tsx @@ -1,9 +1,7 @@ import React from 'react'; import { Link, useLocation } from 'react-router-dom'; import { Nav, NavExpandable, NavItem, NavList, PageSidebar } from '@patternfly/react-core'; -import { getNavBarData, isNavDataGroup, NavDataGroup, NavDataHref } from '~/utilities/NavData'; -import { useUser } from '~/redux/selectors'; -import { useAppContext } from './AppContext'; +import { isNavDataGroup, NavDataGroup, NavDataHref, useBuildNavData } from '~/utilities/NavData'; const checkLinkActiveStatus = (pathname: string, href: string) => href.split('/')[1] === pathname.split('/')[1]; @@ -45,24 +43,27 @@ const NavGroup: React.FC<{ item: NavDataGroup; pathname: string }> = ({ item, pa }; const NavSidebar: React.FC = () => { - const { dashboardConfig } = useAppContext(); const routerLocation = useLocation(); - const { isAdmin } = useUser(); - const userNavData = getNavBarData(isAdmin, dashboardConfig); - const nav = ( - + const userNavData = useBuildNavData(); + + return ( + + + {userNavData.map((item) => + isNavDataGroup(item) ? ( + + ) : ( + + ), + )} + + + } + theme="dark" + /> ); - return ; }; export default NavSidebar; diff --git a/frontend/src/app/useApplicationSettings.tsx b/frontend/src/app/useApplicationSettings.tsx index d0189aba1d..a91773688b 100644 --- a/frontend/src/app/useApplicationSettings.tsx +++ b/frontend/src/app/useApplicationSettings.tsx @@ -1,18 +1,18 @@ import * as React from 'react'; -import { DashboardConfig } from '~/types'; +import { DashboardConfigKind } from '~/k8sTypes'; import { POLL_INTERVAL } from '~/utilities/const'; import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; import { fetchDashboardConfig } from '~/services/dashboardConfigService'; import useTimeBasedRefresh from './useTimeBasedRefresh'; export const useApplicationSettings = (): { - dashboardConfig: DashboardConfig | null; + dashboardConfig: DashboardConfigKind | null; loaded: boolean; loadError: Error | undefined; } => { const [loaded, setLoaded] = React.useState(false); const [loadError, setLoadError] = React.useState(); - const [dashboardConfig, setDashboardConfig] = React.useState(null); + const [dashboardConfig, setDashboardConfig] = React.useState(null); const setRefreshMarker = useTimeBasedRefresh(); React.useEffect(() => { @@ -55,7 +55,7 @@ export const useApplicationSettings = (): { }; }, [setRefreshMarker]); - const retConfig = useDeepCompareMemoize(dashboardConfig); + const retConfig = useDeepCompareMemoize(dashboardConfig); return { dashboardConfig: retConfig, loaded, loadError }; }; diff --git a/frontend/src/components/OdhExploreCardTypeBadge.tsx b/frontend/src/components/OdhExploreCardTypeBadge.tsx index a9448b2151..861c0501cf 100644 --- a/frontend/src/components/OdhExploreCardTypeBadge.tsx +++ b/frontend/src/components/OdhExploreCardTypeBadge.tsx @@ -15,7 +15,7 @@ const OdhExploreCardTypeBadge: React.FC = ({ categ content = 'Partner managed software is hosted on the ISV’s cloud service'; } else if (category === OdhApplicationCategory.SelfManaged) { content = - 'Self-managed software is installed to a RHODS cluster, but does not support upgrade testing, alerting, or other features of externally managed software'; + 'Self-managed software is installed to a Red Hat OpenShift AI cluster, but does not support upgrade testing, alerting, or other features of externally managed software'; } if (!content) { diff --git a/frontend/src/components/ValueUnitField.tsx b/frontend/src/components/ValueUnitField.tsx index f4de70994e..0a639fe7a6 100644 --- a/frontend/src/components/ValueUnitField.tsx +++ b/frontend/src/components/ValueUnitField.tsx @@ -37,6 +37,7 @@ const ValueUnitField: React.FC = ({ setOpen(!open)}> {currentUnitOption.name} diff --git a/frontend/src/concepts/areas/AreaComponent.tsx b/frontend/src/concepts/areas/AreaComponent.tsx new file mode 100644 index 0000000000..cd0ffa8805 --- /dev/null +++ b/frontend/src/concepts/areas/AreaComponent.tsx @@ -0,0 +1,60 @@ +import * as React from 'react'; +import NotFound from '~/pages/NotFound'; +import { SupportedArea } from './types'; +import useIsAreaAvailable from './useIsAreaAvailable'; + +type AreaComponentProps = { + /** What area do you need to be active to show the `children` */ + area: SupportedArea; + /** Lazy rendered children, keeps from executing the content until we know it's available */ + children: () => React.ReactNode; + /** Optionally, if the children are the whole page context, render a 404 page */ + isFullPage?: boolean; +}; + +/** + * Allows for you to wrap an area in the middle of your JSX. + */ +const AreaComponent: React.FC = ({ area, children, isFullPage }) => { + const isAvailable = useIsAreaAvailable(area).status; + + if (!children || typeof children !== 'function') { + // Typescript needs a gate to stop "what if it is null" + // TODO: This should be fixed when we enforce strict mode in TS + return null; + } + + if (isAvailable) { + return <>{children()}; + } + + return isFullPage ? : null; +}; + +/** + * Allows you to lock down a component at the definition level. + * + * Use-case: This component is only for feature X, if area X is not enabled, we don't want this to + * render. + * + * Example usage: + * ``` + * const MyAreaComponent = conditionalArea(SupportedArea.YOUR_AREA)((props) => ) + * ``` + * Notes: + * - Wrap the function definition + * - Don't use React.FC, it is handled internally + */ +export const conditionalArea = + (area: SupportedArea, isFullPage?: boolean) => + (Component: React.FC) => { + const ConditionalArea = (props: Props) => ( + + {() => } + + ); + + return ConditionalArea; + }; + +export default AreaComponent; diff --git a/frontend/src/concepts/areas/AreaContext.tsx b/frontend/src/concepts/areas/AreaContext.tsx new file mode 100644 index 0000000000..9e88a4bb9c --- /dev/null +++ b/frontend/src/concepts/areas/AreaContext.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { Alert, Bullseye, Spinner } from '@patternfly/react-core'; +import useFetchDscStatus from '~/concepts/areas/useFetchDscStatus'; +import { DataScienceClusterKindStatus } from '~/k8sTypes'; + +type AreaContextState = { + /** + * If value is `null`: + * Using the v1 Operator, no status to pull + * TODO: Remove when we no longer want to support v1 + */ + dscStatus: DataScienceClusterKindStatus | null; +}; + +export const AreaContext = React.createContext({ + dscStatus: null, +}); + +type AreaContextProps = { + children: React.ReactNode; +}; + +const AreaContextProvider: React.FC = ({ children }) => { + const [dscStatus, loaded, error] = useFetchDscStatus(); + + if (error) { + return ( + + {error.message} + + ); + } + + if (!loaded) { + return ( + + + + ); + } + + return {children}; +}; + +export default AreaContextProvider; diff --git a/frontend/src/concepts/areas/__tests__/const.spec.ts b/frontend/src/concepts/areas/__tests__/const.spec.ts new file mode 100644 index 0000000000..e334961048 --- /dev/null +++ b/frontend/src/concepts/areas/__tests__/const.spec.ts @@ -0,0 +1,54 @@ +import { SupportedAreasStateMap } from '~/concepts/areas/const'; +import { SupportedArea } from '~/concepts/areas'; + +describe('Verify const stability', () => { + const computeTestFunc = (map: Partial) => { + const hasSuccessfulReliantAreaInternal = ( + key: SupportedArea, + passedArea: SupportedArea[] = [], + ): boolean => { + const state = map[key]; + + if (state?.reliantAreas) { + const updatedPassedArea = [...passedArea, key]; + return state.reliantAreas.every((v) => + updatedPassedArea.includes(v) + ? false + : hasSuccessfulReliantAreaInternal(v, updatedPassedArea), + ); + } + + return true; + }; + return hasSuccessfulReliantAreaInternal; + }; + const hasSuccessfulReliantArea = computeTestFunc(SupportedAreasStateMap); + + it('utility should fail on reliant areas', () => { + const state: Partial = { + [SupportedArea.DS_PROJECTS_VIEW]: { + featureFlags: [], + reliantAreas: [SupportedArea.DS_PROJECTS_PERMISSIONS], + }, + [SupportedArea.DS_PROJECTS_PERMISSIONS]: { + featureFlags: [], + reliantAreas: [SupportedArea.DS_PROJECTS_VIEW], + }, + }; + const hasSuccessfulReliantAreaForTest = computeTestFunc(state); + + expect(hasSuccessfulReliantAreaForTest(SupportedArea.DS_PROJECTS_PERMISSIONS)).toBe(false); + }); + + it('should not have circular reliant areas', () => { + const list = Object.keys(SupportedAreasStateMap); + list.forEach( + (v) => + hasSuccessfulReliantArea(v as SupportedArea) || + expect(`SupportedArea => ${v} has a circle reference in reliantAreas`).toBe( + 'No issues in SupportedAreasStateMap', + ), + ); + expect(list.length > 0).toBe(true); + }); +}); diff --git a/frontend/src/concepts/areas/__tests__/utils.spec.ts b/frontend/src/concepts/areas/__tests__/utils.spec.ts new file mode 100644 index 0000000000..70af261f3a --- /dev/null +++ b/frontend/src/concepts/areas/__tests__/utils.spec.ts @@ -0,0 +1,201 @@ +import { isAreaAvailable, SupportedArea } from '~/concepts/areas'; +import { mockDashboardConfig } from '~/__mocks__/mockDashboardConfig'; +import { mockDscStatus } from '~/__mocks__/mockDscStatus'; +import { StackComponent } from '~/concepts/areas/types'; +import { SupportedAreasStateMap } from '~/concepts/areas/const'; + +describe('isAreaAvailable', () => { + describe('v1 Operator (deprecated)', () => { + it('should enable component (flag true)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.DS_PIPELINES, + mockDashboardConfig({ disablePipelines: false }).spec, + null, + ); + + expect(isAvailable.status).toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disablePipelines']: 'on' }); + expect(isAvailable.reliantAreas).toBe(null); + expect(isAvailable.requiredComponents).toBe(null); + }); + + it('should disable component (flag false)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.DS_PIPELINES, + mockDashboardConfig({ disablePipelines: true }).spec, + null, + ); + + expect(isAvailable.status).not.toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disablePipelines']: 'off' }); + expect(isAvailable.reliantAreas).toBe(null); + expect(isAvailable.requiredComponents).toBe(null); + }); + + it('should enable area when not a feature flag component', () => { + const isAvailable = isAreaAvailable( + SupportedArea.WORKBENCHES, + mockDashboardConfig({}).spec, + null, + ); + + expect(isAvailable.status).toBe(true); + expect(isAvailable.featureFlags).toBe(null); + expect(isAvailable.reliantAreas).toEqual({ [SupportedArea.DS_PROJECTS_VIEW]: true }); + expect(isAvailable.requiredComponents).toBe(null); + }); + }); + + describe('v2 Operator', () => { + describe('flags and cluster states', () => { + it('should enable area (flag true, cluster true)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.DS_PIPELINES, + mockDashboardConfig({ disablePipelines: false }).spec, + mockDscStatus({ installedComponents: { [StackComponent.DS_PIPELINES]: true } }), + ); + + expect(isAvailable.status).toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disablePipelines']: 'on' }); + expect(isAvailable.reliantAreas).toBe(null); + expect(isAvailable.requiredComponents).toEqual({ [StackComponent.DS_PIPELINES]: true }); + }); + + it('should disable area (flag true, cluster false)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.DS_PIPELINES, + mockDashboardConfig({ disablePipelines: false }).spec, + mockDscStatus({ installedComponents: { [StackComponent.DS_PIPELINES]: false } }), + ); + + expect(isAvailable.status).not.toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disablePipelines']: 'on' }); + expect(isAvailable.reliantAreas).toBe(null); + expect(isAvailable.requiredComponents).toEqual({ [StackComponent.DS_PIPELINES]: false }); + }); + + it('should disable area (flag false, cluster true)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.DS_PIPELINES, + mockDashboardConfig({ disablePipelines: true }).spec, + mockDscStatus({ installedComponents: { [StackComponent.DS_PIPELINES]: true } }), + ); + + expect(isAvailable.status).not.toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disablePipelines']: 'off' }); + expect(isAvailable.reliantAreas).toBe(null); + expect(isAvailable.requiredComponents).toEqual({ [StackComponent.DS_PIPELINES]: true }); + }); + + it('should disable area (flag false, cluster false)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.DS_PIPELINES, + mockDashboardConfig({ disablePipelines: true }).spec, + mockDscStatus({ installedComponents: { [StackComponent.DS_PIPELINES]: false } }), + ); + + expect(isAvailable.status).not.toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disablePipelines']: 'off' }); + expect(isAvailable.reliantAreas).toBe(null); + expect(isAvailable.requiredComponents).toEqual({ [StackComponent.DS_PIPELINES]: false }); + }); + + it('should enable area (no flag, cluster true)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.WORKBENCHES, + mockDashboardConfig({}).spec, + mockDscStatus({ installedComponents: { [StackComponent.WORKBENCHES]: true } }), + ); + + expect(isAvailable.status).toBe(true); + expect(isAvailable.featureFlags).toBe(null); + expect(isAvailable.reliantAreas).toEqual({ [SupportedArea.DS_PROJECTS_VIEW]: true }); + expect(isAvailable.requiredComponents).toEqual({ [StackComponent.WORKBENCHES]: true }); + }); + + it('should disable area (no flag, cluster false)', () => { + const isAvailable = isAreaAvailable( + SupportedArea.WORKBENCHES, + mockDashboardConfig({}).spec, + mockDscStatus({ installedComponents: { [StackComponent.WORKBENCHES]: false } }), + ); + + expect(isAvailable.status).not.toBe(true); + expect(isAvailable.featureFlags).toBe(null); + expect(isAvailable.reliantAreas).toEqual({ [SupportedArea.DS_PROJECTS_VIEW]: true }); + expect(isAvailable.requiredComponents).toEqual({ [StackComponent.WORKBENCHES]: false }); + }); + }); + + /** + * These tests rely on Model Serving being in a specific configuration, we may need to replace + * these tests if these become obsolete. + */ + describe('reliantAreas', () => { + it('should enable area if at least one reliant area is enabled', () => { + // Make sure this test is valid + expect(SupportedAreasStateMap[SupportedArea.MODEL_SERVING].reliantAreas).toEqual([ + SupportedArea.K_SERVE, + SupportedArea.MODEL_MESH, + ]); + + // Test both reliant areas + const isAvailableReliantModelMesh = isAreaAvailable( + SupportedArea.MODEL_SERVING, + mockDashboardConfig({ disableModelServing: false }).spec, + mockDscStatus({ installedComponents: { [StackComponent.MODEL_MESH]: true } }), + ); + + expect(isAvailableReliantModelMesh.status).toBe(true); + expect(isAvailableReliantModelMesh.featureFlags).toEqual({ ['disableModelServing']: 'on' }); + expect(isAvailableReliantModelMesh.reliantAreas).toEqual({ + [SupportedArea.K_SERVE]: false, + [SupportedArea.MODEL_MESH]: true, + }); + expect(isAvailableReliantModelMesh.requiredComponents).toBe(null); + + const isAvailableReliantKServe = isAreaAvailable( + SupportedArea.MODEL_SERVING, + mockDashboardConfig({ disableModelServing: false }).spec, + mockDscStatus({ installedComponents: { [StackComponent.K_SERVE]: true } }), + ); + + expect(isAvailableReliantKServe.status).toBe(true); + expect(isAvailableReliantKServe.featureFlags).toEqual({ ['disableModelServing']: 'on' }); + expect(isAvailableReliantKServe.reliantAreas).toEqual({ + [SupportedArea.K_SERVE]: true, + [SupportedArea.MODEL_MESH]: false, + }); + expect(isAvailableReliantKServe.requiredComponents).toBe(null); + }); + + it('should disable area if reliant areas are all disabled', () => { + // Make sure this test is valid + expect(SupportedAreasStateMap[SupportedArea.MODEL_SERVING].reliantAreas).toEqual([ + SupportedArea.K_SERVE, + SupportedArea.MODEL_MESH, + ]); + + // Test both areas disabled + const isAvailable = isAreaAvailable( + SupportedArea.MODEL_SERVING, + mockDashboardConfig({ disableModelServing: false }).spec, + mockDscStatus({ + installedComponents: { + [StackComponent.K_SERVE]: false, + [StackComponent.MODEL_MESH]: false, + }, + }), + ); + + expect(isAvailable.status).not.toBe(true); + expect(isAvailable.featureFlags).toEqual({ ['disableModelServing']: 'on' }); + expect(isAvailable.reliantAreas).toEqual({ + [SupportedArea.K_SERVE]: false, + [SupportedArea.MODEL_MESH]: false, + }); + expect(isAvailable.requiredComponents).toBe(null); + }); + }); + }); +}); diff --git a/frontend/src/concepts/areas/const.ts b/frontend/src/concepts/areas/const.ts new file mode 100644 index 0000000000..31c9b2b30b --- /dev/null +++ b/frontend/src/concepts/areas/const.ts @@ -0,0 +1,45 @@ +import { StackComponent, SupportedArea, SupportedAreasState } from './types'; + +export const SupportedAreasStateMap: SupportedAreasState = { + [SupportedArea.BYON]: { + featureFlags: ['disableBYONImageStream'], + }, + [SupportedArea.CLUSTER_SETTINGS]: { + featureFlags: ['disableClusterManager'], + }, + [SupportedArea.CUSTOM_RUNTIMES]: { + featureFlags: ['disableCustomServingRuntimes'], + reliantAreas: [SupportedArea.MODEL_SERVING], + }, + [SupportedArea.DS_PIPELINES]: { + featureFlags: ['disablePipelines'], + requiredComponents: [StackComponent.DS_PIPELINES], + }, + [SupportedArea.DS_PROJECTS_VIEW]: { + featureFlags: ['disableProjects'], + }, + [SupportedArea.DS_PROJECTS_PERMISSIONS]: { + featureFlags: ['disableProjectSharing'], + reliantAreas: [SupportedArea.DS_PROJECTS_VIEW], + }, + [SupportedArea.K_SERVE]: { + //featureFlags: ['disableKServe'], // TODO: validate KServe feature flag + requiredComponents: [StackComponent.K_SERVE], + }, + [SupportedArea.MODEL_MESH]: { + //featureFlags: ['disableModelMesh'], // TODO: validate ModelMesh feature flag + requiredComponents: [StackComponent.MODEL_MESH], + }, + [SupportedArea.MODEL_SERVING]: { + featureFlags: ['disableModelServing'], + reliantAreas: [SupportedArea.K_SERVE, SupportedArea.MODEL_MESH], + }, + [SupportedArea.USER_MANAGEMENT]: { + featureFlags: ['disableUserManagement'], + }, + [SupportedArea.WORKBENCHES]: { + // featureFlags: [], // TODO: We want to disable, no flag exists today + requiredComponents: [StackComponent.WORKBENCHES], + reliantAreas: [SupportedArea.DS_PROJECTS_VIEW], + }, +}; diff --git a/frontend/src/concepts/areas/index.ts b/frontend/src/concepts/areas/index.ts new file mode 100644 index 0000000000..d4ab163880 --- /dev/null +++ b/frontend/src/concepts/areas/index.ts @@ -0,0 +1,11 @@ +/* + This section of concepts is intended to be for all things understanding our state with respect to + areas of the application and the stack components that may relate. + + This area should not bloat to support specifics of any area, this is just helpers and ways to + determine the state we are in. +*/ +export { default as AreaComponent, conditionalArea } from './AreaComponent'; +export { SupportedArea } from './types'; +export { default as useIsAreaAvailable } from './useIsAreaAvailable'; +export { isAreaAvailable } from './utils'; diff --git a/frontend/src/concepts/areas/types.ts b/frontend/src/concepts/areas/types.ts new file mode 100644 index 0000000000..72db26a798 --- /dev/null +++ b/frontend/src/concepts/areas/types.ts @@ -0,0 +1,91 @@ +import { EitherOrBoth } from '~/typeHelpers'; +import { DashboardCommonConfig } from '~/k8sTypes'; + +// TODO: clean up this definition / update the DashboardConfig to a better state +export type FeatureFlag = keyof Omit; + +export type IsAreaAvailableStatus = { + /** A single boolean status */ + status: boolean; + /* Each status portion broken down -- null if no check made */ + featureFlags: { [key in FeatureFlag]?: 'on' | 'off' } | null; // simplified. `disableX` flags are weird to read + reliantAreas: { [key in SupportedArea]?: boolean } | null; // only needs 1 to be true + requiredComponents: { [key in StackComponent]?: boolean } | null; +}; + +/** All areas that we need to support in some fashion or another */ +export enum SupportedArea { + /* Standalone areas */ + DS_PIPELINES = 'ds-pipelines', + // TODO: Jupyter Tile Support? (outside of feature flags today) + WORKBENCHES = 'workbenches', + // TODO: Support Applications/Tile area + // TODO: Support resources area + + /* Admin areas */ + BYON = 'bring-your-own-notebook', + CLUSTER_SETTINGS = 'cluster-settings', + USER_MANAGEMENT = 'user-management', + + /* DS Projects specific areas */ + DS_PROJECTS_PERMISSIONS = 'ds-projects-permission', + DS_PROJECTS_VIEW = 'ds-projects', + + /* Model Serving areas */ + MODEL_SERVING = 'model-serving-shell', + CUSTOM_RUNTIMES = 'custom-serving-runtimes', + K_SERVE = 'kserve', + MODEL_MESH = 'model-mesh', +} + +/** Components deployed by the Operator. Part of the DSC Status. */ +export enum StackComponent { + CODE_FLARE = 'codeflare', + DS_PIPELINES = 'data-science-pipelines-operator', + K_SERVE = 'kserve', + MODEL_MESH = 'model-mesh', + // Bug: https://github.com/opendatahub-io/opendatahub-operator/issues/641 + DASHBOARD = 'odh-dashboard', + RAY = 'ray', + WORKBENCHES = 'workbenches', +} + +// TODO: Support extra operators, like the pipelines operator -- maybe as a "external dependency need?" +type SupportedComponentFlagValue = { + /** + * An area can be reliant on another area being enabled. The list is "OR"-ed together. + * + * Example, Model Serving is a shell for either KServe or ModelMesh. It has no value on its own. + * It can also be a chain of reliance... example, Custom Runtimes is a Model Serving feature. + * + * TODO: support AND -- maybe double array? + */ + reliantAreas?: SupportedArea[]; +} & EitherOrBoth< + { + /** + * Refers to OdhDashboardConfig's feature flags, any number of them to be "enabled", the result + * is AND-ed. Omit to not be related to any feature flag. + * + * Note: "disable" methodology is confusing and needs to be removed + * Note: "Enabled" will mean "disable" is false + * @see https://github.com/opendatahub-io/odh-dashboard/issues/1108 + */ + featureFlags: FeatureFlag[]; + }, + { + /** + * Refers to the related stack component names. If a backend component is not installed, this + * can prevent the feature flag from enabling the item. Omit to not be reliant on a backend + * component. + */ + requiredComponents: StackComponent[]; + } +>; + +/** + * Relationships between areas and the state of the cluster. + */ +export type SupportedAreasState = { + [key in SupportedArea]: SupportedComponentFlagValue; +}; diff --git a/frontend/src/concepts/areas/useFetchDscStatus.ts b/frontend/src/concepts/areas/useFetchDscStatus.ts new file mode 100644 index 0000000000..034bbca0b3 --- /dev/null +++ b/frontend/src/concepts/areas/useFetchDscStatus.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; +import useFetchState from '~/utilities/useFetchState'; +import { DataScienceClusterKindStatus } from '~/k8sTypes'; + +/** + * Should only return `null` when on v1 Operator. + */ +const fetchDscStatus = (): Promise => { + const url = '/api/dsc/status'; + return axios + .get(url) + .then((response) => response.data) + .catch((e) => { + if (e.response.status === 404) { + // DSC is not available, assume v1 Operator + return null; + } + throw new Error(e.response.data.message); + }); +}; + +const useFetchDscStatus = () => useFetchState(fetchDscStatus, null); + +export default useFetchDscStatus; diff --git a/frontend/src/concepts/areas/useIsAreaAvailable.ts b/frontend/src/concepts/areas/useIsAreaAvailable.ts new file mode 100644 index 0000000000..4697b1909a --- /dev/null +++ b/frontend/src/concepts/areas/useIsAreaAvailable.ts @@ -0,0 +1,21 @@ +import * as React from 'react'; +import { useAppContext } from '~/app/AppContext'; +import { AreaContext } from '~/concepts/areas/AreaContext'; +import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; +import { IsAreaAvailableStatus, SupportedArea } from './types'; +import { isAreaAvailable } from './utils'; + +const useIsAreaAvailable = (area: SupportedArea): IsAreaAvailableStatus => { + const { dashboardConfig } = useAppContext(); + const { dscStatus } = React.useContext(AreaContext); + + const dashboardConfigSpecSafe = useDeepCompareMemoize(dashboardConfig.spec); + const dscStatusSafe = useDeepCompareMemoize(dscStatus); + + return React.useMemo( + () => isAreaAvailable(area, dashboardConfigSpecSafe, dscStatusSafe), + [area, dashboardConfigSpecSafe, dscStatusSafe], + ); +}; + +export default useIsAreaAvailable; diff --git a/frontend/src/concepts/areas/utils.ts b/frontend/src/concepts/areas/utils.ts new file mode 100644 index 0000000000..79c78611e4 --- /dev/null +++ b/frontend/src/concepts/areas/utils.ts @@ -0,0 +1,74 @@ +import { DashboardConfigKind, DataScienceClusterKindStatus } from '~/k8sTypes'; +import { IsAreaAvailableStatus, FeatureFlag, SupportedArea } from './types'; +import { SupportedAreasStateMap } from './const'; + +type FlagState = { [flag in FeatureFlag]?: boolean }; +const getFlags = (dashboardConfigSpec: DashboardConfigKind['spec']): FlagState => { + const flags = dashboardConfigSpec.dashboardConfig; + + // TODO: Improve to be a list of items + const isFeatureFlag = (key: string, value: unknown): key is FeatureFlag => + typeof value === 'boolean'; + + return { + ...Object.keys(flags).reduce((flagState, key) => { + const value = flags[key as FeatureFlag]; + if (isFeatureFlag(key, value)) { + flagState[key] = key.startsWith('disable') ? !value : value; + } + return flagState; + }, {}), + // TODO: support this better; improve types + // notebookController: dashboardConfigSpec.notebookController?.enabled ?? false, + }; +}; + +export const isAreaAvailable = ( + area: SupportedArea, + dashboardConfigSpec: DashboardConfigKind['spec'], + dscStatus: DataScienceClusterKindStatus | null, +): IsAreaAvailableStatus => { + const { featureFlags, requiredComponents, reliantAreas } = SupportedAreasStateMap[area]; + + const reliantAreasState = reliantAreas + ? reliantAreas.reduce( + (areaStates, area) => ({ + ...areaStates, + [area]: isAreaAvailable(area, dashboardConfigSpec, dscStatus).status, + }), + {}, + ) + : null; + // Only need one to be true to work + const reliantAreaValues = reliantAreasState ? Object.values(reliantAreasState) : []; + const hasMetReliantAreas = reliantAreaValues.length > 0 ? reliantAreaValues.some((v) => v) : true; + + const flagState = getFlags(dashboardConfigSpec); + const featureFlagState = featureFlags + ? featureFlags.reduce( + (acc, flag) => ({ ...acc, [flag]: flagState[flag] ? 'on' : 'off' }), + {}, + ) + : null; + const hasMetFeatureFlags = featureFlagState + ? Object.values(featureFlagState).every((v) => v === 'on') + : true; + + const requiredComponentsState = + requiredComponents && dscStatus + ? requiredComponents.reduce( + (acc, component) => ({ ...acc, [component]: dscStatus.installedComponents[component] }), + {}, + ) + : null; + const hasMetRequiredComponents = requiredComponentsState + ? Object.values(requiredComponentsState).every((v) => v) + : true; + + return { + status: hasMetReliantAreas && hasMetFeatureFlags && hasMetRequiredComponents, + reliantAreas: reliantAreasState, + featureFlags: featureFlagState, + requiredComponents: requiredComponentsState, + }; +}; diff --git a/frontend/src/concepts/pipelines/context/PipelinesContext.tsx b/frontend/src/concepts/pipelines/context/PipelinesContext.tsx index 415cb6e85b..6955359aa7 100644 --- a/frontend/src/concepts/pipelines/context/PipelinesContext.tsx +++ b/frontend/src/concepts/pipelines/context/PipelinesContext.tsx @@ -16,6 +16,7 @@ import ViewPipelineServerModal from '~/concepts/pipelines/content/ViewPipelineSe import useSyncPreferredProject from '~/concepts/projects/useSyncPreferredProject'; import useManageElyraSecret from '~/concepts/pipelines/context/useManageElyraSecret'; import { deleteServer } from '~/concepts/pipelines/utils'; +import { conditionalArea, SupportedArea } from '~/concepts/areas'; import useAPIState, { APIState } from './useAPIState'; import usePipelineNamespaceCR, { dspaLoaded, hasServerTimedOut } from './usePipelineNamespaceCR'; import usePipelinesAPIRoute from './usePipelinesAPIRoute'; @@ -49,10 +50,10 @@ type PipelineContextProviderProps = { namespace: string; }; -export const PipelineContextProvider: React.FC = ({ - children, - namespace, -}) => { +export const PipelineContextProvider = conditionalArea( + SupportedArea.DS_PIPELINES, + true, +)(({ children, namespace }) => { const { projects } = React.useContext(ProjectsContext); const project = projects.find(byName(namespace)) ?? null; useSyncPreferredProject(project); @@ -109,7 +110,7 @@ export const PipelineContextProvider: React.FC = ( {children} ); -}; +}); type UsePipelinesAPI = APIState & { /** The contextual namespace */ diff --git a/frontend/src/concepts/pipelines/context/PipelinesContextWorkaround.tsx b/frontend/src/concepts/pipelines/context/PipelinesContextWorkaround.tsx deleted file mode 100644 index 13edad62bd..0000000000 --- a/frontend/src/concepts/pipelines/context/PipelinesContextWorkaround.tsx +++ /dev/null @@ -1,52 +0,0 @@ -/** - * This file is immediately deprecated, this is for a small fix for the next release and will - * be fixed by https://github.com/opendatahub-io/odh-dashboard/issues/2010 - */ -import * as React from 'react'; -import axios from 'axios'; -import { Bullseye, Spinner } from '@patternfly/react-core'; -import { DataScienceClusterKindStatus } from '~/k8sTypes'; -import useFetchState from '~/utilities/useFetchState'; -import { PipelineContextProvider as PipelineContextProviderActual } from './PipelinesContext'; - -/** - * Should only return `null` when on v1 Operator. - */ -const fetchDscStatus = (): Promise => { - const url = '/api/dsc/status'; - return axios - .get(url) - .then((response) => response.data) - .catch((e) => { - if (e.response.status === 404) { - // DSC is not available, assume v1 Operator - return null; - } - throw new Error(e.response.data.message); - }); -}; - -const useFetchDscStatus = () => useFetchState(fetchDscStatus, null); - -/** @deprecated - replaced by https://github.com/opendatahub-io/odh-dashboard/issues/2010 */ -export const PipelineContextProviderWorkaround: React.FC< - React.ComponentProps -> = ({ children, ...props }) => { - const [dscStatus, loaded] = useFetchDscStatus(); - - if (!loaded) { - return ( - - - - ); - } - - if (dscStatus && !dscStatus.installedComponents?.['data-science-pipelines-operator']) { - // eslint-disable-next-line no-console - console.log('Not rendering DS Pipelines Context because there is no backing component.'); - return <>{children}; - } - - return {children}; -}; diff --git a/frontend/src/concepts/pipelines/context/index.ts b/frontend/src/concepts/pipelines/context/index.ts index d9e8a6a3b5..98c15164e0 100644 --- a/frontend/src/concepts/pipelines/context/index.ts +++ b/frontend/src/concepts/pipelines/context/index.ts @@ -6,4 +6,3 @@ export { ViewServerModal, PipelineServerTimedOut, } from './PipelinesContext'; -export { PipelineContextProviderWorkaround } from './PipelinesContextWorkaround'; diff --git a/frontend/src/concepts/projects/ProjectsContext.tsx b/frontend/src/concepts/projects/ProjectsContext.tsx index b9e25bca1e..94202b1cc6 100644 --- a/frontend/src/concepts/projects/ProjectsContext.tsx +++ b/frontend/src/concepts/projects/ProjectsContext.tsx @@ -1,11 +1,14 @@ import * as React from 'react'; import useFetchState, { FetchState } from '~/utilities/useFetchState'; -import { getDSGProjects } from '~/api'; +import { getProjects } from '~/api'; import { KnownLabels, ProjectKind } from '~/k8sTypes'; +import { useDashboardNamespace } from '~/redux/selectors'; +import { isAvailableProject } from '~/concepts/projects/utils'; type ProjectFetchState = FetchState; type ProjectsContext = { projects: ProjectKind[]; + dataScienceProjects: ProjectKind[]; modelServingProjects: ProjectKind[]; /** eg. Terminating state, etc */ nonActiveProjects: ProjectKind[]; @@ -26,6 +29,7 @@ type ProjectsContext = { export const ProjectsContext = React.createContext({ projects: [], + dataScienceProjects: [], modelServingProjects: [], nonActiveProjects: [], preferredProject: null, @@ -44,39 +48,46 @@ type ProjectsProviderProps = { }; const ProjectsContextProvider: React.FC = ({ children }) => { - const fetchProjects = React.useCallback(() => getDSGProjects(), []); + const fetchProjects = React.useCallback(() => getProjects(), []); const [preferredProject, setPreferredProject] = React.useState(null); const [projectData, loaded, loadError, refreshProjects] = useFetchState( fetchProjects, [], ); + const { dashboardNamespace } = useDashboardNamespace(); - const { projects, modelServingProjects, nonActiveProjects } = React.useMemo( + const { projects, dataScienceProjects, modelServingProjects, nonActiveProjects } = React.useMemo( () => projectData.reduce<{ projects: ProjectKind[]; + dataScienceProjects: ProjectKind[]; modelServingProjects: ProjectKind[]; nonActiveProjects: ProjectKind[]; }>( (states, project) => { - if (project.status?.phase === 'Active') { - // Project that is active - states.projects.push(project); - if (project.metadata.labels?.[KnownLabels.MODEL_SERVING_PROJECT]) { - // Model Serving active projects - states.modelServingProjects.push(project); + if (isAvailableProject(project.metadata.name, dashboardNamespace)) { + if (project.status?.phase === 'Active') { + // Project that is active + states.projects.push(project); + if (project.metadata.labels?.[KnownLabels.DASHBOARD_RESOURCE]) { + states.dataScienceProjects.push(project); + } + if (project.metadata.labels?.[KnownLabels.MODEL_SERVING_PROJECT]) { + // Model Serving active projects + states.modelServingProjects.push(project); + } + } else { + // Non 'Active' -- aka terminating + states.nonActiveProjects.push(project); } - } else { - // Non 'Active' -- aka terminating - states.nonActiveProjects.push(project); } return states; }, - { projects: [], modelServingProjects: [], nonActiveProjects: [] }, + { projects: [], dataScienceProjects: [], modelServingProjects: [], nonActiveProjects: [] }, ), - [projectData], + [projectData, dashboardNamespace], ); const refresh = React.useCallback( @@ -114,6 +125,7 @@ const ProjectsContextProvider: React.FC = ({ children }) { + it('should be false when the project starts with "openshift-"', () => { + expect(isAvailableProject('openshift-monitoring', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('openshift-apiserver', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('openshift-authentication', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('openshift-config', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('openshift-infra', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('openshift-node', mockDashboardNamespace)).toBe(false); + }); + it('should be false when the project starts with "kube-"', () => { + expect(isAvailableProject('kube-public', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('kube-system', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('kube-node-lease', mockDashboardNamespace)).toBe(false); + }); + it('should be false when the project is "default", "system", or "openshift"', () => { + expect(isAvailableProject('default', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('system', mockDashboardNamespace)).toBe(false); + expect(isAvailableProject('openshift', mockDashboardNamespace)).toBe(false); + }); + it('should be false when the project is where the dashboard is deployed', () => { + expect(isAvailableProject(mockDashboardNamespace, mockDashboardNamespace)).toBe(false); + }); + it('should be true in all the other situations', () => { + expect(isAvailableProject('notebook-images', mockDashboardNamespace)).toBe(true); + expect(isAvailableProject('openshiftblabla', mockDashboardNamespace)).toBe(true); + expect(isAvailableProject('kubelike', mockDashboardNamespace)).toBe(true); + expect(isAvailableProject('odh-not-dashboard', mockDashboardNamespace)).toBe(true); + }); +}); diff --git a/frontend/src/concepts/projects/utils.ts b/frontend/src/concepts/projects/utils.ts new file mode 100644 index 0000000000..374b55fb92 --- /dev/null +++ b/frontend/src/concepts/projects/utils.ts @@ -0,0 +1,9 @@ +export const isAvailableProject = (projectName: string, dashboardNamespace: string) => + !( + projectName.startsWith('openshift-') || + projectName.startsWith('kube-') || + projectName === 'default' || + projectName === 'system' || + projectName === 'openshift' || + projectName === dashboardNamespace + ); diff --git a/frontend/src/images/rhods-logo.svg b/frontend/src/images/rhods-logo.svg index 57aad00a2a..e14ae8008d 100644 --- a/frontend/src/images/rhods-logo.svg +++ b/frontend/src/images/rhods-logo.svg @@ -1,7 +1,7 @@ - + + viewBox="0 0 835.9 244" style="enable-background:new 0 0 835.9 244;" xml:space="preserve">

+ 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/detail/pipelines/PipelinesSection.tsx b/frontend/src/pages/projects/screens/detail/pipelines/PipelinesSection.tsx index 0776f669fd..b3e865c791 100644 --- a/frontend/src/pages/projects/screens/detail/pipelines/PipelinesSection.tsx +++ b/frontend/src/pages/projects/screens/detail/pipelines/PipelinesSection.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert, Divider } from '@patternfly/react-core'; +import { Divider } from '@patternfly/react-core'; import { ProjectSectionID } from '~/pages/projects/screens/detail/types'; import { ProjectSectionTitles } from '~/pages/projects/screens/detail/const'; import DetailsSection from '~/pages/projects/screens/detail/DetailsSection'; @@ -12,28 +12,11 @@ import PipelineServerActions from '~/concepts/pipelines/content/pipelinesDetails const PipelinesSection: React.FC = () => { const { - project, pipelinesServer: { initializing, installed, timedOut }, } = usePipelinesAPI(); const [isPipelinesEmpty, setIsPipelinesEmpty] = React.useState(false); - if (!project) { - // Only possible today because of not having the API installed - // TODO: Fix in https://github.com/opendatahub-io/odh-dashboard/issues/2010 - return ( - - - - ); - } - return ( <> = ({ breadcrumbPath={[ Projects} + render={() => Data Science Projects} />, = ({ existingData, isOp onClose(submitted); setActionInProgress(false); setRemovedNotebooks([]); + setError(undefined); resetData(); }; @@ -61,72 +53,60 @@ const ManageStorageModal: React.FC = ({ existingData, isOp const canCreate = !actionInProgress && createData.nameDesc.name.trim() && hasValidNotebookRelationship; - const submit = async () => { - setError(undefined); - setActionInProgress(true); - + const runPromiseActions = async (dryRun: boolean) => { const { - nameDesc: { name, description }, - size, forNotebook: { name: notebookName, mountPath }, } = createData; - - const pvc = assemblePvc(name, namespace, description, size); - - const handleError = (e: Error) => { - setError(e); - setActionInProgress(false); - }; - const handleNotebookNameConnection = (pvcName: string) => { - if (notebookName) { - attachNotebookPVC(notebookName, namespace, pvcName, mountPath.value) - .then(() => { - setActionInProgress(false); - onBeforeClose(true); - }) - .catch((e) => { - setError(e); - setActionInProgress(false); - }); - } else { - setActionInProgress(false); - onBeforeClose(true); - } - }; - + const pvcPromises: Promise[] = []; if (existingData) { const pvcName = existingData.metadata.name; - if (getPvcDisplayName(existingData) !== createData.nameDesc.name) { - await updatePvcDisplayName(pvcName, namespace, createData.nameDesc.name); - } - if (getPvcDescription(existingData) !== createData.nameDesc.description) { - await updatePvcDescription(pvcName, namespace, createData.nameDesc.description); + if ( + getPvcDisplayName(existingData) !== createData.nameDesc.name || + getPvcDescription(existingData) !== createData.nameDesc.description || + existingData.spec.resources.requests.storage !== createData.size + ) { + pvcPromises.push(updatePvc(createData, existingData, namespace, { dryRun })); } if (removedNotebooks.length > 0) { // Remove connected pvcs - Promise.all( - removedNotebooks.map((notebookName) => - removeNotebookPVC(notebookName, namespace, pvcName), + pvcPromises.push( + ...removedNotebooks.map((notebookName) => + removeNotebookPVC(notebookName, namespace, pvcName, { dryRun }), ), - ) - .then(() => handleNotebookNameConnection(pvcName)) - .catch(handleError); - return; + ); } - handleNotebookNameConnection(pvcName); - if (parseInt(getPvcTotalSize(existingData)) !== createData.size) { - await updatePvcSize(pvcName, namespace, `${createData.size}Gi`); + + await Promise.all(pvcPromises); + if (notebookName) { + await attachNotebookPVC(notebookName, namespace, pvcName, mountPath.value, { + dryRun, + }); } - } else { - createPvc(pvc) - .then((createdPvc) => handleNotebookNameConnection(createdPvc.metadata.name)) - .catch(handleError); + return; } + const createdPvc = await createPvc(createData, namespace, { dryRun }); + if (notebookName) { + await attachNotebookPVC(notebookName, namespace, createdPvc.metadata.name, mountPath.value, { + dryRun, + }); + } + }; + + const submit = () => { + setError(undefined); + setActionInProgress(true); + + runPromiseActions(true) + .then(() => runPromiseActions(false).then(() => onBeforeClose(true))) + .catch((e) => { + setError(e); + setActionInProgress(false); + }); }; return ( = ({ existingData, isOp isOpen={isOpen} onClose={() => onBeforeClose(false)} showClose - actions={[ - , - , - ]} + footer={ + onBeforeClose(false)} + isSubmitDisabled={!canCreate} + error={error} + alertTitle="Error creating storage" + /> + } > - - -
{ - e.preventDefault(); - submit(); - }} - > + { + e.preventDefault(); + submit(); + }} + > + + setCreateData(key, value)} currentSize={existingData?.status?.capacity?.storage} autoFocusName /> - {createData.hasExistingNotebookConnections && ( + + {createData.hasExistingNotebookConnections && ( + @@ -168,7 +152,9 @@ const ManageStorageModal: React.FC = ({ existingData, isOp loaded={notebookLoaded} error={notebookError} /> - )} + + )} + { setCreateData('forNotebook', forNotebookData); @@ -176,21 +162,14 @@ const ManageStorageModal: React.FC = ({ existingData, isOp forNotebookData={createData.forNotebook} isDisabled={connectedNotebooks.length !== 0 && removedNotebooks.length === 0} /> - - - {restartNotebooks.length !== 0 && ( - - - - )} - {error && ( - - - {error.message} - - )} -
+ {restartNotebooks.length !== 0 && ( + + + + )} +
+
); }; diff --git a/frontend/src/pages/projects/screens/projects/ProjectListView.tsx b/frontend/src/pages/projects/screens/projects/ProjectListView.tsx index fadd0b87ee..eae7cb24e4 100644 --- a/frontend/src/pages/projects/screens/projects/ProjectListView.tsx +++ b/frontend/src/pages/projects/screens/projects/ProjectListView.tsx @@ -8,6 +8,7 @@ import { getProjectDisplayName, getProjectOwner } from '~/pages/projects/utils'; import { useAppContext } from '~/app/AppContext'; import LaunchJupyterButton from '~/pages/projects/screens/projects/LaunchJupyterButton'; import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; +import { ProjectScope } from '~/pages/projects/types'; import NewProjectButton from './NewProjectButton'; import { columns } from './tableData'; import ProjectTableRow from './ProjectTableRow'; @@ -16,15 +17,18 @@ import ManageProjectModal from './ManageProjectModal'; type ProjectListViewProps = { allowCreate: boolean; + scope: ProjectScope; }; -const ProjectListView: React.FC = ({ allowCreate }) => { +const ProjectListView: React.FC = ({ allowCreate, scope }) => { const { dashboardConfig } = useAppContext(); - const { projects: unfilteredProjects, refresh } = React.useContext(ProjectsContext); + const { projects, dataScienceProjects, refresh } = React.useContext(ProjectsContext); const navigate = useNavigate(); const [searchType, setSearchType] = React.useState(SearchType.NAME); const [search, setSearch] = React.useState(''); - const filteredProjects = unfilteredProjects.filter((project) => { + const filteredProjects = ( + scope === ProjectScope.ALL_PROJECTS ? projects : dataScienceProjects + ).filter((project) => { if (!search) { return true; } @@ -63,6 +67,7 @@ const ProjectListView: React.FC = ({ allowCreate }) => { } + data-id="project-view-table" rowRenderer={(project) => ( void; +}; + +const isProjectScope = isInEnum(ProjectScope); + +const ProjectScopeSelect: React.FC = ({ selection, setSelection }) => { + const [isOpen, setOpen] = React.useState(false); + return ( + + ); +}; + +export default ProjectScopeSelect; diff --git a/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx b/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx index a54934e7dd..cba59d1fd9 100644 --- a/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx +++ b/frontend/src/pages/projects/screens/projects/ProjectTableRow.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; -import { Text, TextVariants, Timestamp } from '@patternfly/react-core'; +import { Flex, Label, Text, TextVariants, Timestamp, Tooltip } from '@patternfly/react-core'; import { ActionsColumn, Td, Tr } from '@patternfly/react-table'; -import { ProjectKind } from '~/k8sTypes'; +import { KnownLabels, ProjectKind } from '~/k8sTypes'; import useProjectTableRowItems from '~/pages/projects/screens/projects/useProjectTableRowItems'; import useProjectNotebookStates from '~/pages/projects/notebook/useProjectNotebookStates'; import ListNotebookState from '~/pages/projects/notebook/ListNotebookState'; @@ -28,9 +28,18 @@ const ProjectTableRow: React.FC = ({ return ( - - - + + {project.metadata.labels?.[KnownLabels.DASHBOARD_RESOURCE] && ( + + + + )} + + + + {owner && {owner}} diff --git a/frontend/src/pages/projects/screens/projects/ProjectView.tsx b/frontend/src/pages/projects/screens/projects/ProjectView.tsx index a5417e8169..6b6a6c3e43 100644 --- a/frontend/src/pages/projects/screens/projects/ProjectView.tsx +++ b/frontend/src/pages/projects/screens/projects/ProjectView.tsx @@ -4,8 +4,11 @@ import { useAccessReview } from '~/api'; import { AccessReviewResourceAttributes } from '~/k8sTypes'; import { ProjectsContext } from '~/concepts/projects/ProjectsContext'; import useMountProjectRefresh from '~/concepts/projects/useMountProjectRefresh'; +import { useBrowserStorage } from '~/components/browserStorage'; +import { ProjectScope } from '~/pages/projects/types'; import EmptyProjects from './EmptyProjects'; import ProjectListView from './ProjectListView'; +import ProjectScopeSelect from './ProjectScopeSelect'; const accessReviewResource: AccessReviewResourceAttributes = { group: 'project.openshift.io', @@ -14,24 +17,33 @@ const accessReviewResource: AccessReviewResourceAttributes = { }; const ProjectView: React.FC = () => { - const { projects } = React.useContext(ProjectsContext); + const [scope, setScope] = useBrowserStorage( + 'odh.dashboard.project.scope', + ProjectScope.DS_PROJECTS, + ); + const { projects, dataScienceProjects } = React.useContext(ProjectsContext); useMountProjectRefresh(); const [allowCreate, rbacLoaded] = useAccessReview(accessReviewResource); return ( } loaded={rbacLoaded} - empty={projects.length === 0} + empty={ + scope === ProjectScope.ALL_PROJECTS + ? projects.length === 0 + : dataScienceProjects.length === 0 + } emptyStatePage={} provideChildrenPadding > - + ); }; diff --git a/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx b/frontend/src/pages/projects/screens/spawner/SpawnerPage.tsx index d7ae0600f3..8d684c154f 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(() => { @@ -121,7 +124,7 @@ const SpawnerPage: React.FC = ({ existingNotebook }) => { title={existingNotebook ? `Edit ${editNotebookDisplayName}` : 'Create workbench'} breadcrumb={ - Data science projects} /> + Data Science Projects} /> ( {displayName} 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/service.ts b/frontend/src/pages/projects/screens/spawner/service.ts index cd3fa8a273..7deda08b56 100644 --- a/frontend/src/pages/projects/screens/spawner/service.ts +++ b/frontend/src/pages/projects/screens/spawner/service.ts @@ -1,7 +1,6 @@ import * as _ from 'lodash'; import { assembleConfigMap, - assemblePvc, assembleSecret, createConfigMap, createPvc, @@ -31,19 +30,12 @@ export const createPvcDataForNotebook = async ( projectName: string, storageData: StorageData, ): Promise<{ volumes: Volume[]; volumeMounts: VolumeMount[] }> => { - const { - storageType, - creating: { - nameDesc: { name: pvcName, description: pvcDescription }, - size, - }, - } = storageData; + const { storageType } = storageData; const { volumes, volumeMounts } = getVolumesByStorageData(storageData); if (storageType === StorageType.NEW_PVC) { - const pvcData = assemblePvc(pvcName, projectName, pvcDescription, size); - const pvc = await createPvc(pvcData); + const pvc = await createPvc(storageData.creating, projectName); const newPvcName = pvc.metadata.name; volumes.push({ name: newPvcName, persistentVolumeClaim: { claimName: newPvcName } }); volumeMounts.push({ mountPath: ROOT_MOUNT_PATH, name: newPvcName }); @@ -58,10 +50,6 @@ export const replaceRootVolumesForNotebook = async ( ): Promise<{ volumes: Volume[]; volumeMounts: VolumeMount[] }> => { const { storageType, - creating: { - nameDesc: { name: creatingName, description }, - size, - }, existing: { storage: existingName }, } = storageData; @@ -78,8 +66,7 @@ export const replaceRootVolumesForNotebook = async ( }; replacedVolumeMount = { name: existingName, mountPath: ROOT_MOUNT_PATH }; } else { - const pvcData = assemblePvc(creatingName, projectName, description, size); - const pvc = await createPvc(pvcData); + const pvc = await createPvc(storageData.creating, projectName); const newPvcName = pvc.metadata.name; replacedVolume = { name: newPvcName, persistentVolumeClaim: { claimName: newPvcName } }; replacedVolumeMount = { mountPath: ROOT_MOUNT_PATH, name: newPvcName }; diff --git a/frontend/src/pages/projects/screens/spawner/storage/StorageField.tsx b/frontend/src/pages/projects/screens/spawner/storage/StorageField.tsx index 8f74f6a47f..b6ce58c6bd 100644 --- a/frontend/src/pages/projects/screens/spawner/storage/StorageField.tsx +++ b/frontend/src/pages/projects/screens/spawner/storage/StorageField.tsx @@ -5,8 +5,6 @@ import { getDashboardMainContainer } from '~/utilities/utils'; import CreateNewStorageSection from './CreateNewStorageSection'; import AddExistingStorageField from './AddExistingStorageField'; -import '~/pages/projects/screens/detail/storage/ManageStorageModal.scss'; - type StorageFieldType = { storageData: StorageData; setStorageData: UpdateObjectAtPropAndValue; diff --git a/frontend/src/pages/projects/screens/spawner/storage/useAvailablePvcSize.ts b/frontend/src/pages/projects/screens/spawner/storage/useAvailablePvcSize.ts deleted file mode 100644 index 893074d886..0000000000 --- a/frontend/src/pages/projects/screens/spawner/storage/useAvailablePvcSize.ts +++ /dev/null @@ -1,24 +0,0 @@ -import * as React from 'react'; -import { AppContext } from '~/app/AppContext'; - -const DEFAULT_PVC_SIZE = 20; - -const useDefaultPvcSize = (): number => { - const { - dashboardConfig: { - spec: { notebookController }, - }, - } = React.useContext(AppContext); - - let defaultPvcSize = DEFAULT_PVC_SIZE; - if (notebookController?.pvcSize) { - const parsedConfigSize = parseInt(notebookController?.pvcSize); - if (!isNaN(parsedConfigSize)) { - defaultPvcSize = parsedConfigSize; - } - } - - return defaultPvcSize; -}; - -export default useDefaultPvcSize; diff --git a/frontend/src/pages/projects/screens/spawner/storage/useDefaultPvcSize.ts b/frontend/src/pages/projects/screens/spawner/storage/useDefaultPvcSize.ts new file mode 100644 index 0000000000..48ff049a79 --- /dev/null +++ b/frontend/src/pages/projects/screens/spawner/storage/useDefaultPvcSize.ts @@ -0,0 +1,14 @@ +import * as React from 'react'; +import { AppContext } from '~/app/AppContext'; + +const useDefaultPvcSize = (): string => { + const { + dashboardConfig: { + spec: { notebookController }, + }, + } = React.useContext(AppContext); + + return notebookController?.pvcSize || '20Gi'; +}; + +export default useDefaultPvcSize; diff --git a/frontend/src/pages/projects/screens/spawner/storage/utils.ts b/frontend/src/pages/projects/screens/spawner/storage/utils.ts index 5a2282c438..2ba0deff6e 100644 --- a/frontend/src/pages/projects/screens/spawner/storage/utils.ts +++ b/frontend/src/pages/projects/screens/spawner/storage/utils.ts @@ -13,7 +13,7 @@ import useRelatedNotebooks, { } from '~/pages/projects/notebook/useRelatedNotebooks'; import useGenericObjectState from '~/utilities/useGenericObjectState'; import { getRootVolumeName } from '~/pages/projects/screens/spawner/spawnerUtils'; -import useDefaultPvcSize from './useAvailablePvcSize'; +import useDefaultPvcSize from './useDefaultPvcSize'; export const useCreateStorageObjectForNotebook = ( existingData?: PersistentVolumeClaimKind, @@ -22,14 +22,14 @@ export const useCreateStorageObjectForNotebook = ( setData: UpdateObjectAtPropAndValue, resetDefaults: () => void, ] => { - const defaultPvcSize = useDefaultPvcSize(); + const size = useDefaultPvcSize(); const createDataState = useGenericObjectState({ nameDesc: { name: '', k8sName: undefined, description: '', }, - size: defaultPvcSize, + size, forNotebook: { name: '', mountPath: { @@ -44,11 +44,13 @@ export const useCreateStorageObjectForNotebook = ( const existingName = existingData ? getPvcDisplayName(existingData) : ''; const existingDescription = existingData ? getPvcDescription(existingData) : ''; - const existingSize = existingData ? existingData.spec.resources.requests.storage : ''; + const existingSize = existingData ? existingData.spec.resources.requests.storage : size; const { notebooks: relatedNotebooks } = useRelatedNotebooks( ConnectedNotebookContext.REMOVABLE_PVC, existingData ? existingData.metadata.name : undefined, ); + const hasExistingNotebookConnections = relatedNotebooks.length > 0; + React.useEffect(() => { if (existingName) { setCreateData('nameDesc', { @@ -56,16 +58,17 @@ export const useCreateStorageObjectForNotebook = ( description: existingDescription, }); - if (relatedNotebooks.length > 0) { - setCreateData('hasExistingNotebookConnections', true); - } + setCreateData('hasExistingNotebookConnections', hasExistingNotebookConnections); - const newSize = parseInt(existingSize); - if (newSize) { - setCreateData('size', newSize); - } + setCreateData('size', existingSize); } - }, [existingName, existingDescription, setCreateData, relatedNotebooks, existingSize]); + }, [ + existingName, + existingDescription, + setCreateData, + hasExistingNotebookConnections, + existingSize, + ]); return createDataState; }; @@ -90,7 +93,7 @@ export const useStorageDataObject = ( setData: UpdateObjectAtPropAndValue, resetDefaults: () => void, ] => { - const defaultPvcSize = useDefaultPvcSize(); + const size = useDefaultPvcSize(); return useGenericObjectState({ storageType: notebook ? StorageType.EXISTING_PVC : StorageType.NEW_PVC, creating: { @@ -98,7 +101,7 @@ export const useStorageDataObject = ( name: '', description: '', }, - size: defaultPvcSize, + size, }, existing: { storage: getRootVolumeName(notebook), 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/pages/projects/screens/spawner/useNotebookSize.ts b/frontend/src/pages/projects/screens/spawner/useNotebookSize.ts index dfced6214d..cd0ba53ecc 100644 --- a/frontend/src/pages/projects/screens/spawner/useNotebookSize.ts +++ b/frontend/src/pages/projects/screens/spawner/useNotebookSize.ts @@ -1,11 +1,12 @@ import * as React from 'react'; import { useAppContext } from '~/app/AppContext'; -import { DashboardConfig, NotebookSize } from '~/types'; +import { DashboardConfigKind } from '~/k8sTypes'; +import { NotebookSize } from '~/types'; import useNotification from '~/utilities/useNotification'; import { useDeepCompareMemoize } from '~/utilities/useDeepCompareMemoize'; import { DEFAULT_NOTEBOOK_SIZES } from './const'; -export const getNotebookSizes = (config: DashboardConfig): NotebookSize[] => { +export const getNotebookSizes = (config: DashboardConfigKind): NotebookSize[] => { let sizes = config.spec.notebookSizes || []; if (sizes.length === 0) { sizes = DEFAULT_NOTEBOOK_SIZES; diff --git a/frontend/src/pages/projects/types.ts b/frontend/src/pages/projects/types.ts index 870eee7658..5f2bc84f6a 100644 --- a/frontend/src/pages/projects/types.ts +++ b/frontend/src/pages/projects/types.ts @@ -22,7 +22,7 @@ export type NameDescType = { export type CreatingStorageObject = { nameDesc: NameDescType; - size: number; + size: string; }; export type MountPath = { @@ -141,3 +141,7 @@ export enum ConfigMapCategory { GENERIC = 'configmap key-value', UPLOAD = 'configmap upload', } +export enum ProjectScope { + DS_PROJECTS = 'Data science projects', + ALL_PROJECTS = 'All available projects', +} diff --git a/frontend/src/services/dashboardConfigService.ts b/frontend/src/services/dashboardConfigService.ts index 7adfe86fb9..92aac1cb8d 100644 --- a/frontend/src/services/dashboardConfigService.ts +++ b/frontend/src/services/dashboardConfigService.ts @@ -1,7 +1,7 @@ import axios from 'axios'; -import { DashboardConfig } from '~/types'; +import { DashboardConfigKind } from '~/k8sTypes'; -export const fetchDashboardConfig = (): Promise => { +export const fetchDashboardConfig = (): Promise => { const url = '/api/config'; return axios .get(url) diff --git a/frontend/src/typeHelpers.ts b/frontend/src/typeHelpers.ts index 65026cdb5f..a260731e44 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. @@ -66,6 +68,32 @@ type Never = { */ export type EitherNotBoth = (TypeA & Never) | (TypeB & Never); +/** + * Either TypeA properties or TypeB properties or neither of the properties -- never both. + * + * @example + * ```ts + * type MyType = EitherOrBoth<{ foo: boolean }, { bar: boolean }>; + * + * // Valid usages: + * const objA: MyType = { + * foo: true, + * }; + * const objB: MyType = { + * bar: true, + * }; + * const objBoth: MyType = { + * foo: true, + * bar: true, + * }; + * + * // TS Error -- can't omit both properties: + * const objNeither: MyType = { + * }; + * ``` + */ +export type EitherOrBoth = EitherNotBoth | (TypeA & TypeB); + /** * Either TypeA properties or TypeB properties or neither of the properties -- never both. * @@ -93,3 +121,8 @@ export type EitherNotBoth = (TypeA & Never) | (TypeB & Neve export type EitherOrNone = | EitherNotBoth | (Never & Never); + +export const isInEnum = + (e: T) => + (token: unknown): token is T[keyof T] => + Object.values(e).includes(token as T[keyof T]); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index d1e2971e6d..0450b2886f 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -1,8 +1,6 @@ /* * Common types, should be kept up to date with backend types */ - -import { ServingRuntimeSize } from '~/pages/modelServing/screens/types'; import { EnvironmentFromVariable } from '~/pages/projects/types'; import { ImageStreamKind, ImageStreamSpecTagType } from './k8sTypes'; import { EitherNotBoth } from './typeHelpers'; @@ -35,64 +33,14 @@ export type PrometheusQueryRangeResponse = { export type PrometheusQueryRangeResultValue = [number, string]; /** + * @deprecated - Use AcceleratorProfiles * In some YAML configs, we'll need to stringify a number -- this type just helps show it's not * "any string" as a documentation touch point. Has no baring on the type checking. */ type NumberString = string; +/** @deprecated - Use AcceleratorProfiles */ export type GpuSettingString = 'autodetect' | 'hidden' | NumberString | undefined; -export type OperatorStatus = { - /** Operator is installed and will be cloned to the namespace on creation */ - available: boolean; - /** Has a detection gone underway or is the available a static default */ - queriedForStatus: boolean; -}; - -export type DashboardConfig = K8sResourceCommon & { - spec: { - dashboardConfig: DashboardCommonConfig; - groupsConfig?: { - adminGroups: string; - allowedGroups: string; - }; - notebookSizes?: NotebookSize[]; - modelServerSizes?: ServingRuntimeSize[]; - notebookController?: { - enabled: boolean; - pvcSize?: string; - notebookNamespace?: string; - gpuSetting?: GpuSettingString; - notebookTolerationSettings?: TolerationSettings; - }; - templateOrder?: string[]; - templateDisablement?: string[]; - }; - /** Faux status object -- computed by the service account */ - status: { - dependencyOperators: { - redhatOpenshiftPipelines: OperatorStatus; - }; - }; -}; - -export type DashboardCommonConfig = { - enablement: boolean; - disableInfo: boolean; - disableSupport: boolean; - disableClusterManager: boolean; - disableTracking: boolean; - disableBYONImageStream: boolean; - disableISVBadges: boolean; - disableAppLauncher: boolean; - disableUserManagement: boolean; - disableProjects: boolean; - disableModelServing: boolean; - disableProjectSharing: boolean; - disableCustomServingRuntimes: boolean; - modelMetricsNamespace: string; - disablePipelines: boolean; -}; - export type NotebookControllerUserState = { user: string; lastSelectedImage: string; @@ -152,7 +100,7 @@ export type NotebookTolerationFormSettings = TolerationSettings & { export type ClusterSettingsType = { userTrackingEnabled: boolean; - pvcSize: number | string; + pvcSize: number; cullerTimeout: number; notebookTolerationSettings: TolerationSettings | null; }; diff --git a/frontend/src/utilities/NavData.tsx b/frontend/src/utilities/NavData.tsx index 32916b578a..1ee1561360 100644 --- a/frontend/src/utilities/NavData.tsx +++ b/frontend/src/utilities/NavData.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import { ExclamationCircleIcon } from '@patternfly/react-icons'; import { Icon, Split, SplitItem } from '@patternfly/react-core'; -import { DashboardConfig } from '~/types'; -import { featureFlagEnabled } from './utils'; +import { SupportedArea, useIsAreaAvailable } from '~/concepts/areas'; +import { useAppContext } from '~/app/AppContext'; +import { useUser } from '~/redux/selectors'; type NavDataCommon = { id: string; @@ -28,121 +29,139 @@ export const isNavDataHref = (navData: NavDataItem): navData is NavDataHref => export const isNavDataGroup = (navData: NavDataItem): navData is NavDataGroup => !!(navData as NavDataGroup)?.children; -const getSettingsNav = ( - isAdmin: boolean, - dashboardConfig: DashboardConfig, -): NavDataGroup | null => { - if (!isAdmin) { - return null; - } +const useAreaCheck = (area: SupportedArea, success: T[]): T[] => + useIsAreaAvailable(area).status ? success : []; - const settingsNavs: NavDataHref[] = []; - if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableBYONImageStream)) { - settingsNavs.push({ - id: 'settings-notebook-images', - label: 'Notebook image settings', - href: '/notebookImages', - }); - } - - if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableClusterManager)) { - settingsNavs.push({ - id: 'settings-cluster-settings', - label: 'Cluster settings', - href: '/clusterSettings', - }); - } - - if ( - featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableCustomServingRuntimes) && - featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableModelServing) - ) { - settingsNavs.push({ - id: 'settings-custom-serving-runtimes', - label: 'Serving runtimes', - href: '/servingRuntimes', - }); - } - - if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableUserManagement)) { - settingsNavs.push({ - id: 'settings-group-settings', - label: 'User management', - href: '/groupSettings', - }); - } - - if (settingsNavs.length === 0) { - return null; - } - - return { - id: 'settings', - group: { id: 'settings', title: 'Settings' }, - children: settingsNavs, - }; -}; - -export const getNavBarData = ( - isAdmin: boolean, - dashboardConfig: DashboardConfig, -): NavDataItem[] => { - const navItems: NavDataItem[] = []; - - navItems.push({ +const useApplicationsNav = (): NavDataItem[] => [ + { id: 'applications', group: { id: 'apps', title: 'Applications' }, children: [ { id: 'apps-installed', label: 'Enabled', href: '/' }, { id: 'apps-explore', label: 'Explore', href: '/explore' }, ], - }); + }, +]; + +const useDSProjectsNav = (): NavDataItem[] => + useAreaCheck(SupportedArea.DS_PROJECTS_VIEW, [ + { id: 'dsg', label: 'Data Science Projects', href: '/projects' }, + ]); - if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableProjects)) { - navItems.push({ id: 'dsg', label: 'Data Science Projects', href: '/projects' }); +const useDSPipelinesNav = (): NavDataItem[] => { + const { dashboardConfig } = useAppContext(); + const isAvailable = useIsAreaAvailable(SupportedArea.DS_PIPELINES).status; + + if (!isAvailable) { + return []; } - if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disablePipelines)) { - const operatorAvailable = - dashboardConfig.status.dependencyOperators.redhatOpenshiftPipelines.available; + const operatorAvailable = + dashboardConfig.status.dependencyOperators.redhatOpenshiftPipelines.available; - if (operatorAvailable) { - navItems.push({ + if (operatorAvailable) { + return [ + { id: 'pipelines', group: { id: 'pipelines', title: 'Data Science Pipelines' }, children: [ { id: 'global-pipelines', label: 'Pipelines', href: '/pipelines' }, { id: 'global-pipeline-runs', label: 'Runs', href: '/pipelineRuns' }, ], - }); - } else { - navItems.push({ - id: 'pipelines', - label: ( - - Data Science Pipelines - - - - - - - ), - href: `/dependency-missing/pipelines`, - }); - } + }, + ]; } - if (featureFlagEnabled(dashboardConfig.spec.dashboardConfig.disableModelServing)) { - navItems.push({ id: 'modelServing', label: 'Model Serving', href: '/modelServing' }); - } + return [ + { + id: 'pipelines', + label: ( + + Data Science Pipelines + + + + + + + ), + href: `/dependency-missing/pipelines`, + }, + ]; +}; + +const useModelServingNav = (): NavDataItem[] => + useAreaCheck(SupportedArea.MODEL_SERVING, [ + { id: 'modelServing', label: 'Model Serving', href: '/modelServing' }, + ]); + +const useResourcesNav = (): NavDataHref[] => [ + { id: 'resources', label: 'Resources', href: '/resources' }, +]; + +const useCustomNotebooksNav = (): NavDataHref[] => + useAreaCheck(SupportedArea.BYON, [ + { + id: 'settings-notebook-images', + label: 'Notebook image settings', + href: '/notebookImages', + }, + ]); + +const useClusterSettingsNav = (): NavDataHref[] => + useAreaCheck(SupportedArea.CLUSTER_SETTINGS, [ + { + id: 'settings-cluster-settings', + label: 'Cluster settings', + href: '/clusterSettings', + }, + ]); - navItems.push({ id: 'resources', label: 'Resources', href: '/resources' }); +const useCustomRuntimesNav = (): NavDataHref[] => + useAreaCheck(SupportedArea.CUSTOM_RUNTIMES, [ + { + id: 'settings-custom-serving-runtimes', + label: 'Serving runtimes', + href: '/servingRuntimes', + }, + ]); - const settingsNav = getSettingsNav(isAdmin, dashboardConfig); - if (settingsNav) { - navItems.push(settingsNav); +const useUserManagementNav = (): NavDataHref[] => + useAreaCheck(SupportedArea.USER_MANAGEMENT, [ + { + id: 'settings-group-settings', + label: 'User management', + href: '/groupSettings', + }, + ]); + +const useSettingsNav = (): NavDataGroup[] => { + const settingsNavs: NavDataHref[] = [ + ...useCustomNotebooksNav(), + ...useClusterSettingsNav(), + ...useCustomRuntimesNav(), + ...useUserManagementNav(), + ]; + + const { isAdmin } = useUser(); + if (!isAdmin || settingsNavs.length === 0) { + return []; } - return navItems; + return [ + { + id: 'settings', + group: { id: 'settings', title: 'Settings' }, + children: settingsNavs, + }, + ]; }; + +export const useBuildNavData = (): NavDataItem[] => [ + ...useApplicationsNav(), + ...useDSProjectsNav(), + ...useDSPipelinesNav(), + ...useModelServingNav(), + ...useResourcesNav(), + ...useSettingsNav(), +]; diff --git a/frontend/src/utilities/tolerations.ts b/frontend/src/utilities/tolerations.ts index 5878656918..0e6c65cd21 100644 --- a/frontend/src/utilities/tolerations.ts +++ b/frontend/src/utilities/tolerations.ts @@ -1,7 +1,7 @@ import { Patch } from '@openshift/dynamic-plugin-sdk-utils'; import _ from 'lodash'; -import { DashboardConfig, PodToleration, TolerationSettings } from '~/types'; -import { NotebookKind } from '~/k8sTypes'; +import { PodToleration, TolerationSettings } from '~/types'; +import { DashboardConfigKind, NotebookKind } from '~/k8sTypes'; import { AcceleratorState } from './useAcceleratorState'; export type TolerationChanges = { @@ -50,7 +50,7 @@ export const determineTolerations = ( }; export const computeNotebooksTolerations = ( - dashboardConfig: DashboardConfig, + dashboardConfig: DashboardConfigKind, notebook: NotebookKind, ): TolerationChanges => { const tolerations = notebook.spec.template.spec.tolerations || []; diff --git a/frontend/src/utilities/utils.ts b/frontend/src/utilities/utils.ts index eff87fc7bd..ad03d76f67 100644 --- a/frontend/src/utilities/utils.ts +++ b/frontend/src/utilities/utils.ts @@ -1,13 +1,6 @@ import { OdhApplication, OdhDocument, OdhDocumentType } from '~/types'; import { CATEGORY_ANNOTATION, DASHBOARD_MAIN_CONTAINER_ID, ODH_PRODUCT_NAME } from './const'; -/** - * Feature flags are required in the config -- but upgrades can be mixed and omission of the property - * usually ends up being enabled. This will prevent that as a general utility. - */ -export const featureFlagEnabled = (disabledSettingState?: boolean): boolean => - disabledSettingState === false; - export const makeCardVisible = (id: string): void => { setTimeout(() => { const element = document.getElementById(id); diff --git a/manifests/apps/jupyter/jupyter-app.yaml b/manifests/apps/jupyter/jupyter-app.yaml index 34f9c8ae5e..872887e3b7 100644 --- a/manifests/apps/jupyter/jupyter-app.yaml +++ b/manifests/apps/jupyter/jupyter-app.yaml @@ -182,7 +182,7 @@ spec: ## Prerequisites - - You have logged in to Red Hat OpenShift Data Science. + - You have logged in to Red Hat OpenShift AI. - You know the names and values you want to use for any environment variables in your notebook server environment, for example, @@ -202,7 +202,7 @@ spec: ii. Enter your credentials and click **Log in** (or equivalent for your identity provider). - If you see **Error 403: Forbidden**, you are not in the default user group or the default administrator group for OpenShift Data Science. Contact your administrator so that they can add you to the correct group using [Adding users for OpenShift Data Science](https://access.redhat.com/documentation/en-us/red_hat_openshift_data_science/1/html/managing_users_and_user_resources/adding-users-for-openshift-data-science_useradd). + If you see **Error 403: Forbidden**, you are not in the default user group or the default administrator group for OpenShift AI. Contact your administrator so that they can add you to the correct group using [Adding users for OpenShift AI](https://access.redhat.com/documentation/en-us/red_hat_openshift_data_science/1/html/managing_users_and_user_resources/adding-users-for-openshift-data-science_useradd). 3. Start a notebook server. diff --git a/manifests/crd/odhquickstarts.console.openshift.io.crd.yaml b/manifests/crd/odhquickstarts.console.openshift.io.crd.yaml index ae15d396db..fa90dba388 100644 --- a/manifests/crd/odhquickstarts.console.openshift.io.crd.yaml +++ b/manifests/crd/odhquickstarts.console.openshift.io.crd.yaml @@ -4,7 +4,7 @@ metadata: name: odhquickstarts.console.openshift.io annotations: description: Extension for guiding user through various workflows in the Red Hat - OpenShift Data Science dashboard. + OpenShift AI dashboard. displayName: OdhQuickStart include.release.openshift.io/ibm-cloud-managed: "true" include.release.openshift.io/self-managed-high-availability: "true" @@ -24,7 +24,7 @@ spec: schema: openAPIV3Schema: description: OdhQuickStart is an extension for guiding user through various - workflows in the Red Hat OpenShift Data Science dashboard. + workflows in the Red Hat OpenShift AI dashboard. type: object required: - spec