diff --git a/.env b/.env index c255a0ca4e..f21fc6c4d2 100644 --- a/.env +++ b/.env @@ -15,4 +15,3 @@ SOURCE_REPOSITORY_REF=master DOC_LINK ='https://opendatahub.io/docs.html' COMMUNITY_LINK ='https://opendatahub.io/community.html' ENABLED_APPS_CM = 'odh-enabled-applications-config' -ADMIN_GROUP = 'odh-admins' 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/README.md b/README.md index 9545ef0e41..3b0cfb47d8 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,14 @@ Before developing for ODH, the basic requirements: $ cd odh-dashboard && npm install ``` + +### Build project + ``` + $ npm run build + ``` + ### Serve development content -This is the default context for running a local UI +This is the default context for running a local UI. Make sure you build the project using the instructions above prior to running the command below. ``` $ npm run start diff --git a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts index d5165d0425..bd3433bd75 100644 --- a/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts +++ b/backend/src/routes/api/cluster-settings/clusterSettingsUtils.ts @@ -13,12 +13,12 @@ export const updateClusterSettings = async ( const query = request.query as { [key: string]: string }; try { const jupyterhubCM = await coreV1Api.readNamespacedConfigMap(name, namespace); - if (query.pvcSize) { + if (query.pvcSize && query.cullerTimeout) { await coreV1Api.patchNamespacedConfigMap( name, namespace, { - data: { singleuser_pvc_size: `${query.pvcSize}Gi` }, + data: { singleuser_pvc_size: `${query.pvcSize}Gi`, culler_timeout: query.cullerTimeout }, }, undefined, undefined, @@ -33,6 +33,11 @@ export const updateClusterSettings = async ( if (jupyterhubCM.body.data.singleuser_pvc_size.replace('Gi', '') !== query.pvcSize) { await scaleDeploymentConfig(fastify, 'jupyterhub', 0); } + if (jupyterhubCM.body.data['culler_timeout'] !== query.cullerTimeout) { + // scale down to 0 and scale it up to 1 + await scaleDeploymentConfig(fastify, 'jupyterhub-idle-culler', 0); + await scaleDeploymentConfig(fastify, 'jupyterhub-idle-culler', 1); + } } return { success: true, error: null }; } catch (e) { @@ -52,6 +57,7 @@ export const getClusterSettings = async ( const clusterSettingsRes = await coreV1Api.readNamespacedConfigMap(name, namespace); return { pvcSize: Number(clusterSettingsRes.body.data.singleuser_pvc_size.replace('Gi', '')), + cullerTimeout: Number(clusterSettingsRes.body.data.culler_timeout), }; } catch (e) { if (e.response?.statusCode !== 404) { diff --git a/backend/src/routes/api/cluster-settings/index.ts b/backend/src/routes/api/cluster-settings/index.ts index 08c0bf8650..4b00eb8bbf 100644 --- a/backend/src/routes/api/cluster-settings/index.ts +++ b/backend/src/routes/api/cluster-settings/index.ts @@ -1,5 +1,4 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; -import { DEV_MODE } from '../../../utils/constants'; import { getClusterSettings, updateClusterSettings } from './clusterSettingsUtils'; export default async (fastify: FastifyInstance): Promise => { diff --git a/backend/src/routes/api/status/index.ts b/backend/src/routes/api/status/index.ts index c8ae9d31e6..6f317bc934 100644 --- a/backend/src/routes/api/status/index.ts +++ b/backend/src/routes/api/status/index.ts @@ -10,7 +10,6 @@ const status = async ( fastify: KubeFastifyInstance, request: FastifyRequest, ): Promise<{ kube: KubeStatus }> => { - const adminGroup = process.env.ADMIN_GROUP; const kubeContext = fastify.kube.currentContext; const { currentContext, namespace, currentUser, clusterID } = fastify.kube; const currentUserName = @@ -20,10 +19,14 @@ const status = async ( userName = 'kube:admin'; } const customObjectsApi = fastify.kube.customObjectsApi; + const coreV1Api = fastify.kube.coreV1Api; let isAdmin = false; try { - //const adminGroup2 = (await coreV1Api.readNamespacedConfigMap('rhods-groups-config', namespace)).body.data['admin_groups']; + const configGroupName = (await coreV1Api.readNamespacedConfigMap('groups-config', namespace)) + .body.data['groups-config']; + const adminGroup = (await coreV1Api.readNamespacedConfigMap(configGroupName, namespace)).body + .data['admin_groups']; const adminGroupResponse = await customObjectsApi.getClusterCustomObject( 'user.openshift.io', 'v1', @@ -33,7 +36,7 @@ const status = async ( const adminUsers = (adminGroupResponse.body as groupObjResponse).users; isAdmin = adminUsers.includes(userName); } catch (e) { - console.log('Failed to get role bindings: ' + e.toString()); + console.log('Failed to get groups: ' + e.toString()); } fastify.kube.coreV1Api.getAPIResources(); if (!kubeContext && !kubeContext.trim()) { diff --git a/backend/src/types.ts b/backend/src/types.ts index ef1b6a6548..cb89a114a8 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -10,6 +10,7 @@ export type DashboardConfig = { export type ClusterSettings = { pvcSize: number; + cullerTimeout: number; } // Add a minimal QuickStart type here as there is no way to get types without pulling in frontend (React) modules 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 23036e5be0..47fe7c8dbc 100644 --- a/frontend/config/webpack.dev.js +++ b/frontend/config/webpack.dev.js @@ -12,7 +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 || 8080; +const BACKEND_PORT = process.env._BACKEND_PORT; module.exports = merge( { diff --git a/frontend/src/pages/clusterSettings/ClusterSettings.scss b/frontend/src/pages/clusterSettings/ClusterSettings.scss index 9e17638843..dc5a422de2 100644 --- a/frontend/src/pages/clusterSettings/ClusterSettings.scss +++ b/frontend/src/pages/clusterSettings/ClusterSettings.scss @@ -1,20 +1,48 @@ .odh-cluster-settings { - .pf-c-form__group { - border: var(--pf-global--BorderWidth--sm) solid var(--pf-global--BorderColor--100); - padding: var(--pf-global--spacer--md); - margin: 0; - } + .pf-c-form__group { + border: var(--pf-global--BorderWidth--sm) solid var(--pf-global--BorderColor--100); + padding: var(--pf-global--spacer--md); + margin: 0; + } - .odh-number-input { - max-width: 100px; + .odh-number-input { + max-width: 100px; + } + + .pf-c-input-group { + padding-top: var(--pf-global--spacer--md); + padding-bottom: var(--pf-global--spacer--md); + &__text { + font-size: var(--pf-global--FontSize--sm); + color: var(--pf-global--Color--100); } + } + + .pf-c-helper-text { + margin-top: var(--pf-global--spacer--sm); + } - .pf-c-input-group { - padding-top: var(--pf-global--spacer--md); - padding-bottom: var(--pf-global--spacer--md); + .pf-c-radio { + margin: var(--pf-global--spacer--sm) 0; + padding-left: var(--pf-global--spacer--sm); + &__label { + font-size: var(--pf-global--FontSize--sm); } + } - &__form { - margin: 0 var(--pf-global--spacer--lg); + &__form { + margin: 0 var(--pf-global--spacer--lg); + } + + &__culler-input-group { + &.pf-c-input-group { + padding: var(--pf-global--spacer--xs) var(--pf-global--spacer--lg) 0; + } + .odh-number-input { + max-width: 40px; + &__hour { + max-width: 60px; + } } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/frontend/src/pages/clusterSettings/ClusterSettings.tsx b/frontend/src/pages/clusterSettings/ClusterSettings.tsx index e0492e1abf..23c114557d 100644 --- a/frontend/src/pages/clusterSettings/ClusterSettings.tsx +++ b/frontend/src/pages/clusterSettings/ClusterSettings.tsx @@ -3,15 +3,16 @@ import * as _ from 'lodash-es'; import { Button, ButtonVariant, - Flex, - FlexItem, Form, FormGroup, + HelperText, + HelperTextItem, InputGroup, InputGroupText, InputGroupTextVariant, PageSection, PageSectionVariants, + Radio, Text, TextInput, } from '@patternfly/react-core'; @@ -20,23 +21,37 @@ import { fetchClusterSettings, updateClusterSettings } from '../../services/clus import { ClusterSettings } from '../../types'; import { useDispatch } from 'react-redux'; import { addNotification } from '../../redux/actions/actions'; +import { + DEFAULT_CONFIG, + DEFAULT_PVC_SIZE, + DEFAULT_CULLER_TIMEOUT, + MIN_PVC_SIZE, + MAX_PVC_SIZE, + CULLER_TIMEOUT_LIMITED, + CULLER_TIMEOUT_UNLIMITED, + MAX_MINUTE, + MIN_MINUTE, + MIN_HOUR, + MAX_HOUR, + DEFAULT_HOUR, +} from './const'; +import { getTimeoutByHourAndMinute, getHourAndMinuteByTimeout } from '../../utilities/utils'; + import './ClusterSettings.scss'; const description = `Update global settings for all users.`; -const DEFAULT_PVC_SIZE = 20; -const MIN_PVC_SIZE = 1; -const MAX_PVC_SIZE = 16384; -const DEFAULT_CONFIG: ClusterSettings = { - pvcSize: DEFAULT_PVC_SIZE, -}; - const ClusterSettings: React.FC = () => { const isEmpty = false; const [loaded, setLoaded] = React.useState(false); const [loadError, setLoadError] = React.useState(); const [clusterSettings, setClusterSettings] = React.useState(DEFAULT_CONFIG); - const [pvcSize, setPvcSize] = React.useState(DEFAULT_PVC_SIZE); + const [pvcSize, setPvcSize] = React.useState(DEFAULT_PVC_SIZE); + const [cullerTimeoutChecked, setCullerTimeoutChecked] = + React.useState(CULLER_TIMEOUT_UNLIMITED); + const [cullerTimeout, setCullerTimeout] = React.useState(DEFAULT_CULLER_TIMEOUT); + const [hour, setHour] = React.useState(DEFAULT_HOUR); + const [minute, setMinute] = React.useState(0); const pvcDefaultBtnRef = React.useRef(); const dispatch = useDispatch(); @@ -47,39 +62,74 @@ const ClusterSettings: React.FC = () => { setLoadError(undefined); setClusterSettings(clusterSettings); setPvcSize(clusterSettings.pvcSize); + if (clusterSettings.cullerTimeout !== DEFAULT_CULLER_TIMEOUT) { + setCullerTimeoutChecked(CULLER_TIMEOUT_LIMITED); + setHour(getHourAndMinuteByTimeout(clusterSettings.cullerTimeout).hour); + setMinute(getHourAndMinuteByTimeout(clusterSettings.cullerTimeout).minute); + } + setCullerTimeout(clusterSettings.cullerTimeout); }) .catch((e) => { setLoadError(e); }); }, []); + React.useEffect(() => { + setCullerTimeout(getTimeoutByHourAndMinute(hour, minute)); + }, [hour, minute]); + + const radioCheckedChange = (_, event) => { + const { value } = event.currentTarget; + setCullerTimeoutChecked(value); + if (value === CULLER_TIMEOUT_UNLIMITED) { + setCullerTimeout(DEFAULT_CULLER_TIMEOUT); + submitClusterSettings({ pvcSize, cullerTimeout: DEFAULT_CULLER_TIMEOUT }); + } else if (value === CULLER_TIMEOUT_LIMITED) { + setCullerTimeout(getTimeoutByHourAndMinute(hour, minute)); + submitClusterSettings({ pvcSize, cullerTimeout: getTimeoutByHourAndMinute(hour, minute) }); + } + }; + + const onEnterPress = (event) => { + if (event.key === 'Enter') { + if (pvcDefaultBtnRef.current) { + pvcDefaultBtnRef.current.focus(); + } + } + }; + const submitClusterSettings = (newClusterSettings: ClusterSettings) => { if (!_.isEqual(clusterSettings, newClusterSettings)) { - updateClusterSettings(newClusterSettings) - .then((response) => { - if (response.success) { - setClusterSettings(newClusterSettings); + if ( + Number(newClusterSettings?.pvcSize) !== 0 && + Number(newClusterSettings?.cullerTimeout) !== 0 + ) { + updateClusterSettings(newClusterSettings) + .then((response) => { + if (response.success) { + setClusterSettings(newClusterSettings); + dispatch( + addNotification({ + status: 'success', + title: 'Cluster settings updated successfully.', + timestamp: new Date(), + }), + ); + } else { + throw new Error(response.error); + } + }) + .catch((e) => { dispatch( addNotification({ - status: 'success', - title: 'Cluster settings updated successfully.', + status: 'danger', + title: 'Error', + message: e.message, timestamp: new Date(), }), ); - } else { - throw new Error(response.error); - } - }) - .catch((e) => { - dispatch( - addNotification({ - status: 'danger', - title: 'Error', - message: e.message, - timestamp: new Date(), - }), - ); - }); + }); + } } }; @@ -94,71 +144,168 @@ const ClusterSettings: React.FC = () => { emptyMessage="No cluster settings found." > {!isEmpty ? ( -
- - - -
{ - e.preventDefault(); + + { + e.preventDefault(); + }} + > + + + Changing the PVC size changes the storage size attached to the new notebook servers + for all users. + + + { + submitClusterSettings({ pvcSize: Number(pvcSize), cullerTimeout }); + }} + onKeyPress={(event) => { + if (event.key === 'Enter') { + if (pvcDefaultBtnRef.current) pvcDefaultBtnRef.current.focus(); + } + }} + onChange={async (value: string) => { + const modifiedValue = value.replace(/ /g, ''); + if (modifiedValue !== '') { + let newValue = Number.isInteger(Number(modifiedValue)) + ? Number(modifiedValue) + : pvcSize; + newValue = + newValue > MAX_PVC_SIZE + ? MAX_PVC_SIZE + : newValue < MIN_PVC_SIZE + ? MIN_PVC_SIZE + : newValue; + setPvcSize(newValue); + } else { + setPvcSize(modifiedValue); + } + }} + /> + GiB + + + + + Note: PVC size must be between 1 GiB and 16384 GiB. + + + + + Set the time limit for idle notebooks to be stopped. + + + + submitClusterSettings({ pvcSize, cullerTimeout })} + onKeyPress={onEnterPress} + onChange={(value: string) => { + let newValue = + isNaN(Number(value)) || !Number.isInteger(Number(value)) + ? hour + : Number(value); + newValue = + newValue > MAX_HOUR ? MAX_HOUR : newValue < MIN_HOUR ? MIN_HOUR : newValue; + // if the hour is max, then the minute can only be set to 0 + if (newValue === MAX_HOUR && minute !== MIN_MINUTE) { + setMinute(MIN_MINUTE); + } + setHour(newValue); + }} + /> + hours + submitClusterSettings({ pvcSize, cullerTimeout })} + onKeyPress={onEnterPress} + onChange={(value: string) => { + let newValue = + isNaN(Number(value)) || !Number.isInteger(Number(value)) + ? minute + : Number(value); + newValue = + newValue > MAX_MINUTE + ? MAX_MINUTE + : newValue < MIN_MINUTE + ? MIN_MINUTE + : newValue; + // if the hour is max, then the minute can only be set to 0 + if (hour === MAX_HOUR) { + newValue = MIN_MINUTE; + } + setMinute(newValue); }} + /> + minutes + + + - - - Changing the PVC size changes the storage size attached to the new notebook - servers for all users. - - - submitClusterSettings({ pvcSize })} - onKeyPress={(event) => { - if (event.key === 'Enter') { - if (pvcDefaultBtnRef.current) pvcDefaultBtnRef.current.focus(); - } - }} - onChange={async (value: string) => { - let newValue = isNaN(Number(value)) ? pvcSize : Number(value); - newValue = - newValue > MAX_PVC_SIZE - ? MAX_PVC_SIZE - : newValue < MIN_PVC_SIZE - ? MIN_PVC_SIZE - : newValue; - setPvcSize(newValue); - }} - /> - - GiB - - - - - -
-
-
-
+ Note: Notebook culler timeout must be between 1 minute and 1000 hours. + + + + + ) : null} ); diff --git a/frontend/src/pages/clusterSettings/const.ts b/frontend/src/pages/clusterSettings/const.ts new file mode 100644 index 0000000000..ae1915863f --- /dev/null +++ b/frontend/src/pages/clusterSettings/const.ts @@ -0,0 +1,17 @@ +import { ClusterSettings } from '../../types'; + +export const DEFAULT_PVC_SIZE = 20; +export const MIN_PVC_SIZE = 1; +export const MAX_PVC_SIZE = 16384; +export const CULLER_TIMEOUT_UNLIMITED = 'culler-unlimited-time'; +export const CULLER_TIMEOUT_LIMITED = 'culler-limited-time'; +export const DEFAULT_HOUR = 4; +export const MAX_HOUR = 1000; +export const MIN_HOUR = 0; +export const MAX_MINUTE = 59; +export const MIN_MINUTE = 0; +export const DEFAULT_CULLER_TIMEOUT = 31536000; // 1 year as no culling +export const DEFAULT_CONFIG: ClusterSettings = { + pvcSize: DEFAULT_PVC_SIZE, + cullerTimeout: DEFAULT_CULLER_TIMEOUT, +}; diff --git a/frontend/src/services/clusterSettingsService.ts b/frontend/src/services/clusterSettingsService.ts index 6c521b7b33..608436d495 100644 --- a/frontend/src/services/clusterSettingsService.ts +++ b/frontend/src/services/clusterSettingsService.ts @@ -20,6 +20,7 @@ export const updateClusterSettings = ( const updateParams = new URLSearchParams(); updateParams.set('pvcSize', `${settings.pvcSize}`); + updateParams.set('cullerTimeout', `${settings.cullerTimeout}`); const options = { params: updateParams }; return axios diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 11a84cc6f3..8837309487 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -9,7 +9,8 @@ export type DashboardConfig = { }; export type ClusterSettings = { - pvcSize: number; + pvcSize: number | string; + cullerTimeout: number; }; export type OdhApplication = { diff --git a/frontend/src/utilities/utils.ts b/frontend/src/utilities/utils.ts index c143b601da..6097765288 100644 --- a/frontend/src/utilities/utils.ts +++ b/frontend/src/utilities/utils.ts @@ -116,3 +116,13 @@ export const matchesSearch = (odhDoc: OdhDocument, filterText: string): boolean (description?.toLowerCase().includes(searchText) ?? false) ); }; + +export const getHourAndMinuteByTimeout = (timeout: number): { hour: number; minute: number } => { + const total_minutes = timeout / 60; + const hour = total_minutes / 60; + const minute = total_minutes % 60; + return { hour, minute }; +}; + +export const getTimeoutByHourAndMinute = (hour: number, minute: number): number => + (hour * 60 + minute) * 60; 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