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 }; +};