Skip to content

Commit

Permalink
feat(sessions): add modal for choosing session environment (#405)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonadoni committed Aug 8, 2024
1 parent b203d93 commit 7a83b20
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 39 deletions.
45 changes: 35 additions & 10 deletions reana-ui/src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-*- coding: utf-8 -*-
This file is part of REANA.
Copyright (C) 2020, 2021, 2022, 2023 CERN.
Copyright (C) 2020, 2021, 2022, 2023, 2024 CERN.
REANA is free software; you can redistribute it and/or modify it
under the terms of the MIT License; see LICENSE file for more details.
Expand All @@ -12,15 +12,15 @@ import isEmpty from "lodash/isEmpty";

import client, {
CONFIG_URL,
INTERACTIVE_SESSIONS_CLOSE_URL,
INTERACTIVE_SESSIONS_OPEN_URL,
USER_INFO_URL,
USER_SIGNOUT_URL,
WORKFLOW_FILES_URL,
WORKFLOW_LOGS_URL,
WORKFLOW_SPECIFICATION_URL,
WORKFLOW_RETENTION_RULES_URL,
WORKFLOW_FILES_URL,
WORKFLOW_SET_STATUS_URL,
INTERACTIVE_SESSIONS_OPEN_URL,
INTERACTIVE_SESSIONS_CLOSE_URL,
WORKFLOW_SPECIFICATION_URL,
} from "~/client";
import {
parseWorkflows,
Expand Down Expand Up @@ -87,6 +87,9 @@ export const WORKFLOW_STOPPED = "Workflow stopped";
export const OPEN_STOP_WORKFLOW_MODAL = "Open stop workflow modal";
export const CLOSE_STOP_WORKFLOW_MODAL = "Close stop workflow modal";
export const WORKFLOW_LIST_REFRESH = "Refresh workflow list";
export const OPEN_INTERACTIVE_SESSION_MODAL = "Open interactive session modal";
export const CLOSE_INTERACTIVE_SESSION_MODAL =
"Close interactive session modal";

export function errorActionCreator(error, name) {
const { status, data } = error?.response;
Expand Down Expand Up @@ -469,16 +472,39 @@ export function closeStopWorkflowModal() {
return { type: CLOSE_STOP_WORKFLOW_MODAL };
}

export function openInteractiveSession(id) {
return async (dispatch) => {
export function openInteractiveSessionModal(workflow) {
return { type: OPEN_INTERACTIVE_SESSION_MODAL, workflow };
}

export function closeInteractiveSessionModal() {
return { type: CLOSE_INTERACTIVE_SESSION_MODAL };
}

export function openInteractiveSession(id, options = {}) {
return async (dispatch, getStore) => {
const state = getStore();
const config = getConfig(state);
return await client
.openInteractiveSession(id)
.openInteractiveSession(id, options)
.then((resp) => {
dispatch({ type: WORKFLOW_LIST_REFRESH });

const interactiveSessionInactivityWarning =
config.maxInteractiveSessionInactivityPeriod
? `Please note that it will be automatically closed after ${config.maxInteractiveSessionInactivityPeriod} days of inactivity.`
: "";
dispatch(
triggerNotification(
"Success!",
"The interactive session has been created. " +
"However, it could take several minutes to start the Jupyter Notebook. " +
"Click on the Jupyter logo to access it. " +
`${interactiveSessionInactivityWarning}`,
),
);
})
.catch((err) => {
dispatch(errorActionCreator(err, INTERACTIVE_SESSIONS_OPEN_URL(id)));
throw err;
});
};
}
Expand All @@ -493,7 +519,6 @@ export function closeInteractiveSession(id) {
})
.catch((err) => {
dispatch(errorActionCreator(err, INTERACTIVE_SESSIONS_CLOSE_URL(id)));
throw err;
});
};
}
9 changes: 6 additions & 3 deletions reana-ui/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
-*- coding: utf-8 -*-
This file is part of REANA.
Copyright (C) 2021, 2022, 2023 CERN.
Copyright (C) 2021, 2022, 2023, 2024 CERN.
REANA is free software; you can redistribute it and/or modify it
under the terms of the MIT License; see LICENSE file for more details.
Expand Down Expand Up @@ -165,8 +165,11 @@ class Client {
});
}

openInteractiveSession(id) {
return this._request(INTERACTIVE_SESSIONS_OPEN_URL(id), { method: "post" });
openInteractiveSession(id, { type = "jupyter", image } = {}) {
return this._request(INTERACTIVE_SESSIONS_OPEN_URL(id, type), {
data: { image },
method: "post",
});
}

closeInteractiveSession(id) {
Expand Down
228 changes: 228 additions & 0 deletions reana-ui/src/components/InteractiveSessionModal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
/*
-*- coding: utf-8 -*-
This file is part of REANA.
Copyright (C) 2024 CERN.
REANA is free software; you can redistribute it and/or modify it
under the terms of the MIT License; see LICENSE file for more details.
*/

import { useCallback, useEffect, useMemo, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import {
Button,
Form,
FormDropdown,
FormGroup,
FormInput,
FormRadio,
Modal,
} from "semantic-ui-react";

import {
closeInteractiveSessionModal,
openInteractiveSession,
} from "~/actions";
import {
getConfig,
getInteractiveSessionModalItem,
getInteractiveSessionModalOpen,
} from "~/selectors";

const EnvironmentType = {
Recommended: "recommended",
Custom: "custom",
};

export default function InteractiveSessionModal() {
const dispatch = useDispatch();
const open = useSelector(getInteractiveSessionModalOpen);
const workflow = useSelector(getInteractiveSessionModalItem);

const config = useSelector(getConfig);
const environments = config.interactiveSessions.environments;
const allSessionTypes = useMemo(
() => Object.keys(environments).sort(),
[environments],
);

const [sessionType, setSessionType] = useState(null);
const [environmentType, setEnvironmentType] = useState(
EnvironmentType.Recommended,
);
const [recommendedImage, setRecommendedImage] = useState(null);
const [customImage, setCustomImage] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [errors, setErrors] = useState({});

const resetForm = useCallback(
(newSessionType = null) => {
if (!newSessionType) {
// get default session type
newSessionType = allSessionTypes.at(0);
}

if (newSessionType) {
setSessionType(newSessionType);
if (environments[newSessionType].recommended.length > 0) {
setEnvironmentType(EnvironmentType.Recommended);
} else {
setEnvironmentType(EnvironmentType.Custom);
}
// default image is first of recommended
setRecommendedImage(
environments[newSessionType].recommended.at(0)?.image ?? "",
);
setCustomImage("");
setIsLoading(false);
setErrors({});
}
},
[environments, allSessionTypes],
);

useEffect(() => {
// reset local state on workflow change
resetForm();
}, [workflow, resetForm]);

// no workflow passed, nothing to show
if (!workflow) return null;

if (allSessionTypes.length > 0 && !sessionType) {
// initialize state
resetForm();
return null;
}

const onCloseModal = () => {
resetForm();
dispatch(closeInteractiveSessionModal());
};

if (allSessionTypes.length === 0) {
return (
<Modal open={open} onClose={onCloseModal} closeIcon size="small">
<Modal.Header>Open interactive session</Modal.Header>
<Modal.Content>
There aren't any available interactive session types.
</Modal.Content>
<Modal.Actions>
<Button onClick={onCloseModal}>Cancel</Button>
</Modal.Actions>
</Modal>
);
}

const sessionTypeOptions = allSessionTypes.map((type) => ({
key: type,
text: type,
value: type,
}));

const recommendedImageOptions = environments[sessionType].recommended.map(
(recommended) => ({
key: recommended.image,
text: recommended.name ?? recommended.image,
value: recommended.image,
}),
);

const isCustomAllowed = environments[sessionType].allowCustom;

const checkFields = () => {
const errors = {};
if (environmentType === EnvironmentType.Custom && !customImage) {
errors.customImage = "Please provide a custom image";
}
setErrors(errors);
return Object.keys(errors).length === 0;
};

const onSubmit = () => {
if (!checkFields()) return;

setIsLoading(true);

const image =
environmentType === EnvironmentType.Recommended
? recommendedImage
: customImage;
dispatch(
openInteractiveSession(workflow.id, { type: sessionType, image }),
).finally(onCloseModal);
};

return (
<Modal open={open} onClose={onCloseModal} closeIcon size="small">
<Modal.Header>Open interactive session</Modal.Header>
<Modal.Content>
<Form id="formOpenSession" onSubmit={onSubmit} loading={isLoading}>
{/* only show dropdown if there are at least two session types to choose from */}
{allSessionTypes.length > 1 && (
<FormDropdown
selection
label="Session type"
options={sessionTypeOptions}
value={sessionType}
onChange={(_, { value }) => resetForm(value)}
disabled={sessionTypeOptions.length === 1}
/>
)}
{/* show selector if both recommended and custom images are allowed */}
{recommendedImageOptions.length > 0 && isCustomAllowed && (
<FormGroup inline>
<label>Environment</label>
<FormRadio
name="environmentType"
label="Recommended environments"
value="recommended"
checked={environmentType === EnvironmentType.Recommended}
onChange={(_, { value }) => setEnvironmentType(value)}
/>
<FormRadio
name="environmentType"
label="Custom environment"
value="custom"
checked={environmentType === EnvironmentType.Custom}
onChange={(_, { value }) => setEnvironmentType(value)}
/>
</FormGroup>
)}
{/* no recommended images and no custom images allowed, nothing can be chosen */}
{recommendedImageOptions.length === 0 && !isCustomAllowed && (
<p>There aren't any allowed environment images.</p>
)}
{environmentType === EnvironmentType.Recommended && (
<FormDropdown
selection
label="Recommended environments"
options={recommendedImageOptions}
value={recommendedImage}
onChange={(_, { value }) => setRecommendedImage(value)}
search={true}
/>
)}
{isCustomAllowed && environmentType === EnvironmentType.Custom && (
<FormInput
label="Custom environment"
value={customImage}
onChange={(_, { value }) => setCustomImage(value)}
placeholder={"Custom container image"}
error={errors.customImage}
/>
)}
</Form>
</Modal.Content>
<Modal.Actions>
{(recommendedImageOptions.length > 0 || isCustomAllowed) && (
<Button primary type="submit" form="formOpenSession">
Open
</Button>
)}
<Button onClick={onCloseModal}>Cancel</Button>
</Modal.Actions>
</Modal>
);
}
Loading

0 comments on commit 7a83b20

Please sign in to comment.