Merge "Bug 571403 - User Interface for Tree Workflow" into develop
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceConfiguration.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceConfiguration.java
index de52dff..91b01da 100644
--- a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceConfiguration.java
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceConfiguration.java
@@ -9,6 +9,7 @@
*
* Contributors:
* Robert Bosch GmbH - initial API and implementation
+ * Dortmund University of Applied Sciences and Arts
********************************************************************************
*/
package org.eclipse.app4mc.cloud.manager;
@@ -58,7 +59,7 @@
}
public List<ServiceConfigurationParameter> getParameterList() {
- return new ArrayList<>(this.parameter);
+ return this.parameter;
}
public ServiceConfigurationParameter getParameter(String key) {
@@ -69,4 +70,5 @@
}
return null;
}
+
}
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNode.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNode.java
index 36e6dc8..94d4143 100644
--- a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNode.java
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNode.java
@@ -9,6 +9,7 @@
*
* Contributors:
* Robert Bosch GmbH - initial API and implementation
+ * Dortmund University of Applied Sciences and Arts
********************************************************************************
*/
package org.eclipse.app4mc.cloud.manager;
@@ -21,6 +22,7 @@
import org.eclipse.app4mc.cloud.manager.administration.CloudServiceDefinition;
public class ServiceNode {
+ public static final String ROOT_NODE_ID = "root";
private final String id;
private final CloudServiceDefinition service;
@@ -202,4 +204,11 @@
public boolean isFailed() {
return this.failed;
}
+
+ public String getQualifiedId() {
+ if (this.parentNode == null || this.parentNode.getId().equals(ROOT_NODE_ID)) {
+ return this.id;
+ }
+ return this.parentNode.getQualifiedId() + "." + this.id;
+ }
}
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNodeProcessingTask.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNodeProcessingTask.java
index a6a17b8..1a785af 100644
--- a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNodeProcessingTask.java
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/ServiceNodeProcessingTask.java
@@ -398,7 +398,7 @@
ServiceNode sn = node;
ArrayList<String> segments = new ArrayList<>();
segments.add(getServiceNodeDirName(sn));
- while (sn.getParentNode() != null && !sn.getParentNode().getId().equals(WorkflowStatus.ROOT_NODE_ID)) {
+ while (sn.getParentNode() != null && !sn.getParentNode().getId().equals(ServiceNode.ROOT_NODE_ID)) {
sn = sn.getParentNode();
segments.add(0, getServiceNodeDirName(sn));
}
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java
index 01d4680..40423a7 100644
--- a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowController.java
@@ -9,6 +9,7 @@
*
* Contributors:
* Robert Bosch GmbH - initial API and implementation
+ * Dortmund University of Applied Sciences and Arts
********************************************************************************
*/
package org.eclipse.app4mc.cloud.manager;
@@ -44,6 +45,7 @@
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.SessionAttributes;
@@ -214,8 +216,6 @@
return "workflow";
}
- // TODO extend selection and removal of services to handle parents
-
@PostMapping("/select/{selected}")
public String selectService(
@PathVariable(name = "selected") String selected,
@@ -236,24 +236,56 @@
return "workflow";
}
+ @PostMapping("/select/{parentId}/{selected}")
+ public String selectService(
+ @PathVariable(name = "selected") String selected,
+ @PathVariable(name = "parentId") String parentId,
+ @ModelAttribute WorkflowStatus ws) {
+
+ CloudServiceDefinition csd = this.cloudServiceDefinitions.stream()
+ .filter(sd -> sd.getKey().equals(selected))
+ .findFirst()
+ .orElse(null);
+
+ if (csd == null) {
+ return "workflow";
+ }
+
+ ws.addSelectedService(parentId, csd, WorkflowStatusHelper.getConfigurationForService(csd));
+
+ // render the form view
+ return "workflow";
+ }
+
@PostMapping("/remove/{selected}")
public String removeService(
@PathVariable(name = "selected") String selected,
@ModelAttribute WorkflowStatus ws) {
- CloudServiceDefinition csd = this.cloudServiceDefinitions.stream()
- .filter(sd -> sd.getKey().equals(selected))
- .findFirst()
- .orElse(null);
-
- if (csd != null) {
- ws.removeSelectedService(csd);
- }
+ ws.removeSelectedService(selected);
// render the form view
return "workflow";
}
+ @PostMapping("/remove/{parentId}/{selected}")
+ public String removeService(
+ @PathVariable(name = "parentId") String parentId,
+ @PathVariable(name = "selected") String selected,
+ @ModelAttribute WorkflowStatus ws) {
+
+ ws.removeSelectedService(parentId, selected);
+
+ return "workflow";
+ }
+
+ @PostMapping("/removeall")
+ public String removeAllServices(@ModelAttribute WorkflowStatus ws) {
+ ws.clear();
+ // render the form view
+ return "workflow";
+ }
+
@GetMapping("/selectedServices")
public String getSelectedServices() {
// render the servicesList fragment contained in selectedServices.html
@@ -277,6 +309,54 @@
// render the resultList fragment contained in status.html
return "status :: resultList";
}
+
+ @GetMapping("/config/{id}")
+ public String getConfiguration(@PathVariable("id") String qualifiedId, Model model, @ModelAttribute WorkflowStatus ws) {
+ ServiceNode node = ws.getServiceByQualifiedId(qualifiedId);
+ model.addAttribute("name", node.getService().getName());
+ model.addAttribute("id", node.getQualifiedId());
+ model.addAttribute("config", node.getServiceConfiguration());
+ model.addAttribute("uuid", ws.getUuid());
+
+ // render the configuration
+ return "configuration :: config";
+ }
+
+ @PostMapping("/config/{id}")
+ public String saveConfiguration(@PathVariable("id") String qualifiedId, @ModelAttribute ServiceConfiguration config,
+ Model model, @ModelAttribute WorkflowStatus ws) {
+
+ // render the configuration
+ ServiceNode node = ws.getServiceByQualifiedId(qualifiedId);
+ ServiceConfiguration nodeConfig = node.getServiceConfiguration();
+ nodeConfig.getParameterList().clear();
+ nodeConfig.getParameterList().addAll(config.getParameterList());
+
+ model.addAttribute("name", node.getService().getName());
+ model.addAttribute("id", qualifiedId);
+ model.addAttribute("config", node.getServiceConfiguration());
+
+ return "configuration :: config";
+ }
+
+ @PutMapping("/config/{id}")
+ public String resetConfiguration(@PathVariable("id") String id, Model model, @ModelAttribute WorkflowStatus ws) {
+
+ ServiceNode node = ws.getServiceByQualifiedId(id);
+
+ CloudServiceDefinition csd = this.cloudServiceDefinitions.stream()
+ .filter(sd -> sd.getKey().equals(node.getService().getKey()))
+ .findFirst()
+ .orElse(null);
+
+ ServiceConfiguration defaultConfig = WorkflowStatusHelper.getConfigurationForService(csd);
+
+ model.addAttribute("name", node.getService().getName());
+ model.addAttribute("id", id);
+ model.addAttribute("config", defaultConfig);
+
+ return "configuration :: config";
+ }
@GetMapping("/{uuid}/files/**")
@ResponseBody
diff --git a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowStatus.java b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowStatus.java
index 47d17fb..e02f670 100644
--- a/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowStatus.java
+++ b/manager/src/main/java/org/eclipse/app4mc/cloud/manager/WorkflowStatus.java
@@ -9,7 +9,7 @@
*
* Contributors:
* Robert Bosch GmbH - initial API and implementation
- * Dortmund University of Applied Sciences and Arts - Bug 570871
+ * Dortmund University of Applied Sciences and Arts
********************************************************************************
*/
package org.eclipse.app4mc.cloud.manager;
@@ -30,12 +30,10 @@
private static final Logger LOGGER = LoggerFactory.getLogger(WorkflowStatus.class);
- public static final String ROOT_NODE_ID = "root";
-
private String name;
private String uuid;
- private final ServiceNode rootNode = new ServiceNode(ROOT_NODE_ID);
+ private final ServiceNode rootNode = new ServiceNode(ServiceNode.ROOT_NODE_ID);
private final ArrayList<String> messages = new ArrayList<>();
private final ArrayList<String> errors = new ArrayList<>();
@@ -82,6 +80,32 @@
public List<ServiceNode> getSelectedServices() {
return this.rootNode.getChildren();
}
+
+ /**
+ * @param serviceId The Id of a given service
+ * @return The service in the with given id.
+ */
+ public ServiceNode getServiceByQualifiedId(String qualifiedId) {
+ String[] keys = qualifiedId.split("\\.");
+ ServiceNode parentNode = this.rootNode;
+ ServiceNode node = null;
+ for (int i = 0; i < keys.length; i++) {
+ String key = keys[i];
+ node = parentNode.getChildren().stream()
+ .filter(child -> child.getId().equals(key))
+ .findFirst()
+ .orElse(null);
+
+ if (node == null) {
+ LOGGER.warn("Could not resolve key '{}' in qualifiedId '{}'", key, qualifiedId);
+ break;
+ }
+
+ parentNode = node;
+ }
+
+ return node;
+ }
/**
* Add a {@link CloudServiceDefinition} and its corresponding
@@ -93,7 +117,10 @@
* {@link CloudServiceDefinition}. Can be <code>null</code>.
*/
public void addSelectedService(CloudServiceDefinition service, ServiceConfiguration config) {
- this.rootNode.addChild(new ServiceNode(service, config));
+ if (this.rootNode.getChildren().stream()
+ .noneMatch(child -> child.getId().equals(service.getKey()))) {
+ this.rootNode.addChild(new ServiceNode(service, config));
+ }
}
/**
@@ -101,7 +128,7 @@
* {@link ServiceConfiguration} as a child of an already registered service node
* denoted by the given parent key.
*
- * @param parentKey The key to identify an existing service node. A service path
+ * @param parentId The qualified Id of the parent - key to identify an existing service node. A service path
* can be represented as key by concatenating the keys with a
* dot, e.g. migration.validation if validation is a child of
* migration. Note: root should not be added as part of the key.
@@ -109,8 +136,8 @@
* @param config The {@link ServiceConfiguration} for the given
* {@link CloudServiceDefinition}. Can be <code>null</code>.
*/
- public void addSelectedService(String parentKey, CloudServiceDefinition service, ServiceConfiguration config) {
- String[] keys = parentKey.split("\\.");
+ public void addSelectedService(String parentId, CloudServiceDefinition service, ServiceConfiguration config) {
+ String[] keys = parentId.split("\\.");
ServiceNode parentNode = this.rootNode;
ServiceNode node = null;
for (int i = 0; i < keys.length; i++) {
@@ -123,7 +150,14 @@
if (i == (keys.length - 1)) {
if (node != null) {
// we found a node, so simply add the child
- node.addChild(new ServiceNode(service, config));
+
+ // If the parent service does not already have that service as a child,
+ // add the service as a child. This helps us to prevent having
+ // multiple instances of the same service at the same level.
+ if (node.getChildren().stream()
+ .noneMatch(child -> child.getId().equals(service.getKey()))) {
+ node.addChild(new ServiceNode(service, config));
+ }
} else if (node == null) {
// we did not find the node, so probably a structural node should be added
node = new ServiceNode(key);
@@ -155,6 +189,22 @@
public void removeSelectedService(CloudServiceDefinition service) {
this.rootNode.removeChild(service);
}
+
+ /**
+ * Removes the given {@link ServiceNode} from the root level of this
+ * {@link WorkflowStatus}.
+ *
+ * @param serviceKey The key of the {@link CloudServiceDefinition} of the {@link ServiceNode} to remove.
+ */
+ public void removeSelectedService(String serviceKey) {
+ ServiceNode node = this.rootNode.getChildren().stream()
+ .filter(child -> child.getId().equals(serviceKey))
+ .findFirst()
+ .orElse(null);
+ if (node != null) {
+ this.rootNode.removeChild(node);
+ }
+ }
/**
* Removes the given {@link CloudServiceDefinition} from the service node
@@ -176,6 +226,39 @@
}
/**
+ * Removes the given {@link CloudServiceDefinition} from the service node
+ * denoted by the given parent ID.
+ *
+ * @param parentId The qualified ID to identify the existing service node from which the
+ * given service should be removed. A service path can be
+ * represented as key by concatenating the keys with a dot,
+ * e.g. migration.validation if validation is a child of
+ * migration. Note: root should not be added as part of the
+ * key.
+ * @param key The key of the {@link ServiceNode} to remove.
+ */
+ public void removeSelectedService(String parentId, String serviceKey) {
+ String[] keys = parentId.split("\\.");
+ ServiceNode parentNode = this.rootNode;
+ ServiceNode node = null;
+ for (int i = 0; i < keys.length; i++) {
+ String key = keys[i];
+ node = parentNode.getChildren().stream()
+ .filter(child -> child.getId().equals(key))
+ .findFirst()
+ .orElse(null);
+
+ if (i == (keys.length - 1)) {
+ if (node != null) {
+ node.removeChild(serviceKey);
+ }
+ }
+
+ parentNode = node;
+ }
+ }
+
+ /**
* Removes a service node denoted by the given key.
*
* @param key The key to identify the service node to remove. A service path can
@@ -331,4 +414,5 @@
this.errors.clear();
this.results.clear();
}
+
}
diff --git a/manager/src/main/resources/static/css/styles.css b/manager/src/main/resources/static/css/styles.css
index 2d0f1ea..ad2dd8b 100644
--- a/manager/src/main/resources/static/css/styles.css
+++ b/manager/src/main/resources/static/css/styles.css
@@ -1,130 +1,140 @@
-html, body{
- height:100%;
+html,
+body {
+ height: 100%;
}
-h1{
- color: black;
+h1 {
+ color: black;
}
-.custom-file-label{
- border-radius : 40px;
- -moz-border-radius:40px;
- -webkit-border-radius:40px;
+.custom-file-label {
+ border-radius: 40px;
+ -moz-border-radius: 40px;
+ -webkit-border-radius: 40px;
}
-.custom-file-label::after{
- border-radius : 40px;
- -moz-border-radius:40px;
- -webkit-border-radius:40px;
+.custom-file-label::after {
+ border-radius: 40px;
+ -moz-border-radius: 40px;
+ -webkit-border-radius: 40px;
}
-.form-control{
- border-radius : 40px;
- -moz-border-radius:40px;
- -webkit-border-radius:40px;
+.form-control {
+ border-radius: 40px;
+ -moz-border-radius: 40px;
+ -webkit-border-radius: 40px;
}
-#wf-fm-group{
- padding:0;
+#wf-fm-group {
+ padding: 0;
}
-.hero{
- height: 100%;
- margin: 0;
- padding: 0;
- background-image: linear-gradient(rgba(220, 220, 220, 0.9) 50%, rgba(220, 220, 220, 0.9) 50%), url("../images/bg-header2.jpg");
- background-color: #cccccc;
- background-size: cover;
+.hero {
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ background-image: linear-gradient(
+ rgba(220, 220, 220, 0.9) 50%,
+ rgba(220, 220, 220, 0.9) 50%
+ ),
+ url('../images/bg-header2.jpg');
+ background-color: #cccccc;
+ background-size: cover;
}
-.left-btn, .right-btn{
- border-radius: 40px;
- padding-left: 30px;
- padding-right: 30px;
+.left-btn,
+.right-btn {
+ border-radius: 40px;
+ padding-left: 30px;
+ padding-right: 30px;
}
-.list-group{
- border-radius : 40px;
+.list-group {
+ border-radius: 40px;
}
-.banner{
- margin: 0;
- padding: 0;
- background-image: linear-gradient(rgba(220, 220, 220, 0.9) 50%, rgba(220, 220, 220, 0.9) 50%), url("../images/bg-header2.jpg");
- background-color: #cccccc;
- background-size: cover;
- height: 300px;
- width:100%;
- z-index: -1;
- position: absolute;
- top: 0;
- right: 0;
+.banner {
+ margin: 0;
+ padding: 0;
+ background-image: linear-gradient(
+ rgba(220, 220, 220, 0.9) 50%,
+ rgba(220, 220, 220, 0.9) 50%
+ ),
+ url('../images/bg-header2.jpg');
+ background-color: #cccccc;
+ background-size: cover;
+ height: 300px;
+ width: 100%;
+ z-index: -1;
+ position: absolute;
+ top: 0;
+ right: 0;
}
-.list-group-item{
- border-radius : 40px;
- -moz-border-radius:40px;
- -webkit-border-radius:40px;
- padding-top: 5px;
- padding-bottom: 5px;
- padding-right: 5px;
+.list-group-item {
+ border-radius: 40px;
+ -moz-border-radius: 40px;
+ -webkit-border-radius: 40px;
+ padding-top: 5px;
+ padding-bottom: 5px;
+ padding-right: 5px;
}
-.btn-rmv{
- background-color: Black;
- border: none;
- color: white;
- padding: 10px 14px;
- font-size: 16px;
- cursor: pointer;
- border-radius : 50%;
+.btn-rmv {
+ background-color: Black;
+ border: none;
+ color: white;
+ padding: 10px 14px;
+ font-size: 16px;
+ cursor: pointer;
+ border-radius: 50%;
}
-.navcon{
- margin: 0;
- padding: 0;
+.navcon {
+ margin: 0;
+ padding: 0;
}
-.nav-item{
- margin-left: 30px;
+.nav-item {
+ margin-left: 30px;
}
-.nav-brand{
- width: 150px;
- display: block;
+.nav-brand {
+ width: 150px;
+ display: block;
}
.plist {
margin: auto 0;
}
-.fm-ctrl-left{
- border-radius : 40px 0px 0px 40px;
- -moz-border-radius:40px 0px 0px 40px;
- -webkit-border-radius:40px 0px 0px 40px;
+.fm-ctrl-left {
+ border-radius: 40px 0px 0px 40px;
+ -moz-border-radius: 40px 0px 0px 40px;
+ -webkit-border-radius: 40px 0px 0px 40px;
}
-.fm-ctrl-center{
- border-radius : 0;
- -moz-border-radius:0;
- -webkit-border-radius:0;
+.fm-ctrl-center {
+ border-radius: 0;
+ -moz-border-radius: 0;
+ -webkit-border-radius: 0;
}
-.fm-ctrl-right{
- border-radius : 0px 40px 40px 0px;
- -moz-border-radius:0px 40px 40px 0px;
- -webkit-border-radius:0px 40px 40px 0px;
+.fm-ctrl-right {
+ border-radius: 0px 40px 40px 0px;
+ -moz-border-radius: 0px 40px 40px 0px;
+ -webkit-border-radius: 0px 40px 40px 0px;
}
-.btn-save{
- border: none;
- color: white;
- padding: 10px 14px;
- font-size: 16px;
- cursor: pointer;
+.btn-save {
+ border: none;
+ color: white;
+ padding: 10px 14px;
+ font-size: 16px;
+ cursor: pointer;
}
-.half-btn{
- width: 100px
+.half-btn {
+ width: 100px;
}
/* Scroll Bar */
@@ -133,16 +143,16 @@
}
::-webkit-scrollbar-track {
- background: #f1f1f1;
+ background: #f1f1f1;
}
-
+
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 5px;
}
::-webkit-scrollbar-thumb:hover {
- background: #555;
+ background: #555;
}
.disabled_service {
@@ -153,5 +163,218 @@
.flash {
color: #007bff;
font-weight: bold;
- margin-bottom: 1.5rem!important;
+ margin-bottom: 1.5rem !important;
}
+
+.tree,
+.tree ul,
+.tree li {
+ position: relative;
+ padding-bottom: 10px;
+}
+
+.stem {
+ padding: 0 0 0 8px;
+ font-size: 24px;
+ margin-right: 6px;
+}
+
+.stem::before {
+ position: relative;
+ content: '\1F4C4';
+ top: 2px;
+ margin-right: 6px;
+}
+
+.tree ul {
+ list-style: none;
+ padding-left: 32px;
+}
+
+.tree li::before,
+.tree li::after {
+ content: '';
+ position: absolute;
+ left: -12px;
+}
+
+.tree li::before {
+ border-top: 3px solid #000;
+ top: 9px;
+ width: 16px;
+ height: 0;
+}
+
+.tree li::after {
+ border-left: 4px solid #000;
+ height: 100%;
+ width: 0px;
+ top: 2px;
+}
+
+.tree ul > li:last-child::after {
+ height: 8px;
+}
+
+.tree ul > li:last-child {
+ padding-bottom: 0;
+}
+
+/* Style the caret/arrow */
+.caret-down {
+ cursor: pointer;
+ user-select: none; /* Prevent text selection */
+}
+
+/* Rotate the caret/arrow icon when clicked on (using JavaScript) */
+.caret-down::before {
+ content: '\25B6';
+ color: black;
+ display: inline-block;
+ margin-right: 6px;
+ margin-left: 6px;
+ transform: rotate(90deg);
+}
+
+.caret::before {
+ transform: rotate(0deg);
+}
+
+.space::before {
+ content: '\2000';
+ color: black;
+ display: inline-block;
+ margin-right: 6px;
+ margin-left: 6px;
+}
+
+.nested {
+ display: none;
+}
+
+.active {
+ display: block;
+}
+
+/* The Modal (background) */
+.modal {
+ display: none;
+ position: fixed;
+ z-index: 4;
+ padding-top: 20vh;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ background-color: rgb(0, 0, 0);
+ background-color: rgba(0, 0, 0, 0.4);
+}
+
+/* Modal Content */
+.modal-content {
+ background-color: #fefefe;
+ margin: auto;
+ padding: 20px;
+ border: 1px solid #888;
+ border-radius: 10px;
+ width: 60%;
+ max-height: 60vh;
+}
+
+/* The Close Button */
+.modalClose {
+ color: #aaaaaa;
+ float: right;
+ font-size: 28px;
+ font-weight: bold;
+}
+
+.modalClose:hover,
+.modalClose:focus {
+ color: #000;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.servicemodalgrid {
+ display: grid;
+ grid-template-columns: 20% auto;
+ grid-column-gap: 10px;
+ grid-row-gap: 10px;
+}
+
+.action {
+ cursor: pointer;
+}
+
+.action-lg {
+ cursor: pointer;
+ font-size: 20px;
+}
+
+.scroll{
+ overflow-y: auto;
+ padding: 0 25px;
+}
+
+.scroll::-webkit-scrollbar-thumb,
+.scroll::-webkit-scrollbar-track {
+ visibility: hidden;
+}
+
+.scroll:hover::-webkit-scrollbar-thumb,
+.scroll:hover::-webkit-scrollbar-track {
+ visibility: visible;
+}
+
+.tag {
+ border-radius: 15px;
+}
+
+.tagText {
+ font-size: 12px;
+}
+
+#configurationParameterOptions {
+ height: 50px;
+ border-radius: 15px;
+ overflow-x: auto;
+}
+
+.dropdown {
+ position: relative;
+ display: inline-block;
+}
+
+#dropdown-content {
+ display: none;
+ position: absolute;
+ background-color: #f1f1f1;
+ min-width: 160px;
+ box-shadow: 0px 8px 16px 0px rgba(0, 0, 0, 0.2);
+ z-index: 1;
+}
+
+#dropdown-content a {
+ color: black;
+ padding: 12px 16px;
+ text-decoration: none;
+ display: block;
+}
+
+#dropdown-content a:hover {
+ background-color: #ddd;
+}
+
+.disabled_control {
+ pointer-events: none;
+}
+
+.index-btn{
+ width: 200px;
+}
+
+.config-desc{
+ font-size: 10px;
+}
\ No newline at end of file
diff --git a/manager/src/main/resources/static/js/workflow.js b/manager/src/main/resources/static/js/workflow.js
index 7f11b7c..40f155a 100644
--- a/manager/src/main/resources/static/js/workflow.js
+++ b/manager/src/main/resources/static/js/workflow.js
@@ -29,6 +29,22 @@
});
}
+function updateSelectedServicesWithParent(service) {
+ let parentId = $('#pserviceId').val();
+ if (parentId === '--'){
+ updateSelectedServices(service);
+ } else {
+ $.ajax({
+ type: 'POST',
+ url: '/select/' + parentId + '/' + service,
+ success: function(result) {
+ $('#selectedServicesBlock').load('/selectedServices');
+ $('[data-toggle="tooltip"]').tooltip();
+ }
+ });
+ }
+}
+
function selectServiceProfile(profile) {
$.ajax({
type: 'POST',
@@ -49,6 +65,161 @@
});
}
+function removeService() {
+ let parentId = $('#parentId').val();
+ let serviceKey = $('#serviceKey').val();
+
+ if (parentId === '--'){
+ removeSelectedServices(serviceKey);
+
+ }else if (parentId === '' && serviceKey === ''){
+ $.ajax({
+ type: 'POST',
+ url: '/removeall',
+ success: function(result) {
+ $('#selectedServicesBlock').load('/selectedServices');
+ }
+ });
+
+ }else{
+ $.ajax({
+ type: 'POST',
+ url: '/remove/' + parentId + '/' + serviceKey,
+ success: function(result) {
+ $('#selectedServicesBlock').load('/selectedServices');
+ }
+ });
+ }
+}
+
+function toggleBranch(element){
+ element.parentElement.querySelector(".nested").classList.toggle("active");
+ element.classList.toggle("caret");
+}
+
+function openSelectModal(parentService, parentServiceId){
+ $('#serviceModal').css("display", "block");
+ $('#pservice').val(parentService);
+ $('#pserviceId').val(parentServiceId);
+}
+
+function openRemoveAllServiceModal() {
+ $('#removeserviceModal').css("display", "block");
+ const header = "Delete All Services";
+ const text = "Are you sure you want to delete all services?";
+
+ $('#removeServiceHeader').text(header);
+ $('#removeServiceTxt').text(text);
+ $('#removeModalGrid').css("display", "none");
+ $('#parentId').val("")
+ $('#serviceKey').val("")
+}
+
+function openRemoveServiceModal(parentId, serviceKey) {
+ $('#removeserviceModal').css("display", "block");
+ const header = "Delete a service";
+ const text = "Are you sure you want to delete this service and all its children?";
+
+ $('#removeServiceHeader').text(header);
+ $('#removeServiceTxt').text(text);
+ $('#removeModalGrid').css("display", "grid");
+ $('#parentId').val(parentId)
+ $('#serviceKey').val(serviceKey)
+}
+
+function openConfigModal(serviceId) {
+ $.ajax({
+ url: '/config/' + serviceId,
+ success: function(result) {
+ $("#configModalHolder").html(result);
+ $('#configurationModal').css("display", "block");
+ }
+ });
+}
+
+function closeServiceModal(){
+ $('#serviceModal').hide();
+}
+
+function closeRemoveServiceModal(){
+ $('#removeserviceModal').hide();
+}
+
+function closeConfigModal(){
+ $('#configurationModal').hide();
+}
+
+function saveConfiguration(){
+ $('#configForm').ajaxSubmit({
+ success: function(result) {
+ $('#configurationModal').hide();
+ }
+ });
+}
+
+function resetConfiguration(serviceId){
+ $.ajax({
+ type: 'PUT',
+ url: '/config/' + serviceId,
+ success: function(result) {
+ $("#configModalHolder").html(result);
+ $('#configurationModal').css("display", "block");
+ }
+ });
+}
+
+function displayServiceProfileOptions() {
+ $('#dropdown-content').css("display", "block");
+ event.stopPropagation();
+}
+
+function addConfigurationParameterOption(element) {
+ $('#dropdown-content').hide();
+ const input = document.getElementById('configurationParameterOptionsSelected');
+ if(input.value.includes(element.text)) return;
+
+ if (input.value !== "") {
+ input.value = input.value.concat(',', element.text)
+ } else {
+ input.value = element.text;
+ }
+ $('#configurationParameterOptionsSelected').trigger("change");
+}
+
+function onConfigurationParameterOptionChanged(element) {
+ const valArray = element.value.split(',');
+ const div = document.getElementById('configurationParameterOptions');
+ div.innerHTML = '';
+ for (let index = 0; index < valArray.length; index++) {
+ if (valArray[index] !== '') {
+ const html =
+ '<div class="d-flex bg-info px-2 py-1 mr-1 align-middle tag">\
+ <p class="mr-1 my-auto pr-1 border-right border-secondary text-light text-nowrap tagText">' + valArray[index] + '</p>\
+ <a class="my-auto text-light tagClose" onclick="removeConfigurationParameterOption(this)">\
+ <i class="fas fa-times-circle"></i>\
+ </a>\
+ </div>';
+
+ div.innerHTML += html;
+ }
+ }
+}
+
+function removeConfigurationParameterOption(element) {
+ const value = element.previousElementSibling.innerText;
+ const input = document.getElementById('configurationParameterOptionsSelected');
+ let valArray = input.value.split(',');
+ valArray = valArray.filter(val => val !== value);
+ input.value = valArray.join();
+ $('#configurationParameterOptionsSelected').trigger("change");
+
+ event.stopPropagation()
+}
+
+function onConfigurationBackgroundClick() {
+ $('#dropdown-content').hide();
+}
+
function loadFragments() {
$('#selectedServicesBlock').load('/selectedServices');
$('#messagesBlock').load('/messages');
diff --git a/manager/src/main/resources/templates/configuration.html b/manager/src/main/resources/templates/configuration.html
new file mode 100644
index 0000000..590a791
--- /dev/null
+++ b/manager/src/main/resources/templates/configuration.html
@@ -0,0 +1,139 @@
+<!DOCTYPE html>
+<html xmlns:th="https://www.thymeleaf.org">
+
+<head>
+</head>
+
+<body>
+ <div id="configurationModal" onclick="onConfigurationBackgroundClick()" class="modal" th:fragment="config">
+ <!-- Modal content -->
+ <form id="configForm" action="#" th:action="@{/config/__${id}__}" method="post" th:object="${config}" class="modal-content">
+ <div class="d-flex justify-content-end">
+ <span class="modalClose" id="closeConfigModal" onclick="closeConfigModal()">×</span>
+ </div>
+ <h3 class="border-bottom mb-2">Configuration...</h3>
+
+ <div class="scroll">
+ <h5 th:text="${name + ' Service'}">Service</h5>
+ <div th:if="${config.serviceDescription != '' and config.serviceDescription != null}" class="d-flex flex-row mb-3 ml-3">
+ <i class="fas fa-info-circle text-secondary my-auto"></i>
+ <p th:text="${config.serviceDescription}" class="font-italic config-desc ml-2 my-auto text-secondary">Description</p>
+ </div>
+ <div class="servicemodalgrid mb-1">
+ <label for="parentId">Service Id:</label>
+ <input class="form-control" type="text" id="parentId" th:value="${id}" readonly>
+ </div>
+
+ <div class="mb-3">
+ <div th:each="parameter, parameterStatus : ${config.parameterList}">
+ <!-- Bind all unused variables of the configuration for a complete
+ representation of the configuration in the backend. But make them
+ invisible in the front end. -->
+ <div class="d-none">
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].cardinality}/>
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].type}/>
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].name}/>
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].key}/>
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].mandatory}/>
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].managerParameter}/>
+ <div th:each="pv, pvStatus : ${parameter.possibleValues}">
+ <input type="text" th:field=*{parameterList[__${parameterStatus.index}__].possibleValues[__${pvStatus.index}__]}/>
+ </div>
+ </div>
+
+ <!-- single non-boolean value -->
+ <div class="servicemodalgrid my-1" th:if="${parameter.cardinality == 'single' and parameter.type != 'boolean' and parameter.possibleValues.isEmpty()}">
+ <label class="form-check-label" th:text="${parameter.name + ':'}">Label</label>
+ <input
+ class="form-control"
+ type="text"
+ th:field="*{parameterList[__${parameterStatus.index}__].value}"
+ th:disabled="${uuid != null}"
+ th:title="*{parameterList[__${parameterStatus.index}__].description}"
+ data-toggle="tooltip"/>
+ </div>
+
+ <!-- single boolean value -->
+ <div th:if="${parameter.cardinality == 'single' and parameter.type == 'boolean'}" class="form-check">
+ <input
+ class="form-check-input"
+ type="checkbox"
+ th:field="*{parameterList[__${parameterStatus.index}__].value}"
+ th:value="true"
+ th:disabled="${uuid != null}">
+ <label
+ class="form-check-label"
+ th:text="${parameter.name}"
+ th:for="${parameter.key}"
+ th:title="*{parameterList[__${parameterStatus.index}__].description}"
+ data-toggle="tooltip">Label</label>
+ </div>
+
+ <!-- multiple possible values + cardinality multiple = tags -->
+ <div class="servicemodalgrid my-1" th:if="${parameter.cardinality == 'multiple' and parameter.possibleValues.size() > 1}">
+ <label
+ th:text="${parameter.name + ':'}"
+ th:title="*{parameterList[__${parameterStatus.index}__].description}"
+ data-toggle="tooltip">Label</label>
+
+ <!-- The visible input for the multiple values. The binding to the configuration object is not done here. -->
+ <div class="dropdown w-100">
+ <div id="configurationParameterOptions"
+ class="d-flex flex-wrap border p-2"
+ th:classappend="${uuid != null} ? 'disabled_service disabled_control' : ''"
+ onclick="displayServiceProfileOptions()">
+ <th:block th:each="pv : ${parameter.possibleValues}">
+ <div th:if="${parameter.value != null && parameter.value.contains(pv)}" class="d-flex bg-info px-2 py-1 mr-1 align-middle tag">
+ <p class="mr-1 my-auto pr-1 border-right border-secondary text-light text-nowrap tagText" th:text="${pv}">Text</p>
+ <a class="my-auto text-light tagClose"
+ th:classappend="${uuid != null} ? disabled_control : ''"
+ onclick="removeConfigurationParameterOption(this)">
+ <i class="fas fa-times-circle"></i>
+ </a>
+ </div>
+ </th:block>
+ </div>
+ <div id="dropdown-content">
+ <div th:each="pv : ${parameter.possibleValues}">
+ <a th:text="${pv}" onclick="addConfigurationParameterOption(this)">Text</a>
+ </div>
+ </div>
+ </div>
+
+ <!-- Bind the multiple values to this hidden input -->
+ <input
+ th:field="*{parameterList[__${parameterStatus.index}__].value}"
+ class="d-none"
+ id="configurationParameterOptionsSelected"
+ onchange="onConfigurationParameterOptionChanged(this)"/>
+ </div>
+
+ <!-- multiple possible values + cardinality single = combobox -->
+ <div class="servicemodalgrid my-1" th:if="${parameter.cardinality == 'single' and parameter.possibleValues.size() > 1}">
+ <label th:text="${parameter.name + ':'}">Label</label>
+ <select
+ class="custom-select"
+ th:field="*{parameterList[__${parameterStatus.index}__].value}"
+ th:disabled="${uuid != null}"
+ th:title="*{parameterList[__${parameterStatus.index}__].description}"
+ data-toggle="tooltip">
+ <option value=""></option>
+ <option
+ th:each="pv : ${parameter.possibleValues}"
+ th:text="${pv}"
+ th:value="${pv}">
+ </option>
+ </select>
+ </div>
+ </div>
+ </div>
+
+ <div class="d-flex justify-content-center my-4" th:if="${uuid == null}">
+ <a class="btn btn-secondary half-btn fm-ctrl-left" th:attr="onclick=|resetConfiguration('${id}')|">Reset</a>
+ <a class="btn btn-primary half-btn fm-ctrl-right" onclick="saveConfiguration()">Save</a>
+ </div>
+ </div>
+ </form>
+ </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/manager/src/main/resources/templates/index.html b/manager/src/main/resources/templates/index.html
index 9293d64..ca7ce04 100644
--- a/manager/src/main/resources/templates/index.html
+++ b/manager/src/main/resources/templates/index.html
@@ -22,10 +22,10 @@
</div>
<div class="row mt-2">
<div class="col text-right pr-1">
- <a class="btn btn-primary mt-2 fm-ctrl-left" href="/workflow">Start Workflow</a>
+ <a class="btn btn-light text-primary mt-2 fm-ctrl-left index-btn" href="/workflowConfig">Configure Workflow</a>
</div>
<div class="col pl-1">
- <a class="btn btn-primary mt-2 fm-ctrl-right" href="/workflowConfig">Configure Workflow</a>
+ <a class="btn btn-primary mt-2 fm-ctrl-right index-btn" href="/workflow">Start Workflow</a>
</div>
</div>
</div>
diff --git a/manager/src/main/resources/templates/selectedServices.html b/manager/src/main/resources/templates/selectedServices.html
index 26b0114..afbe15c 100644
--- a/manager/src/main/resources/templates/selectedServices.html
+++ b/manager/src/main/resources/templates/selectedServices.html
@@ -6,102 +6,96 @@
<body>
<div th:fragment="servicesList" id="selectedServices" th:object="${workflowStatus}">
- <ul class="list-group">
- <li th:each="selected : *{selectedServices}" class="list-group-item d-flex justify-content-between" th:classappend="*{uuid != null} ? disabled_service : ''">
- <p
- class="p-0 flex-grow-1 plist"
- th:text="${selected.service.name}"
- th:title="${selected.service.description}"
- data-toggle="tooltip"
- th:classappend="*{uuid != null} ? disabled_service : ''">Service</p>
- <a
- th:if="*{uuid == null}"
- th:attr="onclick=|removeSelectedServices('${selected.service.key}')|"
- class="btn btn-primary btn-sm btn-rmv">
- <i class="fas fa-times"></i>
- </a>
- </li>
- </ul>
- <select
- name="services"
- id="services"
- onchange="updateSelectedServices(this.value)"
- class="form-control"
- th:if="*{uuid == null}">
- <option></option>
- <option
- th:each="service : ${cloudServiceDefinitions}"
- th:text="${service.name}"
- th:value="${service.key}">
- </option>
- </select><br>
-
- <div th:if="not *{selectedServices.isEmpty()}">
- <div th:each="node, nodeStatus : *{selectedServices}" class="mb-3">
- <h4 th:text="${node.service.name + ' Configuration'}">Config</h4>
- <div th:text="${node.serviceConfiguration.serviceDescription}" class="font-italic mb-1">Description</div>
- <div th:each="parameter, parameterStatus : ${node.serviceConfiguration.parameterList}">
- <!-- single non-boolean value -->
- <div th:if="${parameter.cardinality == 'single' and parameter.type != 'boolean' and parameter.possibleValues.isEmpty()}">
- <label class="form-check-label" th:text="${parameter.name}">Label</label>
- <input
- class="form-control mb-1"
- type="text"
- th:field="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].value}"
- th:disabled="*{uuid != null}"
- th:title="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].description}"
- data-toggle="tooltip"/>
- </div>
- <!-- single boolean value -->
- <div th:if="${parameter.cardinality == 'single' and parameter.type == 'boolean'}" class="form-check">
- <input
- class="form-check-input mb-1"
- type="checkbox"
- th:field="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].value}"
- th:value="true"
- th:disabled="*{uuid != null}">
- <label
- class="form-check-label"
- th:text="${parameter.name}"
- th:for="${parameter.key}"
- th:title="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].description}"
- data-toggle="tooltip">Label</label>
- </div>
- <!-- multiple possible values + cardinality multiple = checkboxes -->
- <div th:if="${parameter.cardinality == 'multiple' and parameter.possibleValues.size() > 1}">
- <label
- th:text="${parameter.name}"th:title="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].description}"
- data-toggle="tooltip">Label</label>
- <div th:each="pv : ${parameter.possibleValues}" class="form-check">
- <input
- type="checkbox"
- th:field="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].value}"
- th:value="${pv}"
- th:disabled="*{uuid != null}">
- <label class="form-check-label" th:text="${pv}">Value</label>
- </div>
- </div>
- <!-- multiple possible values + cardinality single = combobox -->
- <div th:if="${parameter.cardinality == 'single' and parameter.possibleValues.size() > 1}">
- <label th:text="${parameter.name}">Label</label>
- <select
- class="form-control mb-1"
- th:field="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].value}"
- th:disabled="*{uuid != null}"
- th:title="*{selectedServices[__${nodeStatus.index}__].serviceConfiguration.parameterList[__${parameterStatus.index}__].description}"
- data-toggle="tooltip">
- <option value=""></option>
+ <div class="tree">
+ <span class="stem">Input</span>
+ <i class="fas fa-plus-circle text-primary action-lg"
+ onclick="openSelectModal('--', '--')"
+ title="Add service"
+ th:if="*{uuid == null}"></i>
+ <i class="fas fa-times-circle fa-2x text-black action-lg"
+ title="Delete All Services"
+ onclick="openRemoveAllServiceModal()"
+ th:if="not *{selectedServices.isEmpty()} and *{uuid == null}"></i>
+ <ul>
+ <li th:each="selected : *{selectedServices}" th:classappend="*{uuid != null} ? disabled_service : ''">
+ <span
+ th:class="${selected.children.isEmpty()}? space : caret-down"
+ th:text="${selected.isStructuralNode()? '' : selected.service.name}"
+ th:title="${selected.isStructuralNode()? '' : selected.service.description}"
+ onclick="toggleBranch(this)"
+ data-toggle="tooltip"
+ th:classappend="*{uuid != null} ? disabled_service : ''">Service 1</span>
+ <i class="fas fa-plus-circle text-primary action"
+ th:attr="onclick=|openSelectModal('${selected.isStructuralNode()? '' : selected.service.key}', '${selected.getQualifiedId()}')|"
+ title="Add Child Service"
+ th:if="*{uuid == null}"></i>
+ <i class="fas fa-times-circle text-black action"
+ th:attr="onclick=|openRemoveServiceModal('--', '${selected.isStructuralNode()? '' : selected.service.key}')|"
+ title="Delete Service"
+ th:if="*{uuid == null}"></i>
+ <i class="fas fa-cogs text-secondary action"
+ th:attr="onclick=|openConfigModal('${selected.getQualifiedId()}')|"
+ title="Service Configuration"></i>
+
+ <section th:include="serviceChildren :: children" th:with="children=${selected.children}, parent=${selected}"></section>
+ </li>
+ </ul>
+ </div>
+
+ <!-- The modal view for selecting child services -->
+ <div id="serviceModal" class="modal">
+ <!-- Modal content -->
+ <div class="modal-content">
+ <div class="d-flex justify-content-end">
+ <span class="modalClose" id="closemodal" onclick="closeServiceModal()">×</span>
+ </div>
+ <h3 class="border-bottom">Add service...</h3>
+ <div class="scroll">
+ <div class="servicemodalgrid">
+ <label for="pservice">Parent Service:</label>
+ <input class="form-control" type="text" id="pservice" readonly>
+ <label for="pserviceId">Parent Service Id:</label>
+ <input class="form-control" type="text" id="pserviceId" readonly>
+ <label for="serviceSelect">Service:</label>
+ <select id="serviceSelect"
+ class="form-control"
+ th:attr="onchange=|updateSelectedServicesWithParent(this.value)|">
+ <option></option>
<option
- th:each="pv : ${parameter.possibleValues}"
- th:text="${pv}"
- th:value="${pv}">
+ th:each="service : ${cloudServiceDefinitions}"
+ th:text="${service.name}"
+ th:value="${service.key}">
</option>
- </select>
- </div>
- </div>
- </div>
- </div>
-
+ </select>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- Remove Service Modal -->
+ <div id="removeserviceModal" class="modal">
+ <!-- Modal content -->
+ <div class="modal-content">
+ <div class="d-flex justify-content-end">
+ <span class="modalClose" id="closeRemoveModal" onclick="closeRemoveServiceModal()">×</span>
+ </div>
+ <h3 id="removeServiceHeader" class="border-bottom mb-2">Delete a service...</h3>
+ <div class="scroll">
+ <p id="removeServiceTxt">Are you sure you want to delete this service and all its children?</p>
+ <div class="servicemodalgrid" id="removeModalGrid">
+ <label for="parentId">Parent Service Id:</label>
+ <input class="form-control" type="text" id="parentId" readonly>
+ <label for="serviceKey">Service Key:</label>
+ <input class="form-control" type="text" id="serviceKey" readonly>
+ </div>
+ <div class="d-flex justify-content-center my-4">
+ <a class="btn btn-primary fm-ctrl-left half-btn" onclick="removeService()">Yes</a>
+ <a class="btn btn-secondary fm-ctrl-right half-btn" onclick="closeRemoveServiceModal()">No</a>
+ </div>
+ </div>
+ </div>
+ </div>
+
</div>
</body>
</html>
diff --git a/manager/src/main/resources/templates/serviceChildren.html b/manager/src/main/resources/templates/serviceChildren.html
new file mode 100644
index 0000000..b818ec0
--- /dev/null
+++ b/manager/src/main/resources/templates/serviceChildren.html
@@ -0,0 +1,35 @@
+<!DOCTYPE html>
+<html xmlns:th="https://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
+
+<head>
+</head>
+
+<body>
+ <div th:fragment="children">
+ <ul class="nested active">
+ <li th:each="child : ${children}">
+ <span
+ th:class="${child.children.isEmpty()}? space : caret-down"
+ th:text="${child.isStructuralNode()? '' : child.service.name}"
+ th:title="${child.isStructuralNode()? '' : child.service.description}"
+ onclick="toggleBranch(this)"
+ th:classappend="*{uuid != null} ? disabled_service : ''">Child Service</span>
+ <i class="fas fa-plus-circle text-primary action"
+ th:attr="onclick=|openSelectModal('${child.isStructuralNode()? '' : child.service.key}', '${child.getQualifiedId()}')|"
+ title="Add Child Service"
+ th:if="*{uuid == null}"></i>
+ <i class="fas fa-times-circle text-black action"
+ title="Delete Service"
+ th:attr="onclick=|openRemoveServiceModal('${child.parentNode.getQualifiedId()}', '${child.isStructuralNode()? '' : child.service.key}')|"
+ th:if="*{uuid == null}"></i>
+ <i class="fas fa-cogs text-secondary action"
+ th:attr="onclick=|openConfigModal('${child.getQualifiedId()}')|"
+ title="Service Configuration"
+ th:if="${not child.isStructuralNode()}"></i>
+
+ <section th:include="serviceChildren :: children" th:with="children=${child.children}, parent=${child}"></section>
+ </li>
+ </ul>
+ </div>
+</body>
+</html>
\ No newline at end of file
diff --git a/manager/src/main/resources/templates/workflow.html b/manager/src/main/resources/templates/workflow.html
index 1ce4f39..7ad11dd 100644
--- a/manager/src/main/resources/templates/workflow.html
+++ b/manager/src/main/resources/templates/workflow.html
@@ -80,6 +80,8 @@
<div id="resultsBlock"></div>
+ <!-- Configuration Modal -->
+ <div id="configModalHolder"></div>
</div>
</body>
</html>
\ No newline at end of file