From 05ab57485b3465666845c7e662f5d94c72315680 Mon Sep 17 00:00:00 2001 From: Donald Labaj Date: Fri, 8 Apr 2022 10:20:29 -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 --- backend/src/routes/api/notebook/index.ts | 60 +++ .../src/routes/api/notebook/notebooksUtils.ts | 321 +++++++++++++++ backend/src/routes/api/status/index.ts | 4 - backend/src/types.ts | 100 ++++- 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/services/notebookImageService.ts | 50 +++ frontend/src/types.ts | 39 ++ frontend/src/utilities/NavData.ts | 8 + frontend/src/utilities/time.ts | 59 +++ .../src/utilities/useWatchNotebookImages.tsx | 57 +++ 18 files changed, 1441 insertions(+), 7 deletions(-) create mode 100644 backend/src/routes/api/notebook/index.ts create mode 100644 backend/src/routes/api/notebook/notebooksUtils.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 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/status/index.ts b/backend/src/routes/api/status/index.ts index 78d4e271ba..60f82bde35 100644 --- a/backend/src/routes/api/status/index.ts +++ b/backend/src/routes/api/status/index.ts @@ -4,10 +4,6 @@ import { KubeFastifyInstance, KubeStatus } from '../../../types'; import { DEV_MODE } from '../../../utils/constants'; import { addCORSHeader } from '../../../utils/responseUtils'; -type groupObjResponse = { - users: string[]; -}; - const status = async ( fastify: KubeFastifyInstance, request: FastifyRequest, diff --git a/backend/src/types.ts b/backend/src/types.ts index 85be2e4e1e..cec4f9fc4a 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -12,6 +12,7 @@ export type DashboardConfig = { export type ClusterSettings = { pvcSize: number; userTrackingEnabled: boolean + cullerTimeout: number; } // Add a minimal QuickStart type here as there is no way to get types without pulling in frontend (React) modules @@ -33,17 +34,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 +234,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/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..971b38f3da 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 && } 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/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/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..f3b863ecae 100644 --- a/frontend/src/utilities/NavData.ts +++ b/frontend/src/utilities/NavData.ts @@ -19,6 +19,13 @@ export const navData: NavDataItem[] = [ ], }, { id: 'resources', label: 'Resources', href: '/resources' }, + { + id: 'settings', + group: { id: 'settings', title: 'Settings' }, + children: [ + { id: 'settings-notebook-images', label: 'Notebook Images', href: '/notebookImages' }, + ], + }, ]; export const adminNavData: NavDataItem[] = [ @@ -35,6 +42,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 }; +};