From 8ffb829ff4d51c0d44a81541137b1f695d6c1224 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 8 Apr 2022 12:16:36 -0400 Subject: [PATCH] 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 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. --- 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 | 321 +++++++++++++++ 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 | 101 ++++- backend/src/utils/responseUtils.ts | 24 -- frontend/config/dotenv.js | 2 + frontend/config/webpack.dev.js | 6 +- frontend/package-lock.json | 44 ++ frontend/package.json | 1 + frontend/src/app/Routes.tsx | 2 + frontend/src/pages/ApplicationsPage.tsx | 6 +- .../pages/notebookImages/DeleteImageModal.tsx | 49 +++ .../pages/notebookImages/ImportImageModal.tsx | 99 +++++ .../pages/notebookImages/NotebookImages.tsx | 82 ++++ .../notebookImages/NotebookImagesTable.scss | 12 + .../notebookImages/NotebookImagesTable.tsx | 382 ++++++++++++++++++ .../pages/notebookImages/UpdateImageModal.tsx | 117 ++++++ frontend/src/redux/actions/actions.ts | 3 +- frontend/src/services/buildsService.ts | 3 +- frontend/src/services/componentsServices.ts | 5 +- .../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/validateIsvService.ts | 5 +- frontend/src/types.ts | 39 ++ 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 - 45 files changed, 1497 insertions(+), 212 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/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.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..fe09719169 --- /dev/null +++ b/backend/src/routes/api/notebook/notebooksUtils.ts @@ -0,0 +1,321 @@ +import { FastifyRequest } from 'fastify'; +import createError from 'http-errors'; +import { + KubeFastifyInstance, + Notebook, + ImageStreamListKind, + ImageStreamKind, + NotebookStatus, + PipelineRunListKind, + PipelineRunKind, + NotebookCreateRequest, + NotebookUpdateRequest, + NotebookPackage, +} from '../../../types'; + +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'], +}); + +const mapPipelineRunToNotebook = (plr: PipelineRunKind): Notebook => ({ + id: plr.metadata.name, + name: plr.spec.params.find((p) => p.name === 'name')?.value, + description: plr.spec.params.find((p) => p.name === 'desc')?.value, + url: plr.spec.params.find((p) => p.name === 'url')?.value, + user: plr.spec.params.find((p) => p.name === 'creator')?.value, + phase: 'Importing', +}); + +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 pipelineRuns = await customObjectsApi + .listNamespacedCustomObject( + 'tekton.dev', + 'v1beta1', + namespace, + 'pipelineruns', + undefined, + undefined, + undefined, + 'app.kubernetes.io/created-by=byon', + ) + .then((r) => r.body as PipelineRunListKind); + + const imageStreamNames = imageStreams.items.map((is) => is.metadata.name); + const notebooks: Notebook[] = [ + ...imageStreams.items.map((is) => mapImageStreamToNotebook(is)), + ...pipelineRuns.items + .filter((plr) => !imageStreamNames.includes(plr.metadata.name)) + .map((plr) => mapPipelineRunToNotebook(plr)), + ]; + + 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 }; + } + + const pipelineRun = await customObjectsApi + .getNamespacedCustomObject( + 'tekton.dev', + 'v1beta1', + namespace, + 'pipelineruns', + params.notebook, + ) + .then((r) => r.body as PipelineRunKind) + .catch((r) => null); + + if (pipelineRun) { + return { notebooks: mapPipelineRunToNotebook(pipelineRun), 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 payload: PipelineRunKind = { + apiVersion: 'tekton.dev/v1beta1', + kind: 'PipelineRun', + metadata: { + generateName: 'byon-import-jupyterhub-image-run-', + }, + spec: { + params: [ + { name: 'desc', value: body.description }, + { name: 'name', value: body.name }, + { name: 'url', value: body.url }, + // FIXME: This shouldn't be a user defined value consumed from the request payload but should be a controlled value from an authentication middleware. + { name: 'creator', value: body.user }, + ], + pipelineRef: { + name: 'byon-import-jupyterhub-image', + }, + workspaces: [ + { + name: 'data', + volumeClaimTemplate: { + spec: { + accessModes: ['ReadWriteOnce'], + resources: { + requests: { + storage: '10Mi', + }, + }, + }, + }, + }, + ], + }, + }; + + try { + await customObjectsApi.createNamespacedCustomObject( + 'tekton.dev', + 'v1beta1', + namespace, + 'pipelineruns', + 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); + }); + // Cleanup in case the pipelinerun is still present and was not garbage collected yet + await customObjectsApi + .deleteNamespacedCustomObject( + 'tekton.dev', + 'v1beta1', + namespace, + 'pipelineruns', + params.notebook, + ) + .catch(() => {}); + 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; + + 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) { + const packages = JSON.parse( + imageStream.spec.tags[0].annotations['opendatahub.io/notebook-python-dependencies'], + ) as NotebookPackage[]; + + body.packages.map((update) => { + const original = packages.find((p) => p.name === update.name); + if (original) { + original.visible = update.visible; + } + }); + + imageStream.spec.tags[0].annotations['opendatahub.io/notebook-python-dependencies'] = + JSON.stringify(packages); + } + if (typeof body.visible !== 'undefined') { + if (body.visible) { + imageStream.metadata.labels['opendatahub.io/notebook-image'] = 'true'; + } else { + imageStream.metadata.labels['opendatahub.io/notebook-image'] = null; + } + } + if (body.name) { + imageStream.metadata.annotations['opendatahub.io/notebook-image-name'] = body.name; + } + if (body.description) { + 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..907bfce59c 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,92 @@ 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; + software?: NotebookPackage[]; +} & 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; +} + +export type NotebookUpdateRequest = { + id: string; + name?: string; + description?: string; + visible?: boolean; + 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?: { + 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..c768e79759 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:8080', + }, }, module: { rules: [ 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/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/ImportImageModal.tsx b/frontend/src/pages/notebookImages/ImportImageModal.tsx new file mode 100644 index 0000000000..0beae960cf --- /dev/null +++ b/frontend/src/pages/notebookImages/ImportImageModal.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { Button, Form, FormGroup, TextInput, Modal, ModalVariant } from '@patternfly/react-core'; +import { importNotebook } from '../../services/notebookImageService'; +import { State } from '../../redux/types'; +import { useSelector } from 'react-redux'; +import { Notebook } from 'types'; +export type ImportImageModalProps = { + isOpen: boolean; + onCloseHandler: () => void; + onImportHandler(notebook: Notebook); +}; +export const ImportImageModal: React.FC = ({ + isOpen, + onImportHandler, + onCloseHandler, +}) => { + const [repository, setRepository] = React.useState(''); + const [name, setName] = React.useState(''); + const [description, setDescription] = React.useState(''); + const userName: string = useSelector((state) => state.appState.user || ''); + return ( + { + importNotebook({ + name: name, + url: repository, + description: description, + user: userName, + }).then((value) => { + onImportHandler(value); + onCloseHandler(); + }); + }} + > + Import + , + , + ]} + > +
+ + { + setRepository(value); + }} + /> + + + { + setName(value); + }} + /> + + + { + setDescription(value); + }} + /> + +
+
+ ); +}; + +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..8fd3fdb358 --- /dev/null +++ b/frontend/src/pages/notebookImages/NotebookImagesTable.scss @@ -0,0 +1,12 @@ +.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% +} diff --git a/frontend/src/pages/notebookImages/NotebookImagesTable.tsx b/frontend/src/pages/notebookImages/NotebookImagesTable.tsx new file mode 100644 index 0000000000..adab277a48 --- /dev/null +++ b/frontend/src/pages/notebookImages/NotebookImagesTable.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { + Button, + Flex, + FlexItem, + Popover, + Select, + SelectOption, + SelectVariant, + SearchInput, + Spinner, + Switch, + Toolbar, + ToolbarContent, + ToolbarItem, +} from '@patternfly/react-core'; +import { + ActionsColumn, + TableComposable, + Thead, + Tr, + Th, + ThProps, + Tbody, + Td, + ExpandableRowContent, + IAction, +} from '@patternfly/react-table'; +import { Notebook } from 'types'; +import { ImportImageModal } from './ImportImageModal'; +import { CheckIcon, ExclamationTriangleIcon } from '@patternfly/react-icons'; +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; +}; + +export const NotebookImagesTable: React.FC = ({ + notebooks, + forceUpdate, +}) => { + const rowActions = (notebook: Notebook): IAction[] => [ + { + title: 'Edit', + onClick: () => { + setCurrentNotebook(notebook); + setUpdateImageModalVisible(true); + }, + }, + { + isSeparator: true, + }, + { + title: 'Delete', + onClick: () => { + setCurrentNotebook(notebook); + setDeleteImageModalVisible(true); + }, + }, + ]; + const getPhase = (nb: Notebook) => { + if (nb.phase === 'Succeeded') + return ( + <> + {nb.phase} + + ); + else if (nb.phase === 'Failed') + return ( + + {nb.error && nb.error.message ? nb.error?.message : 'An unknown error has occurred.'} + + } + > +
+ {nb.phase} +
+
+ ); + else + return ( + <> + {nb.phase} + + ); + }; + 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 getSortableRowValues = (nb: Notebook): string[] => { + const { name, description = '', phase = '', visible = false, user = '' } = nb; + return [name, description, phase, visible.toString(), user]; + }; + + 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', + status: 'Status', + enable: 'Enable', + 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) => { + console.log('List of notebooks: ' + expandedNotebookIDs.toString()); + return expandedNotebookIDs.includes(notebook.id); + }; + const [notebookVisible, setNotebookVisible] = React.useState( + notebooks.map((notebook) => { + return { id: notebook.id, visible: notebook.visible }; + }), + ); + + const selectOptions = [ + + Name + , + + Description + , + + Status + , + + User + , + + Uploaded + , + ]; + const [tableFilter, setTableFilter] = React.useState({ + filter: '', + option: 'name', + }); + const [selected, setSelected] = React.useState('name'); + const [tableSelectIsOpen, setTableSelectIsOpen] = React.useState(false); + + const items = ( + + + + + + { + setTableFilter({ + filter: value, + option: tableFilter.option, + }); + }} + onClear={() => { + setTableFilter({ + filter: '', + option: tableFilter.option, + }); + }} + /> + + + + + + ); + + 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} + {columnNames.status} + {columnNames.enable} + {columnNames.user} + {columnNames.uploaded} + + + + {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} + {getPhase(notebook)} + + 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 Include + {packages} + + + + + + ); + })} +
+
+ ); +}; diff --git a/frontend/src/pages/notebookImages/UpdateImageModal.tsx b/frontend/src/pages/notebookImages/UpdateImageModal.tsx new file mode 100644 index 0000000000..2745a5642b --- /dev/null +++ b/frontend/src/pages/notebookImages/UpdateImageModal.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Button, Form, FormGroup, TextInput, Modal, ModalVariant } from '@patternfly/react-core'; +import { Caption, TableComposable, Tbody, Thead, Th, Tr, Td } from '@patternfly/react-table'; +import { updateNotebook } from '../../services/notebookImageService'; +import { Notebook, NotebookPackage } from 'types'; +export type UpdateImageModalProps = { + isOpen: boolean; + notebook: Notebook; + onCloseHandler: () => void; + onUpdateHandler(notebook: Notebook); +}; +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 : [], + ); + + React.useEffect(() => { + if (isOpen === true) { + setName(notebook.name); + setDescription(notebook.description != undefined ? notebook.description : ''); + setPackages(notebook.packages != undefined ? notebook.packages : []); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); + + return ( + { + updateNotebook({ + id: notebook.id, + name: name, + description: description, + packages: packages, + }).then((value) => { + onUpdateHandler(value); + onCloseHandler(); + }); + }} + > + Save Changes + , + , + ]} + > +
+ + { + setName(value); + }} + /> + + + { + setDescription(value); + }} + /> + + + + + Change the advertised packages shown with this notebook image. Modifying that packages + here does not effect the contents of the notebook image. + + + + Package + Version + + + + {packages.map((value) => ( + + {value.name} + {value.version} + + ))} + + + +
+
+ ); +}; + +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/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/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..be0cc8a00f --- /dev/null +++ b/frontend/src/services/notebookImageService.ts @@ -0,0 +1,50 @@ +import axios from 'axios'; +import { Notebook, NotebookCreateRequest, NotebookUpdateRequest } 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.notebook; + }) + .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.notebook; + }) + .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.notebook; + }) + .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/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..ae3bad5b01 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -156,3 +156,42 @@ 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; + software?: NotebookPackage[]; +} & 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; +}; + +export type NotebookUpdateRequest = { + id: string; + name?: string; + description?: string; + visible?: boolean; + packages?: NotebookPackage[]; +}; + +export type NotebookPackage = { + name: string; + version: string; + visible: boolean; +}; 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