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