From 122597c09e8a46cc2917b0ed22eb6bc8492e6d31 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Thu, 21 Apr 2022 14:53:01 -0400 Subject: [PATCH] [UPSTREAM] Fix to allow a user to enter an empty string. [UPSTREAM] Fix to allow a user to enter an empty string. Fixed linting error. Updated to read userTrackingEnabled instead of clusterSettings.userTrackingEnabled. [Upstream] Added BYON, webpack proxy, and CORS heading fix. Updated with REST api notebook image endpoints and new notebook image types. Updated REST API (#155) * Updated with REST API design changes. Fixed formatting error. * Update backend/src/types.ts Co-authored-by: Tom Coufal <7453394+tumido@users.noreply.github.com> Co-authored-by: Tom Coufal <7453394+tumido@users.noreply.github.com> Updated type.ts in front end with Succeeded [byon] Implement backend (#156) * feat(byon): List all notebooks Signed-off-by: Tomas Coufal * feat(byon): Get single notebook Signed-off-by: Tomas Coufal * fix(byon): Update api spec to include software and id Signed-off-by: Tomas Coufal * feat(byon): Schedule new notebook import Signed-off-by: Tomas Coufal * feat(byon): Delete notebook Signed-off-by: Tomas Coufal * feat(byon): Update notebook Signed-off-by: Tomas Coufal Rebased BYON from master (#162) * Updated way that to detect an admin. (#137) Fixed linting error. * Update backend port setting and makefile dev commands. (#133) * Update README.md (#96) Updated Readme to specify that a build is required prior to running the development server. * Fix for input not allowing blank spaces.: Fix to allow a user to enter an empty string. Fixed with review comment. * Add culler timeout settings feature (#134) * Added types for notebookimage and status (#147) Updated with REST api notebook image endpoints and new notebook image types. * Updated REST API (#155) * Updated with REST API design changes. Fixed formatting error. * Update backend/src/types.ts Co-authored-by: Tom Coufal <7453394+tumido@users.noreply.github.com> Co-authored-by: Tom Coufal <7453394+tumido@users.noreply.github.com> * Updated type.ts in front end with Succeeded * [byon] Implement backend (#156) * feat(byon): List all notebooks Signed-off-by: Tomas Coufal * feat(byon): Get single notebook Signed-off-by: Tomas Coufal * fix(byon): Update api spec to include software and id Signed-off-by: Tomas Coufal * feat(byon): Schedule new notebook import Signed-off-by: Tomas Coufal * feat(byon): Delete notebook Signed-off-by: Tomas Coufal * feat(byon): Update notebook Signed-off-by: Tomas Coufal Co-authored-by: Christopher Chase Co-authored-by: Chad Roberts Co-authored-by: Juntao Wang <37624318+DaoDaoNoCode@users.noreply.github.com> Co-authored-by: Tom Coufal <7453394+tumido@users.noreply.github.com> fix(byon): Use notebook-image label for visibility toggle (#169) Signed-off-by: Tomas Coufal feat(byon): Feat add creator annotation (#170) Signed-off-by: Tomas Coufal feat(byon): Allow to patch name and description on notebook (#172) Signed-off-by: Tomas Coufal feat(byon): Allow python dependency visibility field changes (#173) Signed-off-by: Tomas Coufal Added code for importing byon image. (#171) Updated front end code with import and start of table. Updated with byon changes Updated code with byon ui changes. Integrated import, delete, and get functionality for notebooks. Added start of edit panel. Updated build error fixes. Updates to edit dialog and linting cleanup. Added sort to table. Added filtering. Added failure warning to allow user to click Add check disable enabling failed notebook images Fixed with review comments. Fixed time bug. Removed admin Update backend port setting and makefile dev commands. (#133) Use webpack dev server proxy instead of CORS utilities (#127) Fixed issues with cherry picks. Fixed additional git cherry pick issues. Added ability to add packages, modify packages, etc. Added software package on import. Added additional table and fix some styling issues. Fixed issue where form was submitted. Updated import in notebooksUtils.ts Updated code to work with image streams instead of pipelines. Fixed linting errors. Fixed empty software package bug. Fixed bugs found during testing. Notebook image table update. Fixed error where not showing the empty state. Fixed table issue. Add another update of the notbook image table. Fixed issue where notebook would description disappeared. Fixed missing notification. Prevent duplicate names. Fixed issue with notebook images not updating correctly. --- Makefile | 16 - backend/src/routes/api/builds/index.ts | 8 - .../src/routes/api/cluster-settings/index.ts | 14 - backend/src/routes/api/components/index.ts | 14 - backend/src/routes/api/config/index.ts | 5 - backend/src/routes/api/console-links/index.ts | 8 - backend/src/routes/api/docs/index.ts | 8 - .../src/routes/api/getting-started/index.ts | 8 - backend/src/routes/api/notebook/index.ts | 60 +++ .../src/routes/api/notebook/notebooksUtils.ts | 305 +++++++++++++ backend/src/routes/api/quickstarts/index.ts | 8 - backend/src/routes/api/segment-key/index.ts | 8 - backend/src/routes/api/status/index.ts | 9 - backend/src/routes/api/validate-isv/index.ts | 14 - backend/src/types.ts | 106 ++++- backend/src/utils/responseUtils.ts | 24 - frontend/config/dotenv.js | 2 + frontend/config/webpack.dev.js | 8 +- frontend/package-lock.json | 44 ++ frontend/package.json | 1 + frontend/src/app/Routes.tsx | 2 + frontend/src/pages/ApplicationsPage.tsx | 6 +- .../pages/clusterSettings/ClusterSettings.tsx | 91 ++-- .../pages/notebookImages/DeleteImageModal.tsx | 49 ++ .../pages/notebookImages/EditStepTableRow.tsx | 105 +++++ .../notebookImages/ImportImageModal.scss | 3 + .../pages/notebookImages/ImportImageModal.tsx | 336 ++++++++++++++ .../pages/notebookImages/NotebookImages.tsx | 82 ++++ .../notebookImages/NotebookImagesTable.scss | 29 ++ .../notebookImages/NotebookImagesTable.tsx | 422 ++++++++++++++++++ .../notebookImages/UpdateImageModal.scss | 3 + .../pages/notebookImages/UpdateImageModal.tsx | 320 +++++++++++++ frontend/src/redux/actions/actions.ts | 3 +- frontend/src/services/buildsService.ts | 3 +- .../src/services/clusterSettingsService.ts | 5 +- frontend/src/services/componentsServices.ts | 5 +- frontend/src/services/consoleLinksService.ts | 3 +- .../src/services/dashboardConfigService.ts | 3 +- frontend/src/services/docsService.ts | 3 +- .../src/services/gettingStartedService.ts | 5 +- frontend/src/services/notebookImageService.ts | 50 +++ frontend/src/services/quickStartsService.ts | 3 +- frontend/src/services/segmentKeyService.ts | 3 +- frontend/src/services/validateIsvService.ts | 5 +- frontend/src/types.ts | 46 ++ frontend/src/utilities/NavData.ts | 1 + frontend/src/utilities/time.ts | 59 +++ .../src/utilities/useWatchNotebookImages.tsx | 57 +++ frontend/src/utilities/utils.ts | 9 +- install/dev-backend.sh | 11 - install/dev-frontend.sh | 10 - install/dev.sh | 15 - 52 files changed, 2161 insertions(+), 256 deletions(-) create mode 100644 backend/src/routes/api/notebook/index.ts create mode 100644 backend/src/routes/api/notebook/notebooksUtils.ts delete mode 100644 backend/src/utils/responseUtils.ts create mode 100644 frontend/src/pages/notebookImages/DeleteImageModal.tsx create mode 100644 frontend/src/pages/notebookImages/EditStepTableRow.tsx create mode 100644 frontend/src/pages/notebookImages/ImportImageModal.scss create mode 100644 frontend/src/pages/notebookImages/ImportImageModal.tsx create mode 100644 frontend/src/pages/notebookImages/NotebookImages.tsx create mode 100644 frontend/src/pages/notebookImages/NotebookImagesTable.scss create mode 100644 frontend/src/pages/notebookImages/NotebookImagesTable.tsx create mode 100644 frontend/src/pages/notebookImages/UpdateImageModal.scss create mode 100644 frontend/src/pages/notebookImages/UpdateImageModal.tsx create mode 100644 frontend/src/services/notebookImageService.ts create mode 100644 frontend/src/utilities/time.ts create mode 100644 frontend/src/utilities/useWatchNotebookImages.tsx delete mode 100755 install/dev-backend.sh delete mode 100755 install/dev-frontend.sh delete mode 100755 install/dev.sh diff --git a/Makefile b/Makefile index 6d502b7984..e3abc404f2 100644 --- a/Makefile +++ b/Makefile @@ -18,22 +18,6 @@ reinstall: build push undeploy deploy ################################## -# DEV - run apps locally for development - -.PHONY: dev-frontend -dev-frontend: - ./install/dev-frontend.sh - -.PHONY: dev-backend -dev-backend: - ./install/dev-backend.sh - -.PHONY: dev -dev: - ./install/dev.sh - -################################## - # BUILD - build image locally using s2i .PHONY: build diff --git a/backend/src/routes/api/builds/index.ts b/backend/src/routes/api/builds/index.ts index 7233961c48..477e68bb82 100644 --- a/backend/src/routes/api/builds/index.ts +++ b/backend/src/routes/api/builds/index.ts @@ -1,22 +1,14 @@ import { KubeFastifyInstance } from '../../../types'; import { FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { listBuilds } from './list'; module.exports = async (fastify: KubeFastifyInstance) => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return listBuilds() .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/cluster-settings/index.ts b/backend/src/routes/api/cluster-settings/index.ts index 35996a2272..4b00eb8bbf 100644 --- a/backend/src/routes/api/cluster-settings/index.ts +++ b/backend/src/routes/api/cluster-settings/index.ts @@ -1,21 +1,13 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { getClusterSettings, updateClusterSettings } from './clusterSettingsUtils'; export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return getClusterSettings(fastify) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); @@ -23,15 +15,9 @@ export default async (fastify: FastifyInstance): Promise => { fastify.get('/update', async (request: FastifyRequest, reply: FastifyReply) => { return updateClusterSettings(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/components/index.ts b/backend/src/routes/api/components/index.ts index f4e8ee1bfe..4ee826c400 100644 --- a/backend/src/routes/api/components/index.ts +++ b/backend/src/routes/api/components/index.ts @@ -1,22 +1,14 @@ import { KubeFastifyInstance } from '../../../types'; import { FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { listComponents, removeComponent } from './list'; module.exports = async (fastify: KubeFastifyInstance) => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return listComponents(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); @@ -24,15 +16,9 @@ module.exports = async (fastify: KubeFastifyInstance) => { fastify.get('/remove', async (request: FastifyRequest, reply: FastifyReply) => { return removeComponent(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/config/index.ts b/backend/src/routes/api/config/index.ts index ad8133bf11..c47fc72828 100644 --- a/backend/src/routes/api/config/index.ts +++ b/backend/src/routes/api/config/index.ts @@ -1,14 +1,9 @@ import { KubeFastifyInstance } from '../../../types'; import { FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { getDashboardConfig } from '../../../utils/resourceUtils'; module.exports = async (fastify: KubeFastifyInstance) => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(getDashboardConfig()); }); }; diff --git a/backend/src/routes/api/console-links/index.ts b/backend/src/routes/api/console-links/index.ts index 8e02c3c529..32b204769c 100644 --- a/backend/src/routes/api/console-links/index.ts +++ b/backend/src/routes/api/console-links/index.ts @@ -1,22 +1,14 @@ import { KubeFastifyInstance } from '../../../types'; import { FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { listConsoleLinks } from './list'; module.exports = async (fastify: KubeFastifyInstance) => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return listConsoleLinks() .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/docs/index.ts b/backend/src/routes/api/docs/index.ts index 011f358fdc..c31dc3e47c 100644 --- a/backend/src/routes/api/docs/index.ts +++ b/backend/src/routes/api/docs/index.ts @@ -1,21 +1,13 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { listDocs } from './list'; export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return listDocs(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/getting-started/index.ts b/backend/src/routes/api/getting-started/index.ts index dbbabfa250..e245744c66 100644 --- a/backend/src/routes/api/getting-started/index.ts +++ b/backend/src/routes/api/getting-started/index.ts @@ -1,22 +1,14 @@ import { FastifyInstance } from 'fastify'; import { OdhGettingStarted } from '../../../types'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { listGettingStartedDocs } from './list'; export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request, reply) => { return listGettingStartedDocs(request) .then((res: OdhGettingStarted[]) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/notebook/index.ts b/backend/src/routes/api/notebook/index.ts new file mode 100644 index 0000000000..46becb6585 --- /dev/null +++ b/backend/src/routes/api/notebook/index.ts @@ -0,0 +1,60 @@ +import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { + addNotebook, + deleteNotebook, + getNotebook, + getNotebooks, + updateNotebook, +} from './notebooksUtils'; + +export default async (fastify: FastifyInstance): Promise => { + fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { + return getNotebooks(fastify) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }); + + fastify.get('/:notebook', async (request: FastifyRequest, reply: FastifyReply) => { + return getNotebook(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }); + + fastify.delete('/:notebook', async (request: FastifyRequest, reply: FastifyReply) => { + return deleteNotebook(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }); + + fastify.put('/:notebook', async (request: FastifyRequest, reply: FastifyReply) => { + return updateNotebook(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }); + + fastify.post('/', async (request: FastifyRequest, reply: FastifyReply) => { + return addNotebook(fastify, request) + .then((res) => { + return res; + }) + .catch((res) => { + reply.send(res); + }); + }); +}; diff --git a/backend/src/routes/api/notebook/notebooksUtils.ts b/backend/src/routes/api/notebook/notebooksUtils.ts new file mode 100644 index 0000000000..d689070473 --- /dev/null +++ b/backend/src/routes/api/notebook/notebooksUtils.ts @@ -0,0 +1,305 @@ +import { FastifyRequest } from 'fastify'; +import createError from 'http-errors'; +import { + KubeFastifyInstance, + Notebook, + ImageStreamListKind, + ImageStreamKind, + NotebookStatus, + NotebookCreateRequest, + NotebookUpdateRequest, + NotebookPackage, +} from '../../../types'; + +const packagesToString = (packages: NotebookPackage[]): string => { + if (packages.length > 0) { + let packageAsString = '['; + packages.forEach((value, index) => { + packageAsString = packageAsString + JSON.stringify(value); + if (index !== packages.length - 1) { + packageAsString = packageAsString + `,`; + } else { + packageAsString = packageAsString + ']'; + } + }); + return packageAsString; + } + return '[]'; +}; +const mapImageStreamToNotebook = (is: ImageStreamKind): Notebook => ({ + id: is.metadata.name, + name: is.metadata.annotations['opendatahub.io/notebook-image-name'], + description: is.metadata.annotations['opendatahub.io/notebook-image-desc'], + phase: is.metadata.annotations['opendatahub.io/notebook-image-phase'] as NotebookStatus, + visible: is.metadata.labels['opendatahub.io/notebook-image'] === 'true', + error: is.metadata.annotations['opendatahub.io/notebook-image-messages'] + ? JSON.parse(is.metadata.annotations['opendatahub.io/notebook-image-messages']) + : [], + packages: + is.spec.tags && + (JSON.parse( + is.spec.tags[0].annotations['opendatahub.io/notebook-python-dependencies'], + ) as NotebookPackage[]), + software: + is.spec.tags && + (JSON.parse( + is.spec.tags[0].annotations['opendatahub.io/notebook-software'], + ) as NotebookPackage[]), + uploaded: is.metadata.creationTimestamp, + url: is.metadata.annotations['opendatahub.io/notebook-image-url'], + user: is.metadata.annotations['opendatahub.io/notebook-image-creator'], +}); + +export const getNotebooks = async ( + fastify: KubeFastifyInstance, +): Promise<{ notebooks: Notebook[]; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + + try { + const imageStreams = await customObjectsApi + .listNamespacedCustomObject( + 'image.openshift.io', + 'v1', + namespace, + 'imagestreams', + undefined, + undefined, + undefined, + 'app.kubernetes.io/created-by=byon', + ) + .then((r) => r.body as ImageStreamListKind); + + const imageStreamNames = imageStreams.items.map((is) => { + is.metadata.name; + }); + const notebooks: Notebook[] = [...imageStreams.items.map((is) => mapImageStreamToNotebook(is))]; + + return { notebooks: notebooks, error: null }; + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error('Unable to retrieve notebook image(s): ' + e.toString()); + return { notebooks: null, error: 'Unable to retrieve notebook image(s): ' + e.message }; + } + } +}; + +export const getNotebook = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ notebooks: Notebook; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const params = request.params as { notebook: string }; + + try { + const imageStream = await customObjectsApi + .getNamespacedCustomObject( + 'image.openshift.io', + 'v1', + namespace, + 'imagestreams', + params.notebook, + ) + .then((r) => r.body as ImageStreamKind) + .catch((r) => null); + + if (imageStream) { + return { notebooks: mapImageStreamToNotebook(imageStream), error: null }; + } + + throw new createError.NotFound(`Notebook ${params.notebook} does not exist.`); + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error('Unable to retrieve notebook image(s): ' + e.toString()); + return { notebooks: null, error: 'Unable to retrieve notebook image(s): ' + e.message }; + } + } +}; + +export const addNotebook = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ success: boolean; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const body = request.body as NotebookCreateRequest; + const imageTag = body.url.split(':')[1]; + + const notebooks = await getNotebooks(fastify); + const validName = notebooks.notebooks.filter((nb) => nb.name === body.name); + + if (validName.length > 0) { + fastify.log.error('Duplicate name unable to add notebook image'); + return { success: false, error: 'Unable to add notebook image: ' + body.name }; + } + + const payload: ImageStreamKind = { + kind: 'ImageStream', + apiVersion: 'image.openshift.io/v1', + metadata: { + annotations: { + 'opendatahub.io/notebook-image-desc': body.description ? body.description : '', + 'opendatahub.io/notebook-image-name': body.name, + 'opendatahub.io/notebook-image-url': body.url, + 'opendatahub.io/notebook-image-creator': body.user, + 'opendatahub.io/notebook-image-phase': 'Succeeded', + 'opendatahub.io/notebook-image-origin': 'Admin', + 'opendatahub.io/notebook-image-messages': '', + }, + name: `byon-${Date.now()}`, + namespace: namespace, + labels: { + 'app.kubernetes.io/created-by': 'byon', + 'opendatahub.io/notebook-image': 'true', + }, + }, + spec: { + lookupPolicy: { + local: true, + }, + tags: [ + { + annotations: { + 'opendatahub.io/notebook-software': packagesToString(body.software), + 'opendatahub.io/notebook-python-dependencies': packagesToString(body.packages), + 'openshift.io/imported-from': body.url, + }, + from: { + kind: 'DockerImage', + name: body.url, + }, + name: imageTag, + }, + ], + }, + }; + + try { + await customObjectsApi.createNamespacedCustomObject( + 'image.openshift.io', + 'v1', + namespace, + 'imagestreams', + payload, + ); + return { success: true, error: null }; + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error('Unable to add notebook image: ' + e.toString()); + return { success: false, error: 'Unable to add notebook image: ' + e.message }; + } + } +}; + +export const deleteNotebook = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ success: boolean; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const params = request.params as { notebook: string }; + + try { + await customObjectsApi + .deleteNamespacedCustomObject( + 'image.openshift.io', + 'v1', + namespace, + 'imagestreams', + params.notebook, + ) + .catch((e) => { + throw createError(e.statusCode, e?.body?.message); + }); + return { success: true, error: null }; + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error('Unable to delete notebook image: ' + e.toString()); + return { success: false, error: 'Unable to delete notebook image: ' + e.message }; + } + } +}; + +export const updateNotebook = async ( + fastify: KubeFastifyInstance, + request: FastifyRequest, +): Promise<{ success: boolean; error: string }> => { + const customObjectsApi = fastify.kube.customObjectsApi; + const namespace = fastify.kube.namespace; + const params = request.params as { notebook: string }; + const body = request.body as NotebookUpdateRequest; + + const notebooks = await getNotebooks(fastify); + const validName = notebooks.notebooks.filter((nb) => nb.name === body.name && nb.id !== body.id); + + if (validName.length > 0) { + fastify.log.error('Duplicate name unable to add notebook image'); + return { success: false, error: 'Unable to add notebook image: ' + body.name }; + } + + try { + const imageStream = await customObjectsApi + .getNamespacedCustomObject( + 'image.openshift.io', + 'v1', + namespace, + 'imagestreams', + params.notebook, + ) + .then((r) => r.body as ImageStreamKind) + .catch((e) => { + throw createError(e.statusCode, e?.body?.message); + }); + + if (body.packages && imageStream.spec.tags) { + imageStream.spec.tags[0].annotations['opendatahub.io/notebook-python-dependencies'] = + JSON.stringify(body.packages); + } + + if (body.software && imageStream.spec.tags) { + imageStream.spec.tags[0].annotations['opendatahub.io/notebook-software'] = JSON.stringify( + body.software, + ); + } + + if (typeof body.visible !== undefined) { + if (body.visible) { + imageStream.metadata.labels['opendatahub.io/notebook-image'] = 'true'; + } else { + imageStream.metadata.labels['opendatahub.io/notebook-image'] = 'false'; + } + } + if (body.name) { + imageStream.metadata.annotations['opendatahub.io/notebook-image-name'] = body.name; + } + + if (body.description !== undefined) { + imageStream.metadata.annotations['opendatahub.io/notebook-image-desc'] = body.description; + } + + await customObjectsApi + .patchNamespacedCustomObject( + 'image.openshift.io', + 'v1', + namespace, + 'imagestreams', + params.notebook, + imageStream, + undefined, + undefined, + undefined, + { + headers: { 'Content-Type': 'application/merge-patch+json' }, + }, + ) + .catch((e) => console.log(e)); + + return { success: true, error: null }; + } catch (e) { + if (e.response?.statusCode !== 404) { + fastify.log.error('Unable to update notebook image: ' + e.toString()); + return { success: false, error: 'Unable to update notebook image: ' + e.message }; + } + } +}; diff --git a/backend/src/routes/api/quickstarts/index.ts b/backend/src/routes/api/quickstarts/index.ts index 3961abe153..822297b171 100644 --- a/backend/src/routes/api/quickstarts/index.ts +++ b/backend/src/routes/api/quickstarts/index.ts @@ -1,22 +1,14 @@ import { FastifyReply, FastifyRequest } from 'fastify'; import { KubeFastifyInstance } from '../../../types'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { getInstalledQuickStarts } from './quickStartUtils'; export default async (fastify: KubeFastifyInstance): Promise => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return getInstalledQuickStarts(fastify) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/segment-key/index.ts b/backend/src/routes/api/segment-key/index.ts index 44edc658b1..c733fa5f61 100644 --- a/backend/src/routes/api/segment-key/index.ts +++ b/backend/src/routes/api/segment-key/index.ts @@ -1,21 +1,13 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { getSegmentKey } from './segmentKeyUtils'; export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return getSegmentKey(fastify) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/status/index.ts b/backend/src/routes/api/status/index.ts index 78d4e271ba..6df076c584 100644 --- a/backend/src/routes/api/status/index.ts +++ b/backend/src/routes/api/status/index.ts @@ -1,8 +1,6 @@ import createError from 'http-errors'; import { FastifyInstance, FastifyRequest } from 'fastify'; import { KubeFastifyInstance, KubeStatus } from '../../../types'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; type groupObjResponse = { users: string[]; @@ -68,16 +66,9 @@ export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request, reply) => { return status(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - console.log(`ERROR: devMode: ${DEV_MODE}`); - if (DEV_MODE) { - addCORSHeader(request, reply); - } reply.send(res); }); }); diff --git a/backend/src/routes/api/validate-isv/index.ts b/backend/src/routes/api/validate-isv/index.ts index 9d937125f6..9620e370d3 100644 --- a/backend/src/routes/api/validate-isv/index.ts +++ b/backend/src/routes/api/validate-isv/index.ts @@ -1,21 +1,13 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; -import { addCORSHeader } from '../../../utils/responseUtils'; import { getValidateISVResults, validateISV } from './validateISV'; export default async (fastify: FastifyInstance): Promise => { fastify.get('/', async (request: FastifyRequest, reply: FastifyReply) => { return validateISV(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } fastify.log.error(`Failed to create validation job: ${res.response?.body?.message}`); reply.send(res); }); @@ -23,15 +15,9 @@ export default async (fastify: FastifyInstance): Promise => { fastify.get('/results', async (request: FastifyRequest, reply: FastifyReply) => { return getValidateISVResults(fastify, request) .then((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } return res; }) .catch((res) => { - if (DEV_MODE) { - addCORSHeader(request, reply); - } fastify.log.error(`Failed to get validation job results: ${res.response?.body?.message}`); reply.send(res); }); diff --git a/backend/src/types.ts b/backend/src/types.ts index 85be2e4e1e..298fc6f95e 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -11,7 +11,7 @@ export type DashboardConfig = { export type ClusterSettings = { pvcSize: number; - userTrackingEnabled: boolean + userTrackingEnabled: boolean; } // Add a minimal QuickStart type here as there is no way to get types without pulling in frontend (React) modules @@ -33,17 +33,23 @@ export declare type QuickStart = { }; // Properties common to (almost) all Kubernetes resources. -export type K8sResourceCommon = { +export type K8sResourceBase = { apiVersion?: string; kind?: string; +} + +export type K8sResourceCommon = { metadata?: { name?: string; namespace?: string; + generateName?: string; uid?: string; labels?: { [key: string]: string }; annotations?: { [key: string]: string }; + creationTimestamp?: Date; }; -}; +} & K8sResourceBase; + export enum BUILD_PHASE { none = 'Not started', @@ -227,3 +233,97 @@ export type BuildStatus = { export type ODHSegmentKey = { segmentKey: string; }; + +export type NotebookError = { + severity: string; + message: string; +} + +export type NotebookStatus = "Importing" | "Validating" | "Succeeded" | "Failed"; + +export type Notebook = { + id: string; + phase?: NotebookStatus; + user?: string; + uploaded?: Date; + error?: NotebookError; +} & NotebookCreateRequest & NotebookUpdateRequest; + +export type NotebookCreateRequest = { + name: string; + url: string; + description?: string; + // FIXME: This shouldn't be a user defined value consumed from the request payload but should be a controlled value from an authentication middleware. + user: string; + software?: NotebookPackage[]; + packages?: NotebookPackage[]; +} + +export type NotebookUpdateRequest = { + id: string; + name?: string; + description?: string; + visible?: boolean; + software?: NotebookPackage[]; + packages?: NotebookPackage[]; +} + +export type NotebookPackage = { + name: string; + version: string; + visible: boolean; +} + + +export type ImageStreamTagSpec = { + name: string; + annotations?: { [key: string]: string }; + from?: { + kind: string; + name: string; + } +} +export type ImageStreamKind = { + spec?: { + lookupPolicy?: { + local: boolean + } + tags: ImageStreamTagSpec[]; + } + status?: any +} & K8sResourceCommon; + +export type ImageStreamListKind = { + items: ImageStreamKind[]; +} & K8sResourceBase; + +export type PipelineRunKind = { + spec: { + params: { + name: string; + value: string; + }[] + pipelineRef: { + name: string; + } + workspaces?: [ + { + name: string + volumeClaimTemplate: { + spec: { + accessModes: string[] + resources: { + requests: { + storage: string + } + } + } + } + } + ] + } +} & K8sResourceCommon; + +export type PipelineRunListKind = { + items: PipelineRunKind[]; +} & K8sResourceBase; diff --git a/backend/src/utils/responseUtils.ts b/backend/src/utils/responseUtils.ts deleted file mode 100644 index d52ae1d37b..0000000000 --- a/backend/src/utils/responseUtils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { FastifyReply, FastifyRequest } from 'fastify'; - -export const addCORSHeader = (req: FastifyRequest, reply: FastifyReply): void => { - const request = req.raw; - const origin = request && request.headers && request.headers.origin; - const hasOrigin = !!origin; - const originHeader = Array.isArray(origin) ? origin[0] : origin || '*'; - - // based on https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin - if (origin) { - reply.header('Access-Control-Allow-Origin', originHeader); - reply.header('Vary', 'Origin'); - } - reply.header('Access-Control-Allow-Credentials', (!hasOrigin).toString()); - reply.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - - const requestHeaders = reply.getHeader('access-control-request-headers'); - if (requestHeaders != null) { - reply.header( - 'Access-Control-Allow-Headers', - Array.isArray(requestHeaders) ? requestHeaders.join(', ') : requestHeaders, - ); - } -}; diff --git a/frontend/config/dotenv.js b/frontend/config/dotenv.js index 4bd89a0bc8..a19cb6d8d8 100644 --- a/frontend/config/dotenv.js +++ b/frontend/config/dotenv.js @@ -144,6 +144,7 @@ const setupDotenvFilesForEnv = ({ env }) => { const DIST_DIR = path.resolve(RELATIVE_DIRNAME, process.env.ODH_DIST_DIR || TS_OUT_DIR || 'public'); const HOST = process.env.ODH_HOST || 'localhost'; const PORT = process.env.ODH_PORT || '3000'; + const BACKEND_PORT = process.env.PORT || process.env.BACKEND_PORT || 8080; const DEV_MODE = process.env.ODH_DEV_MODE || undefined; const OUTPUT_ONLY = process.env._ODH_OUTPUT_ONLY === 'true'; @@ -158,6 +159,7 @@ const setupDotenvFilesForEnv = ({ env }) => { process.env._ODH_PORT = PORT; process.env._ODH_OUTPUT_ONLY = OUTPUT_ONLY; process.env._ODH_DEV_MODE = DEV_MODE; + process.env._BACKEND_PORT = BACKEND_PORT; }; module.exports = { setupWebpackDotenvFilesForEnv, setupDotenvFilesForEnv }; diff --git a/frontend/config/webpack.dev.js b/frontend/config/webpack.dev.js index 814017add3..331976fa52 100644 --- a/frontend/config/webpack.dev.js +++ b/frontend/config/webpack.dev.js @@ -12,6 +12,7 @@ const COMMON_DIR = process.env._ODH_COMMON_DIR; const DIST_DIR = process.env._ODH_DIST_DIR; const HOST = process.env._ODH_HOST; const PORT = process.env._ODH_PORT; +const BACKEND_PORT = process.env._BACKEND_PORT; module.exports = merge( { @@ -33,7 +34,10 @@ module.exports = merge( hot: true, overlay: true, open: true, - stats: 'errors-only' + stats: 'errors-only', + proxy: { + '/api': `http://localhost:${BACKEND_PORT}`, + }, }, module: { rules: [ @@ -59,4 +63,4 @@ module.exports = merge( ] } } -); +); \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fb15041faa..075c3a5538 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2048,6 +2048,50 @@ "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.48.5.tgz", "integrity": "sha512-kXY2piAInTuv1PxuQbSuDYJ+ugIsEs9MnwWqZOMARGrcsElbRLn+vtSxShx7AQeJ9V9e0v64gdgP7pIJqisd1Q==" }, + "@patternfly/react-table": { + "version": "4.67.19", + "resolved": "https://registry.npmjs.org/@patternfly/react-table/-/react-table-4.67.19.tgz", + "integrity": "sha512-pAa0tpafLHtICCiM3TDQ89xqQTvkZtRuwJ6+KKSpN1UdEEHy+3j0JjDUcslN+6Lo7stgoLwgWzGmE7bsx4Ys5Q==", + "requires": { + "@patternfly/react-core": "^4.198.19", + "@patternfly/react-icons": "^4.49.19", + "@patternfly/react-styles": "^4.48.19", + "@patternfly/react-tokens": "^4.50.19", + "lodash": "^4.17.19", + "tslib": "^2.0.0" + }, + "dependencies": { + "@patternfly/react-core": { + "version": "4.198.19", + "resolved": "https://registry.npmjs.org/@patternfly/react-core/-/react-core-4.198.19.tgz", + "integrity": "sha512-f46CIKwWCJ1UNL50TXnvarYUhr2KtxNFw/kGYtG6QwrQwKXscZiXMMtW//0Q08cyhLB0vfxHOLbCKxVaVJ3R3w==", + "requires": { + "@patternfly/react-icons": "^4.49.19", + "@patternfly/react-styles": "^4.48.19", + "@patternfly/react-tokens": "^4.50.19", + "focus-trap": "6.2.2", + "react-dropzone": "9.0.0", + "tippy.js": "5.1.2", + "tslib": "^2.0.0" + } + }, + "@patternfly/react-icons": { + "version": "4.49.19", + "resolved": "https://registry.npmjs.org/@patternfly/react-icons/-/react-icons-4.49.19.tgz", + "integrity": "sha512-Pr6JDDKWOnWChkifXKWglKEPo3Q+1CgiUTUrvk4ZbnD7mhq5e/TFxxInB9CPzi278bvnc2YlPyTjpaAcCN0yGw==" + }, + "@patternfly/react-styles": { + "version": "4.48.19", + "resolved": "https://registry.npmjs.org/@patternfly/react-styles/-/react-styles-4.48.19.tgz", + "integrity": "sha512-8+t8wqYGWkmyhxLty/kQXCY44rnW0y60nUMG7QKNzF1bAFJIpR8jKuVnHArM1h+MI9D53e8OVjKORH83hUAzJw==" + }, + "@patternfly/react-tokens": { + "version": "4.50.19", + "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.50.19.tgz", + "integrity": "sha512-wbUPb8welJ8p+OjXrc0X3UYDj5JjN9xnfpYkZdAySpcFtk0BAn5Py6UEZCjKtw7XHHfCQ1zwKXpXDShcu/5KVQ==" + } + } + }, "@patternfly/react-tokens": { "version": "4.50.5", "resolved": "https://registry.npmjs.org/@patternfly/react-tokens/-/react-tokens-4.50.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index aaa344ce0a..107cecdf06 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -43,6 +43,7 @@ "@patternfly/react-core": "4.198.5", "@patternfly/react-icons": "4.49.5", "@patternfly/react-styles": "4.48.5", + "@patternfly/react-table": "4.67.19", "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.0.4", "@testing-library/user-event": "^12.1.7", diff --git a/frontend/src/app/Routes.tsx b/frontend/src/app/Routes.tsx index ee06e8dee4..8ecda32832 100644 --- a/frontend/src/app/Routes.tsx +++ b/frontend/src/app/Routes.tsx @@ -13,6 +13,7 @@ const ExploreApplications = React.lazy( const ClusterSettingsPage = React.lazy(() => import('../pages/clusterSettings/ClusterSettings')); const LearningCenterPage = React.lazy(() => import('../pages/learningCenter/LearningCenter')); +const NotebookImagesPage = React.lazy(() => import('../pages/notebookImages/NotebookImages')); const NotFound = React.lazy(() => import('../pages/NotFound')); const Routes: React.FC = () => { @@ -26,6 +27,7 @@ const Routes: React.FC = () => { + {isAdmin && } {isAdmin && } diff --git a/frontend/src/pages/ApplicationsPage.tsx b/frontend/src/pages/ApplicationsPage.tsx index 7ebcf603a0..7a801ee8ba 100644 --- a/frontend/src/pages/ApplicationsPage.tsx +++ b/frontend/src/pages/ApplicationsPage.tsx @@ -23,6 +23,7 @@ type ApplicationsPageProps = { loadError?: Error; errorMessage?: string; emptyMessage?: string; + emptyStatePage?: React.ReactNode; }; const ApplicationsPage: React.FC = ({ @@ -34,6 +35,7 @@ const ApplicationsPage: React.FC = ({ children, errorMessage, emptyMessage, + emptyStatePage, }) => { const renderHeader = () => ( @@ -77,7 +79,7 @@ const ApplicationsPage: React.FC = ({ } if (empty) { - return ( + return !emptyStatePage ? ( @@ -86,6 +88,8 @@ const ApplicationsPage: React.FC = ({ + ) : ( + emptyStatePage ); } diff --git a/frontend/src/pages/clusterSettings/ClusterSettings.tsx b/frontend/src/pages/clusterSettings/ClusterSettings.tsx index 55c61c4292..3714b48619 100644 --- a/frontend/src/pages/clusterSettings/ClusterSettings.tsx +++ b/frontend/src/pages/clusterSettings/ClusterSettings.tsx @@ -23,6 +23,7 @@ import { ClusterSettings } from '../../types'; import { useDispatch } from 'react-redux'; import { addNotification } from '../../redux/actions/actions'; import './ClusterSettings.scss'; +import { ExclamationCircleIcon } from '@patternfly/react-icons'; const description = `Update global settings for all users.`; @@ -40,7 +41,7 @@ const ClusterSettings: React.FC = () => { const [loaded, setLoaded] = React.useState(false); const [loadError, setLoadError] = React.useState(); const [clusterSettings, setClusterSettings] = React.useState(DEFAULT_CONFIG); - const [pvcSize, setPvcSize] = React.useState(DEFAULT_PVC_SIZE); + const [pvcSize, setPvcSize] = React.useState(DEFAULT_PVC_SIZE); const [userTrackingEnabled, setUserTrackingEnabled] = React.useState(DEFAULT_USER_TRACKING); const pvcDefaultBtnRef = React.useRef(); @@ -53,6 +54,7 @@ const ClusterSettings: React.FC = () => { setLoadError(undefined); setClusterSettings(clusterSettings); setPvcSize(clusterSettings.pvcSize); + setUserTrackingEnabled(clusterSettings.userTrackingEnabled); }) .catch((e) => { setLoadError(e); @@ -61,31 +63,33 @@ const ClusterSettings: React.FC = () => { const submitClusterSettings = (newClusterSettings: ClusterSettings) => { if (!_.isEqual(clusterSettings, newClusterSettings)) { - updateClusterSettings(newClusterSettings) - .then((response) => { - if (response.success) { - setClusterSettings(newClusterSettings); + if (Number(newClusterSettings?.pvcSize) !== 0) { + updateClusterSettings(newClusterSettings) + .then((response) => { + if (response.success) { + setClusterSettings(newClusterSettings); + dispatch( + addNotification({ + status: 'success', + title: 'Cluster settings updated successfully.', + timestamp: new Date(), + }), + ); + } else { + throw new Error(response.error); + } + }) + .catch((e) => { dispatch( addNotification({ - status: 'success', - title: 'Cluster settings updated successfully.', + status: 'danger', + title: 'Error', + message: e.message, timestamp: new Date(), }), ); - } else { - throw new Error(response.error); - } - }) - .catch((e) => { - dispatch( - addNotification({ - status: 'danger', - title: 'Error', - message: e.message, - timestamp: new Date(), - }), - ); - }); + }); + } } }; @@ -113,7 +117,10 @@ const ClusterSettings: React.FC = () => { } + validated={pvcSize !== '' ? 'success' : 'error'} > Changing the PVC size changes the storage size attached to the new notebook @@ -127,22 +134,31 @@ const ClusterSettings: React.FC = () => { type="text" aria-label="PVC Size Input" value={pvcSize} - pattern="[0-9]+" - onBlur={() => submitClusterSettings({ pvcSize, userTrackingEnabled })} + pattern="/^(\s*|\d+)$/" + onBlur={() => { + submitClusterSettings({ pvcSize: Number(pvcSize), userTrackingEnabled }); + }} onKeyPress={(event) => { if (event.key === 'Enter') { if (pvcDefaultBtnRef.current) pvcDefaultBtnRef.current.focus(); } }} onChange={async (value: string) => { - let newValue = isNaN(Number(value)) ? pvcSize : Number(value); - newValue = - newValue > MAX_PVC_SIZE - ? MAX_PVC_SIZE - : newValue < MIN_PVC_SIZE - ? MIN_PVC_SIZE - : newValue; - setPvcSize(newValue); + const modifiedValue = value.replace(/ /g, ''); + if (modifiedValue !== '') { + let newValue = Number.isInteger(Number(modifiedValue)) + ? Number(modifiedValue) + : pvcSize; + newValue = + newValue > MAX_PVC_SIZE + ? MAX_PVC_SIZE + : newValue < MIN_PVC_SIZE + ? MIN_PVC_SIZE + : newValue; + setPvcSize(newValue); + } else { + setPvcSize(modifiedValue); + } }} /> @@ -153,11 +169,11 @@ const ClusterSettings: React.FC = () => { innerRef={pvcDefaultBtnRef} variant={ButtonVariant.secondary} onClick={() => { - setPvcSize(DEFAULT_PVC_SIZE); submitClusterSettings({ pvcSize: DEFAULT_PVC_SIZE, userTrackingEnabled }); + setPvcSize(DEFAULT_PVC_SIZE); }} > - Restore Defaults + Restore Default { > { + submitClusterSettings({ + pvcSize: Number(pvcSize), + userTrackingEnabled: !userTrackingEnabled, + }); setUserTrackingEnabled(!userTrackingEnabled); - submitClusterSettings({ pvcSize, userTrackingEnabled }); }} aria-label="usageData" id="usage-data-checkbox" diff --git a/frontend/src/pages/notebookImages/DeleteImageModal.tsx b/frontend/src/pages/notebookImages/DeleteImageModal.tsx new file mode 100644 index 0000000000..30fe161e68 --- /dev/null +++ b/frontend/src/pages/notebookImages/DeleteImageModal.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Button, Modal, ModalVariant } from '@patternfly/react-core'; +import { deleteNotebook } from '../../services/notebookImageService'; +import { Notebook } from 'types'; +export type ImportImageModalProps = { + isOpen: boolean; + notebook: Notebook; + onDeleteHandler: () => void; + onCloseHandler: () => void; +}; +export const DeleteImageModal: React.FC = ({ + isOpen, + notebook, + onDeleteHandler, + onCloseHandler, +}) => { + return ( + { + if (notebook) { + deleteNotebook(notebook).then(() => { + onDeleteHandler(); + onCloseHandler(); + }); + } + }} + > + Delete + , + , + ]} + > + Do you wish to permanently delete {notebook?.name}? + + ); +}; + +export default DeleteImageModal; diff --git a/frontend/src/pages/notebookImages/EditStepTableRow.tsx b/frontend/src/pages/notebookImages/EditStepTableRow.tsx new file mode 100644 index 0000000000..579e562255 --- /dev/null +++ b/frontend/src/pages/notebookImages/EditStepTableRow.tsx @@ -0,0 +1,105 @@ +import * as React from 'react'; +import { Tr, Td } from '@patternfly/react-table'; +import { Button, TextInput } from '@patternfly/react-core'; +import { PencilAltIcon, TimesIcon, CheckIcon, MinusCircleIcon } from '@patternfly/react-icons'; +import { NotebookPackage } from '../../types'; + +interface EditStepTableRowProps { + notebookPackage: NotebookPackage; + setEditedValues: (values: NotebookPackage) => void; + onDeleteHandler: () => void; +} + +export const EditStepTableRow: React.FunctionComponent = ({ + notebookPackage, + setEditedValues, + onDeleteHandler, +}) => { + const [modifiedValue, setModifiedValue] = React.useState(notebookPackage); + const [isEditMode, setIsEditMode] = React.useState(false); + + return ( + + + {isEditMode ? ( + { + setModifiedValue({ + name: value, + version: modifiedValue.version, + visible: modifiedValue.visible, + }); + }} + /> + ) : ( + notebookPackage.name + )} + + + {isEditMode ? ( + { + setModifiedValue({ + name: modifiedValue.name, + version: value, + visible: modifiedValue.visible, + }); + }} + /> + ) : ( + notebookPackage.version + )} + + + {!isEditMode ? ( + , + , + ]} + > +
{ + e.preventDefault(); + }} + > + } + validated={validRepo ? undefined : 'error'} + > + { + setRepository(value); + }} + /> + + } + validated={validName ? undefined : 'error'} + > + { + setName(value); + }} + /> + + + { + setDescription(value); + }} + /> + + + { + setActiveTabKey(indexKey as number); + }} + > + Software}> + {software.length > 0 ? ( + <> + + + Add the advertised software shown with this notebook image. Modifying the + software here does not effect the contents of the notebook image. + + + + Software + Version + + + + + {software.map((value, currentIndex) => ( + { + const updatedPackages = [...software]; + updatedPackages[currentIndex] = values; + setSoftware(updatedPackages); + }} + onDeleteHandler={() => { + setSoftware(software.filter((_value, index) => index !== currentIndex)); + }} + /> + ))} + + + + + ) : ( + + + + No software added + + + Add software to be advertised with your notebook image. Making changes here + won’t affect the contents of the image.{' '} + + + + )} + + Packages}> + {packages.length > 0 ? ( + <> + + + Add the advertised packages shown with this notebook image. Modifying the + packages here does not effect the contents of the notebook image. + + + + Package + Version + + + + + {packages.map((value, currentIndex) => ( + { + const updatedPackages = [...packages]; + updatedPackages[currentIndex] = values; + setPackages(updatedPackages); + }} + onDeleteHandler={() => { + setPackages(packages.filter((_value, index) => index !== currentIndex)); + }} + /> + ))} + + + + + ) : ( + + + + No packages added + + + Add packages to be advertised with your notebook image. Making changes here + won’t affect the contents of the image.{' '} + + + + )} + + + +
+ + ); +}; + +export default ImportImageModal; diff --git a/frontend/src/pages/notebookImages/NotebookImages.tsx b/frontend/src/pages/notebookImages/NotebookImages.tsx new file mode 100644 index 0000000000..46adf6b791 --- /dev/null +++ b/frontend/src/pages/notebookImages/NotebookImages.tsx @@ -0,0 +1,82 @@ +import * as React from 'react'; +import { + Button, + ButtonVariant, + Flex, + FlexItem, + EmptyState, + EmptyStateIcon, + EmptyStateVariant, + EmptyStateBody, + PageSection, + PageSectionVariants, + Title, +} from '@patternfly/react-core'; +import ApplicationsPage from '../ApplicationsPage'; +import { useWatchNotebookImages } from '../../utilities/useWatchNotebookImages'; +import { PlusCircleIcon } from '@patternfly/react-icons'; +import { ImportImageModal } from './ImportImageModal'; +import { NotebookImagesTable } from './NotebookImagesTable'; + +const description = `Import, delete, and modify notebook images.`; + +const NotebookImages: React.FC = () => { + const [importImageModalVisible, setImportImageModalVisible] = React.useState(false); + + const { notebooks, loaded, loadError, forceUpdate } = useWatchNotebookImages(); + const isEmpty = !notebooks || notebooks.length === 0; + + const noNotebooksPageSection = ( + + + + + No custom notebook images found. + + To get started import a custom notebook image. + + + { + setImportImageModalVisible(false); + }} + onImportHandler={forceUpdate} + /> + + ); + + return ( + + {!isEmpty ? ( +
+ + + + {' '} + + + + +
+ ) : null} +
+ ); +}; + +export default NotebookImages; diff --git a/frontend/src/pages/notebookImages/NotebookImagesTable.scss b/frontend/src/pages/notebookImages/NotebookImagesTable.scss new file mode 100644 index 0000000000..461fa99341 --- /dev/null +++ b/frontend/src/pages/notebookImages/NotebookImagesTable.scss @@ -0,0 +1,29 @@ +.filter-select { + margin-right: 0px; +} +.filter-search { + margin-right: var(--pf-global--spacer--md); +} +.included-packages { + margin-left: var(--pf-global--spacer--2xl); +} +.included-packages-font { + color: var(--pf-global--Color--200); +} +.phase-success { + color: var(--pf-global--success-color--100); +} +.phase-failed { + color: var(--pf-global--warning-color--100); +} +.phase-failed-cursor{ + cursor: pointer; +} +.enable-switch { + size: 75% +} + +.empty-table { + margin-left: var(--pf-global--spacer--2xl); + margin-right: var(--pf-global--spacer--2xl); +} \ No newline at end of file diff --git a/frontend/src/pages/notebookImages/NotebookImagesTable.tsx b/frontend/src/pages/notebookImages/NotebookImagesTable.tsx new file mode 100644 index 0000000000..d06b9237dd --- /dev/null +++ b/frontend/src/pages/notebookImages/NotebookImagesTable.tsx @@ -0,0 +1,422 @@ +import React from 'react'; +import { + Button, + Bullseye, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Flex, + FlexItem, + Select, + SelectOption, + SelectVariant, + SearchInput, + Switch, + Title, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; +import { + ActionsColumn, + TableComposable, + Thead, + Tr, + Th, + ThProps, + Tbody, + Td, + ExpandableRowContent, + IAction, +} from '@patternfly/react-table'; +import { CubesIcon, SearchIcon } from '@patternfly/react-icons'; +import { Notebook } from 'types'; +import { ImportImageModal } from './ImportImageModal'; +import { relativeTime } from '../../utilities/time'; +import './NotebookImagesTable.scss'; +import { DeleteImageModal } from './DeleteImageModal'; +import { UpdateImageModal } from './UpdateImageModal'; +import { updateNotebook } from '../../services/notebookImageService'; + +export type NotebookImagesTableProps = { + notebooks: Notebook[]; + forceUpdate: () => void; +}; + +type NotebookEnabled = { + id: string; + visible?: boolean; +}; + +type NotebookTableFilterOptions = 'user' | 'name' | 'description' | 'phase' | 'user' | 'uploaded'; +type NotebookTableFilter = { + filter: string; + option: NotebookTableFilterOptions; + count: number; +}; + +export const NotebookImagesTable: React.FC = ({ + notebooks, + forceUpdate, +}) => { + const rowActions = (notebook: Notebook): IAction[] => [ + { + title: 'Edit', + id: `${notebook.name}-edit-button`, + onClick: () => { + setCurrentNotebook(notebook); + setUpdateImageModalVisible(true); + }, + }, + { + isSeparator: true, + }, + { + title: 'Delete', + id: `${notebook.name}-delete-button`, + onClick: () => { + setCurrentNotebook(notebook); + setDeleteImageModalVisible(true); + }, + }, + ]; + + React.useEffect(() => { + setNotebookVisible( + notebooks.map((notebook) => { + return { id: notebook.id, visible: notebook.visible }; + }), + ); + }, [notebooks]); + + const [currentNotebook, setCurrentNotebook] = React.useState(notebooks[0]); + const [deleteImageModalVisible, setDeleteImageModalVisible] = React.useState(false); + const [importImageModalVisible, setImportImageModalVisible] = React.useState(false); + const [updateImageModalVisible, setUpdateImageModalVisible] = React.useState(false); + + const [activeSortIndex, setActiveSortIndex] = React.useState(0); + const [activeSortDirection, setActiveSortDirection] = React.useState<'asc' | 'desc' | undefined>( + 'asc', + ); + + const getFilterCount = (value: string, option): number => { + let total = 0; + notebooks.forEach((notebook) => { + (notebook[option] as string).includes(value) ? total++ : null; + }); + return total; + }; + + const getSortableRowValues = (nb: Notebook): string[] => { + const { name, description = '', phase = '', visible = false, user = '', uploaded = '' } = nb; + return [name, description, phase, visible.toString(), user, uploaded.toString()]; + }; + + if (activeSortIndex !== undefined) { + notebooks.sort((a, b) => { + const aValue = getSortableRowValues(a)[activeSortIndex]; + const bValue = getSortableRowValues(b)[activeSortIndex]; + + if (activeSortDirection === 'asc') { + return (aValue as string).localeCompare(bValue as string); + } + return (bValue as string).localeCompare(aValue as string); + }); + } + + const getSortParams = (columnIndex: number): ThProps['sort'] => ({ + sortBy: { + index: activeSortIndex, + direction: activeSortDirection, + defaultDirection: 'asc', + }, + onSort: (_event, index, direction) => { + setActiveSortIndex(index); + setActiveSortDirection(direction); + }, + columnIndex, + }); + + const columnNames = { + name: 'Name', + description: 'Description', + user: 'User', + uploaded: 'Uploaded', + }; + + const currentTimeStamp: number = Date.now(); + + const [expandedNotebookIDs, setExpandedNotebookIDs] = React.useState([]); + const setNotebookExpanded = (notebook: Notebook, isExpanding = true) => { + setExpandedNotebookIDs((prevExpanded) => { + const otherExpandedRepoNames = prevExpanded.filter((r) => r !== notebook.id); + return isExpanding ? [...otherExpandedRepoNames, notebook.id] : otherExpandedRepoNames; + }); + }; + const isNotebookExpanded = (notebook: Notebook) => { + return expandedNotebookIDs.includes(notebook.id); + }; + const [notebookVisible, setNotebookVisible] = React.useState( + notebooks.map((notebook) => { + return { id: notebook.id, visible: notebook.visible }; + }), + ); + + const selectOptions = [ + + Name + , + + Description + , + + User + , + + Uploaded + , + ]; + const [tableFilter, setTableFilter] = React.useState({ + filter: '', + option: 'name', + count: notebooks.length, + }); + const [selected, setSelected] = React.useState('name'); + const [tableSelectIsOpen, setTableSelectIsOpen] = React.useState(false); + + const items = ( + + + + + + { + const newCount = getFilterCount(value, tableFilter.option); + setTableFilter({ + filter: value, + option: tableFilter.option, + count: newCount, + }); + }} + onClear={() => { + setTableFilter({ + filter: '', + option: tableFilter.option, + count: notebooks.length, + }); + }} + /> + + + + + + ); + + const applyTableFilter = (notebook: Notebook): boolean => { + if ( + tableFilter.filter !== '' && + notebook[tableFilter.option] && + tableFilter.option !== 'uploaded' + ) { + const notebookValue: string = notebook[tableFilter.option] as string; + return !notebookValue.includes(tableFilter.filter); + } + if ( + tableFilter.filter !== '' && + notebook[tableFilter.option] && + tableFilter.option === 'uploaded' + ) { + const notebookValue: string = relativeTime( + currentTimeStamp, + new Date(notebook.uploaded as Date).getTime(), + ); + return !notebookValue.includes(tableFilter.filter); + } + return false; + }; + return ( + + { + setDeleteImageModalVisible(false); + }} + /> + { + setImportImageModalVisible(false); + }} + onImportHandler={forceUpdate} + /> + { + setUpdateImageModalVisible(false); + }} + /> + + {items} + + + + + + {columnNames.name} + {columnNames.description} + Enable + {columnNames.user} + {columnNames.uploaded} + + + + {tableFilter.count > 0 ? ( + notebooks.map((notebook, rowIndex) => { + const packages: any = []; + notebook.packages?.forEach((nbpackage) => { + packages.push(

{`${nbpackage.name} ${nbpackage.version}`}

); + }); + return ( + + + setNotebookExpanded(notebook, !isNotebookExpanded(notebook)), + }} + /> + {notebook.name} + {notebook.description} + + { + return notebook.id === value.id; + })?.visible + } + onChange={() => { + updateNotebook({ + id: notebook.id, + visible: !notebook.visible, + packages: notebook.packages, + }); + setNotebookVisible( + notebookVisible.map((value) => + notebook.id === value.id + ? { id: value.id, visible: !value.visible } + : value, + ), + ); + }} + /> + + {notebook.user} + + {relativeTime(currentTimeStamp, new Date(notebook.uploaded as Date).getTime())} + + + + + + + + {packages.length > 0 ? ( + + + Packages Include + {packages} + + + ) : ( + + + + No packages detected + + Edit the image to add packages + + )} + + + + ); + }) + ) : ( + + + + + + + + No results found + + Clear all filters and try again. + + + + + + + )} +
+
+ ); +}; diff --git a/frontend/src/pages/notebookImages/UpdateImageModal.scss b/frontend/src/pages/notebookImages/UpdateImageModal.scss new file mode 100644 index 0000000000..f70d594109 --- /dev/null +++ b/frontend/src/pages/notebookImages/UpdateImageModal.scss @@ -0,0 +1,3 @@ +.empty-button { + margin-top: var(--pf-global--spacer--lg); +} \ No newline at end of file diff --git a/frontend/src/pages/notebookImages/UpdateImageModal.tsx b/frontend/src/pages/notebookImages/UpdateImageModal.tsx new file mode 100644 index 0000000000..8f7cd64695 --- /dev/null +++ b/frontend/src/pages/notebookImages/UpdateImageModal.tsx @@ -0,0 +1,320 @@ +import React from 'react'; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + EmptyStateVariant, + Form, + FormGroup, + Tab, + Tabs, + TabTitleText, + TextInput, + Title, + Modal, + ModalVariant, +} from '@patternfly/react-core'; +import { Caption, TableComposable, Tbody, Thead, Th, Tr } from '@patternfly/react-table'; +import { CubesIcon, ExclamationCircleIcon, PlusCircleIcon } from '@patternfly/react-icons'; +import { updateNotebook } from '../../services/notebookImageService'; +import { EditStepTableRow } from './EditStepTableRow'; +import { Notebook, NotebookPackage } from 'types'; +import './UpdateImageModal.scss'; +import { useDispatch } from 'react-redux'; +import { addNotification } from '../../redux/actions/actions'; + +export type UpdateImageModalProps = { + isOpen: boolean; + notebook: Notebook; + onCloseHandler: () => void; + onUpdateHandler(); +}; +export const UpdateImageModal: React.FC = ({ + isOpen, + notebook, + onUpdateHandler, + onCloseHandler, +}) => { + const [name, setName] = React.useState(notebook.name); + const [description, setDescription] = React.useState( + notebook.description != undefined ? notebook.description : '', + ); + const [packages, setPackages] = React.useState( + notebook.packages != undefined ? notebook.packages : [], + ); + const [software, setSoftware] = React.useState( + notebook.software != undefined ? notebook.software : [], + ); + const [activeTabKey, setActiveTabKey] = React.useState(0); + const [validName, setValidName] = React.useState(true); + const dispatch = useDispatch(); + + React.useEffect(() => { + if (isOpen === true) { + setName(notebook.name); + setDescription(notebook.description != undefined ? notebook.description : ''); + setPackages(notebook.packages != undefined ? notebook.packages : []); + setSoftware(notebook.software != undefined ? notebook.software : []); + setValidName(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + return ( + { + if (name.length > 0) { + updateNotebook({ + id: notebook.id, + name: name, + description: description, + packages: packages, + software: software, + }).then((value) => { + if (value.success === false) { + dispatch( + addNotification({ + status: 'danger', + title: 'Error', + message: `Unable to update notebook image ${name}`, + timestamp: new Date(), + }), + ); + } + onUpdateHandler(); + onCloseHandler(); + }); + } else { + name.length > 0 ? setValidName(true) : setValidName(false); + } + }} + > + Save Changes + , + , + ]} + > +
{ + e.preventDefault(); + }} + > + } + validated={validName ? undefined : 'error'} + > + { + setName(value); + }} + /> + + + { + setDescription(value); + }} + /> + + + { + setActiveTabKey(indexKey as number); + }} + > + Software}> + {software.length > 0 ? ( + <> + + + Change the advertised software shown with this notebook image. Modifying the + software here does not effect the contents of the notebook image. + + + + Software + Version + + + + + {software.map((value, currentIndex) => ( + { + const updatedPackages = [...software]; + updatedPackages[currentIndex] = values; + setSoftware(updatedPackages); + }} + onDeleteHandler={() => { + setSoftware(software.filter((_value, index) => index !== currentIndex)); + }} + /> + ))} + + + + + ) : ( + + + + No software added + + + Add software to be advertised with your notebook image. Making changes here + won’t affect the contents of the image.{' '} + + + + )} + + Packages}> + {packages.length > 0 ? ( + <> + + + Change the advertised packages shown with this notebook image. Modifying the + packages here does not effect the contents of the notebook image. + + + + Package + Version + + + + + {packages.map((value, currentIndex) => ( + { + const updatedPackages = [...packages]; + updatedPackages[currentIndex] = values; + setPackages(updatedPackages); + }} + onDeleteHandler={() => { + setPackages(packages.filter((_value, index) => index !== currentIndex)); + }} + /> + ))} + + + + + ) : ( + + + + No packages added + + + Add packages to be advertised with your notebook image. Making changes here + won’t affect the contents of the image.{' '} + + + + )} + + + +
+
+ ); +}; + +export default UpdateImageModal; diff --git a/frontend/src/redux/actions/actions.ts b/frontend/src/redux/actions/actions.ts index 3aa9b4e6d4..20c0f48fe1 100644 --- a/frontend/src/redux/actions/actions.ts +++ b/frontend/src/redux/actions/actions.ts @@ -1,5 +1,4 @@ import axios from 'axios'; -import { getBackendURL } from '../../utilities/utils'; import { ThunkAction } from 'redux-thunk'; import { Actions, AppNotification, AppState, GetUserAction } from '../types'; import { Action } from 'redux'; @@ -29,7 +28,7 @@ export const getUserRejected = (error: Error): GetUserAction => ({ }); export const detectUser = (): ThunkAction> => { - const url = getBackendURL('/api/status'); + const url = '/api/status'; return async (dispatch) => { dispatch(getUserPending()); try { diff --git a/frontend/src/services/buildsService.ts b/frontend/src/services/buildsService.ts index 864c08328e..b2cf6a4894 100644 --- a/frontend/src/services/buildsService.ts +++ b/frontend/src/services/buildsService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; import { BuildStatus } from '../types'; export const fetchBuildStatuses = (): Promise => { - const url = getBackendURL('/api/builds'); + const url = '/api/builds'; return axios .get(url) .then((response) => { diff --git a/frontend/src/services/clusterSettingsService.ts b/frontend/src/services/clusterSettingsService.ts index 7a3f36293f..949b6fc4ed 100644 --- a/frontend/src/services/clusterSettingsService.ts +++ b/frontend/src/services/clusterSettingsService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; import { ClusterSettings } from '../types'; -import { getBackendURL } from '../utilities/utils'; export const fetchClusterSettings = (): Promise => { - const url = getBackendURL('/api/cluster-settings'); + const url = '/api/cluster-settings'; return axios .get(url) .then((response) => { @@ -17,7 +16,7 @@ export const fetchClusterSettings = (): Promise => { export const updateClusterSettings = ( settings: ClusterSettings, ): Promise<{ success: boolean; error: string }> => { - const url = getBackendURL('/api/cluster-settings/update'); + const url = '/api/cluster-settings/update'; const updateParams = new URLSearchParams(); updateParams.set('userTrackingEnabled', JSON.stringify(settings.userTrackingEnabled)); diff --git a/frontend/src/services/componentsServices.ts b/frontend/src/services/componentsServices.ts index b12426f6f4..092e2f2d57 100644 --- a/frontend/src/services/componentsServices.ts +++ b/frontend/src/services/componentsServices.ts @@ -1,9 +1,8 @@ import axios from 'axios'; import { OdhApplication } from '../types'; -import { getBackendURL } from '../utilities/utils'; export const fetchComponents = (installed: boolean): Promise => { - const url = getBackendURL('/api/components'); + const url = '/api/components'; const searchParams = new URLSearchParams(); if (installed) { searchParams.set('installed', 'true'); @@ -20,7 +19,7 @@ export const fetchComponents = (installed: boolean): Promise = }; export const removeComponent = (appName: string): Promise<{ success: boolean; error: string }> => { - const url = getBackendURL('/api/components/remove'); + const url = '/api/components/remove'; const searchParams = new URLSearchParams(); if (appName) { searchParams.set('appName', appName); diff --git a/frontend/src/services/consoleLinksService.ts b/frontend/src/services/consoleLinksService.ts index 2dea10d0ae..8cd6ead6da 100644 --- a/frontend/src/services/consoleLinksService.ts +++ b/frontend/src/services/consoleLinksService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; import { ConsoleLinkKind } from '../types'; export const fetchConsoleLinks = (): Promise => { - const url = getBackendURL('/api/console-links'); + const url = '/api/console-links'; return axios .get(url) .then((response) => { diff --git a/frontend/src/services/dashboardConfigService.ts b/frontend/src/services/dashboardConfigService.ts index a2a3c918f3..f9ee947510 100644 --- a/frontend/src/services/dashboardConfigService.ts +++ b/frontend/src/services/dashboardConfigService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; import { DashboardConfig } from '../types'; export const fetchDashboardConfig = (): Promise => { - const url = getBackendURL('/api/config'); + const url = '/api/config'; return axios .get(url) .then((response) => { diff --git a/frontend/src/services/docsService.ts b/frontend/src/services/docsService.ts index c1ce6b6d4b..ccd216d4b4 100644 --- a/frontend/src/services/docsService.ts +++ b/frontend/src/services/docsService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; import { OdhDocument } from '../types'; export const fetchDocs = (docType?: string): Promise => { - const url = getBackendURL('/api/docs'); + const url = '/api/docs'; const searchParams = new URLSearchParams(); if (docType) { searchParams.set('type', docType); diff --git a/frontend/src/services/gettingStartedService.ts b/frontend/src/services/gettingStartedService.ts index 4b70368727..7ec9e0cb6c 100644 --- a/frontend/src/services/gettingStartedService.ts +++ b/frontend/src/services/gettingStartedService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; import { OdhGettingStarted } from '../types'; export const fetchGettingStartedDoc = (appName: string): Promise => { - const url = getBackendURL('/api/getting-started'); + const url = '/api/getting-started'; const searchParams = new URLSearchParams(); if (appName) { searchParams.set('appName', appName); @@ -20,7 +19,7 @@ export const fetchGettingStartedDoc = (appName: string): Promise => { - const url = getBackendURL('/api/getting-started'); + const url = '/api/getting-started'; return axios .get(url) .then((response) => { diff --git a/frontend/src/services/notebookImageService.ts b/frontend/src/services/notebookImageService.ts new file mode 100644 index 0000000000..2a9c8cf2db --- /dev/null +++ b/frontend/src/services/notebookImageService.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import { Notebook, NotebookCreateRequest, NotebookUpdateRequest, ResponseStatus } from '../types'; + +export const fetchNotebooks = (): Promise => { + const url = '/api/notebook'; + return axios + .get(url) + .then((response) => { + return response.data.notebooks; + }) + .catch((e) => { + throw new Error(e.response.data.message); + }); +}; + +export const importNotebook = (notebook: NotebookCreateRequest): Promise => { + const url = '/api/notebook'; + return axios + .post(url, notebook) + .then((response) => { + return response.data; + }) + .catch((e) => { + throw new Error(e.response.data.message); + }); +}; + +export const deleteNotebook = (notebook: Notebook): Promise => { + const url = `/api/notebook/${notebook.id}`; + return axios + .delete(url, notebook) + .then((response) => { + return response.data; + }) + .catch((e) => { + throw new Error(e.response.data.message); + }); +}; + +export const updateNotebook = (notebook: NotebookUpdateRequest): Promise => { + const url = `/api/notebook/${notebook.id}`; + return axios + .put(url, notebook) + .then((response) => { + return response.data; + }) + .catch((e) => { + throw new Error(e.response.data.message); + }); +}; diff --git a/frontend/src/services/quickStartsService.ts b/frontend/src/services/quickStartsService.ts index 30d5079b44..1741fa6b0f 100644 --- a/frontend/src/services/quickStartsService.ts +++ b/frontend/src/services/quickStartsService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; import { QuickStart } from '@patternfly/quickstarts'; export const fetchQuickStarts = (): Promise => { - const url = getBackendURL('/api/quickstarts'); + const url = '/api/quickstarts'; return axios .get(url) .then((response) => { diff --git a/frontend/src/services/segmentKeyService.ts b/frontend/src/services/segmentKeyService.ts index b97d3748c7..15e16e466a 100644 --- a/frontend/src/services/segmentKeyService.ts +++ b/frontend/src/services/segmentKeyService.ts @@ -1,9 +1,8 @@ import axios from 'axios'; import { ODHSegmentKey } from '../types'; -import { getBackendURL } from '../utilities/utils'; export const fetchSegmentKey = (): Promise => { - const url = getBackendURL('/api/segment-key'); + const url = '/api/segment-key'; return axios .get(url) .then((response) => { diff --git a/frontend/src/services/validateIsvService.ts b/frontend/src/services/validateIsvService.ts index c5cdb83a3b..d5abe02781 100644 --- a/frontend/src/services/validateIsvService.ts +++ b/frontend/src/services/validateIsvService.ts @@ -1,11 +1,10 @@ import axios from 'axios'; -import { getBackendURL } from '../utilities/utils'; export const postValidateIsv = ( appName: string, values: { [key: string]: string }, ): Promise<{ complete: boolean; valid: boolean; error: string }> => { - const url = getBackendURL('/api/validate-isv'); + const url = '/api/validate-isv'; const searchParams = new URLSearchParams(); if (appName) { searchParams.set('appName', appName); @@ -25,7 +24,7 @@ export const postValidateIsv = ( export const getValidationStatus = ( appName: string, ): Promise<{ complete: boolean; valid: boolean; error: string }> => { - const url = getBackendURL('/api/validate-isv/results'); + const url = '/api/validate-isv/results'; const searchParams = new URLSearchParams(); if (appName) { searchParams.set('appName', appName); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index ab2dc5d4cd..1f102f72fd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -156,3 +156,49 @@ export type TrackingEventProperties = { type?: string; term?: string; }; + +export type NotebookError = { + severity: string; + message: string; +}; + +export type NotebookStatus = 'Importing' | 'Validating' | 'Succeeded' | 'Failed'; + +export type Notebook = { + id: string; + phase?: NotebookStatus; + user?: string; + uploaded?: Date; + error?: NotebookError; +} & NotebookCreateRequest & + NotebookUpdateRequest; + +export type NotebookCreateRequest = { + name: string; + url: string; + description?: string; + // FIXME: This shouldn't be a user defined value consumed from the request payload but should be a controlled value from an authentication middleware. + user: string; + software?: NotebookPackage[]; + packages?: NotebookPackage[]; +}; + +export type NotebookUpdateRequest = { + id: string; + name?: string; + description?: string; + visible?: boolean; + software?: NotebookPackage[]; + packages?: NotebookPackage[]; +}; + +export type NotebookPackage = { + name: string; + version: string; + visible: boolean; +}; + +export type ResponseStatus = { + success: boolean; + error: string; +}; diff --git a/frontend/src/utilities/NavData.ts b/frontend/src/utilities/NavData.ts index 66d432421c..083fca95cf 100644 --- a/frontend/src/utilities/NavData.ts +++ b/frontend/src/utilities/NavData.ts @@ -35,6 +35,7 @@ export const adminNavData: NavDataItem[] = [ id: 'settings', group: { id: 'settings', title: 'Settings' }, children: [ + { id: 'settings-notebook-images', label: 'Notebook Images', href: '/notebookImages' }, { id: 'settings-cluster-settings', label: 'Cluster settings', href: '/clusterSettings' }, ], }, diff --git a/frontend/src/utilities/time.ts b/frontend/src/utilities/time.ts new file mode 100644 index 0000000000..486a422e46 --- /dev/null +++ b/frontend/src/utilities/time.ts @@ -0,0 +1,59 @@ +export const relativeTime = (current: number, previous: number): string => { + const msPerMinute = 60 * 1000; + const msPerHour = msPerMinute * 60; + const msPerDay = msPerHour * 24; + const msPerMonth = msPerDay * 30; + const msPerYear = msPerDay * 365; + + if (isNaN(previous)) { + return 'Just now'; + } + + const elapsed = current - previous; + + if (elapsed < msPerMinute) { + return 'Just now'; + } else if (elapsed < msPerHour) { + return `${Math.round(elapsed / msPerMinute)} minutes ago`; + } else if (elapsed < msPerDay) { + return `${Math.round(elapsed / msPerHour)} hours ago`; + } else if (elapsed < msPerMonth) { + const days = Math.round(elapsed / msPerDay); + if (days > 1) return `${days} days ago`; + else return `${days} day`; + } else if (elapsed < msPerYear) { + const months = Math.round(elapsed / msPerMonth); + if (months > 1) return `${months} months ago`; + else return `${months} months`; + } else { + const date = new Date(current); + + const month = date.getMonth(); + let monthAsString = 'Jan'; + if (month === 1) { + monthAsString = 'Feb'; + } else if (month === 2) { + monthAsString = 'Mar'; + } else if (month === 3) { + monthAsString = 'April'; + } else if (month === 4) { + monthAsString = 'May'; + } else if (month === 5) { + monthAsString = 'June'; + } else if (month === 6) { + monthAsString = 'July'; + } else if (month === 7) { + monthAsString = 'August'; + } else if (month === 8) { + monthAsString = 'Sept'; + } else if (month === 9) { + monthAsString = 'Oct'; + } else if (month === 10) { + monthAsString = 'Nov'; + } else if (month === 11) { + monthAsString = 'Dec'; + } + + return `${date.getDate()} ${monthAsString} ${date.getFullYear()}`; + } +}; diff --git a/frontend/src/utilities/useWatchNotebookImages.tsx b/frontend/src/utilities/useWatchNotebookImages.tsx new file mode 100644 index 0000000000..19e6f68df6 --- /dev/null +++ b/frontend/src/utilities/useWatchNotebookImages.tsx @@ -0,0 +1,57 @@ +import * as React from 'react'; +// import { useSelector } from 'react-redux'; +// import { State } from '../redux/types'; +import { fetchNotebooks } from '../services/notebookImageService'; +import { Notebook } from '../types'; +import { POLL_INTERVAL } from './const'; +//import { useDeepCompareMemoize } from './useDeepCompareMemoize'; + +export const useWatchNotebookImages = (): { + notebooks: Notebook[]; + loaded: boolean; + loadError: Error | undefined; + forceUpdate: () => void; +} => { + const [loaded, setLoaded] = React.useState(false); + const [loadError, setLoadError] = React.useState(); + const [notebooks, setNotebooks] = React.useState([]); + const forceUpdate = () => { + setLoaded(false); + fetchNotebooks() + .then((data: Notebook[]) => { + setLoaded(true); + setLoadError(undefined); + setNotebooks(data); + }) + .catch((e) => { + setLoadError(e); + }); + }; + + React.useEffect(() => { + let watchHandle; + const watchNotebooks = () => { + fetchNotebooks() + .then((data: Notebook[]) => { + setLoaded(true); + setLoadError(undefined); + setNotebooks(data); + }) + .catch((e) => { + setLoadError(e); + }); + watchHandle = setTimeout(watchNotebooks, POLL_INTERVAL); + }; + watchNotebooks(); + + return () => { + if (watchHandle) { + clearTimeout(watchHandle); + } + }; + // Don't update when components are updated + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { notebooks: notebooks || [], loaded, loadError, forceUpdate }; +}; diff --git a/frontend/src/utilities/utils.ts b/frontend/src/utilities/utils.ts index 0981f00258..f1d586d130 100644 --- a/frontend/src/utilities/utils.ts +++ b/frontend/src/utilities/utils.ts @@ -1,12 +1,5 @@ import { OdhApplication, OdhDocument, OdhDocumentType } from '../types'; -import { DEV_MODE, API_PORT, CATEGORY_ANNOTATION } from './const'; - -export const getBackendURL = (path: string): string => { - if (!DEV_MODE) { - return path; - } - return `${window.location.protocol}//${window.location.hostname}:${API_PORT}${path}`; -}; +import { CATEGORY_ANNOTATION } from './const'; export const makeCardVisible = (id: string): void => { setTimeout(() => { diff --git a/install/dev-backend.sh b/install/dev-backend.sh deleted file mode 100755 index ffbb9b7885..0000000000 --- a/install/dev-backend.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash -printf "\n\n######## dev backend ########\n" - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" - -cd ${DIR}/../backend -pwd - -PORT=${BACKEND_DEV_PORT} -npm install -npm run start:dev diff --git a/install/dev-frontend.sh b/install/dev-frontend.sh deleted file mode 100755 index 85a0327f1a..0000000000 --- a/install/dev-frontend.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash -printf "\n\n######## dev frontend ########\n" - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" - -cd ${DIR}/../frontend -pwd - -npm install -npm run start:dev diff --git a/install/dev.sh b/install/dev.sh deleted file mode 100755 index 7c3401ffa0..0000000000 --- a/install/dev.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash -printf "\n\n######## dev ########\n" - -DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" - -ENV_FILE=${DIR}/../../.env.development - -if [ -f "${ENV_FILE}" ]; then - source ${ENV_FILE} - for ENV_VAR in $(sed 's/=.*//' ${ENV_FILE}); do export "${ENV_VAR}"; done -fi - -PORT=${BACKEND_DEV_PORT} -npm install -npm run dev