diff options
Diffstat (limited to 'src/static')
-rw-r--r-- | src/static/css/base.css | 124 | ||||
-rw-r--r-- | src/static/js/dashboard.js | 1664 | ||||
-rw-r--r-- | src/static/js/workflows/book-a-pod.js | 708 | ||||
-rw-r--r-- | src/static/js/workflows/common-models.js | 189 | ||||
-rw-r--r-- | src/static/js/workflows/design-a-pod.js | 1186 | ||||
-rw-r--r-- | src/static/js/workflows/workflow.js | 246 |
6 files changed, 2452 insertions, 1665 deletions
diff --git a/src/static/css/base.css b/src/static/css/base.css index 12364bd..b6ab104 100644 --- a/src/static/css/base.css +++ b/src/static/css/base.css @@ -69,17 +69,39 @@ a[aria-expanded="true"] > i.rotate { /* Booking Node Styles */ .selected_node { border-color: #40c640; + border-width: 2px; box-shadow: 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(109, 243, 76, 0.6); transition: border-color ease-in-out .1s,box-shadow ease-in-out .1s; } +.invalid_field { + border-color: #c65040; + box-shadow: 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(243, 76, 76, 0.6); + transition: border-color ease-in-out .1s,box-shadow ease-in-out .1s; +} + /* Cursor effects */ .not-allowed { cursor: not-allowed; } +.z-n1 { + z-index: -1 !important; +} + +.z-0 { + z-index: 0 !important; +} + +.z-1 { +z-index: 1 !important; +} + .z-2 { - z-index: 2; + z-index: 2 !important; +} +.z-3 { + z-index: 3 !important; } .mh-30vh { @@ -91,3 +113,103 @@ a[aria-expanded="true"] > i.rotate { white-space: nowrap; overflow: hidden; } + +/* Design a pod Styles */ +.scroll-container { + position: absolute !important; /* Needed for proper functionality*/ + overflow: auto; + scroll-snap-type: y proximity; +} + +.scroll-area { + scroll-snap-align: start; + scroll-snap-stop: always; + min-height: 100vh; +} + +.add-button { + font-size: 3em; + font-weight: bolder; + text-align: center; + height: 2em; + width: 2em; + margin-left: auto; + margin-right: auto; +} + +.cancel-book-button { + font-size: 1em; + font-weight: bolder; + text-align: center; + height: 2em; + width: 10em; +} + +.input-search:focus { + border-color: none !important; + box-shadow: none !important; +} + +.arrow { + box-sizing: border-box; + height: 2vw; + width: 2vw; + border-style: solid; + border-color: black; + border-width: 0px 3px 3px 0px; + transition: border-width 150ms ease-in-out; + box-shadow: 0, 0, 100px, 100px, black; +} + +.arrow-down { + transform: rotate(45deg); +} + +.arrow-up { + transform: rotate(225deg); +} + +#next { + position: fixed !important; + bottom: 0; + left: 0; + background-color: white; + align-items: center; + justify-content: center; + z-index: 2 !important; +} + +#prev { + position: fixed !important; + left: 0; + background-color: white; + align-items: center; + justify-content: center; + z-index: 2 !important; +} + +#next:hover,#next:active { + background-color: #d4d4d4; +} + +#prev:hover,#prev:active { + background-color: #d4d4d4; +} + +.btn-workflow-nav { + box-shadow: none !important; +} + +.interface-btn { + color: inherit; +} + +.card-body-scroll { + height: 25vh; + overflow-y: auto; +} + +.overflow-control { + overflow-y: auto; + overflow-x: hidden; +}
\ No newline at end of file diff --git a/src/static/js/dashboard.js b/src/static/js/dashboard.js deleted file mode 100644 index a63c71b..0000000 --- a/src/static/js/dashboard.js +++ /dev/null @@ -1,1664 +0,0 @@ -/////////////////// -// Global Variables -/////////////////// - -form_submission_callbacks = []; //all runnables will be executed before form submission - -/////////////////// -// Global Functions -/////////////////// - -// Taken from https://docs.djangoproject.com/en/3.0/ref/csrf/ -function getCookie(name) { - var cookieValue = null; - if (document.cookie && document.cookie !== '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = cookies[i].trim(); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} - -function update_page(response) { - if( response.redirect ) - { - window.location.replace(response.redirect); - return; - } - draw_breadcrumbs(response.meta); - update_exit_button(response.meta); - update_side_buttons(response.meta); - $("#formContainer").html(response.content); -} - -function update_side_buttons(meta) { - const step = meta.active; - const page_count = meta.steps.length; - - const back_button = document.getElementById("workflow-nav-back"); - if (step == 0) { - back_button.classList.add("disabled"); - back_button.disabled = true; - } else { - back_button.classList.remove("disabled"); - back_button.disabled = false; - } - - const forward_btn = document.getElementById("workflow-nav-next"); - if (step == page_count - 1) { - forward_btn.classList.add("disabled"); - forward_btn.disabled = true; - } else { - forward_btn.classList.remove("disabled"); - forward_btn.disabled = false; - } -} - -function update_exit_button(meta) { - if (meta.workflow_count == 1) { - document.getElementById("cancel_btn").innerText = "Exit Workflow"; - } else { - document.getElementById("cancel_btn").innerText = "Return to Parent"; - } -} - -function draw_breadcrumbs(meta) { - $("#topPagination").children().not(".page-control").remove(); - - for (const i in meta.steps) { - const step_btn = create_step(meta.steps[i], i == meta["active"]); - $("#topPagination li:last-child").before(step_btn); - } -} - -function create_step(step_json, active) { - const step_dom = document.createElement("li"); - // First create the dom object depending on active or not - step_dom.className = "topcrumb"; - if (active) { - step_dom.classList.add("active"); - } - $(step_dom).html(`<span class="d-flex align-items-center justify-content-center text-capitalize w-100">${step_json['title']}</span>`) - - const code = step_json.valid; - - let stat = ""; - let msg = ""; - if (code < 100) { - $(step_dom).children().first().append("<i class='ml-2 far fa-square'></i>") - stat = ""; - msg = ""; - } else if (code < 200) { - $(step_dom).children().first().append("<i class='ml-2 fas fa-minus-square'></i>") - stat = "invalid"; - msg = step_json.message; - } else if (code < 300) { - $(step_dom).children().first().append("<i class='ml-2 far fa-check-square'></i>") - stat = "valid"; - msg = step_json.message; - } - - if (step_json.enabled == false) { - step_dom.classList.add("disabled"); - } - if (active) { - update_message(msg, stat); - } - - return step_dom; -} - -function update_description(title, desc) { - document.getElementById("view_title").innerText = title; - document.getElementById("view_desc").innerText = desc; -} - -function update_message(message, stepstatus) { - let color_code; - if (stepstatus == 'valid') { - color_code = 'text-success'; - } else if (stepstatus == 'invalid') { - color_code = 'text-danger'; - } else { - color_code = 'none'; - } - document.getElementById("view_message").innerText = message; - document.getElementById("view_message").className = "step_message"; - document.getElementById("view_message").classList.add("message_" + stepstatus); - document.getElementById("view_message").classList.add(color_code); -} - -function submitStepForm(next_step = "current"){ - run_form_callbacks(); - const step_form_data = $("#step_form").serialize(); - const form_data = $.param({ - "step": next_step, - "step_form": step_form_data, - "csrfmiddlewaretoken": $("[name=csrfmiddlewaretoken]").val() - }); - $.post( - '/workflow/manager/', - form_data, - (data) => update_page(data), - 'json' - ).fail(() => alert("failure")); -} - -function run_form_callbacks(){ - for(f of form_submission_callbacks) - f(); - form_submission_callbacks = []; -} - -function create_workflow(type) { - $.ajax({ - type: "POST", - url: "/workflow/create/", - data: { - "workflow_type": type - }, - headers: { - "X-CSRFToken": getCookie('csrftoken') - } - }).done(function (data, textStatus, jqXHR) { - window.location = "/workflow/"; - }).fail(function (jqxHR, textstatus) { - alert("Something went wrong..."); - }); -} - -function add_workflow(type) { - data = $.ajax({ - type: "POST", - url: "/workflow/add/", - data: { - "workflow_type": type - }, - headers: { - "X-CSRFToken": getCookie('csrftoken') - } - }).done(function (data, textStatus, jqXHR) { - update_page(data); - }).fail(function (jqxHR, textstatus) { - alert("Something went wrong..."); - }); -} - -function pop_workflow() { - data = $.ajax({ - type: "POST", - url: "/workflow/pop/", - headers: { - "X-CSRFToken": getCookie('csrftoken') - } - }).done(function (data, textStatus, jqXHR) { - update_page(data); - }).fail(function (jqxHR, textstatus) { - alert("Something went wrong..."); - }); -} - -function continue_workflow() { - window.location.replace("/workflow/"); -} - -/////////////////// -//Class Definitions -/////////////////// - -class MultipleSelectFilterWidget { - - constructor(neighbors, items, initial) { - this.inputs = []; - this.graph_neighbors = neighbors; - this.filter_items = items; - this.currentLab = null; - this.available_resources = {}; - this.result = {}; - this.dropdown_count = 0; - - for(let nodeId in this.filter_items) { - const node = this.filter_items[nodeId]; - this.result[node.class] = {} - } - - this.make_selection(initial); - } - - make_selection(initial_data){ - if(!initial_data || jQuery.isEmptyObject(initial_data)) - return; - - // Need to sort through labs first - let initial_lab = initial_data['lab']; - let initial_resources = initial_data['resource']; - - for( let node_id in initial_lab) { // This should only be length one - const node = this.filter_items[node_id]; - const selection_data = initial_lab[node_id]; - if( selection_data.selected ) { - this.select(node); - this.markAndSweep(node); - this.updateResult(node); - } - if(node['multiple']){ - this.make_multiple_selection(node, selection_data); - } - this.currentLab = node; - this.available_resources = JSON.parse(node['available_resources']); - } - - for( let node_id in initial_resources){ - const node = this.filter_items[node_id]; - const selection_data = initial_resources[node_id]; - if( selection_data.selected ) { - this.select(node); - this.markAndSweep(node); - this.updateResult(node); - } - if(node['multiple']){ - this.make_multiple_selection(node, selection_data); - } - } - this.updateAvailibility(); - } - - make_multiple_selection(node, selection_data){ - const prepop_data = selection_data.values; - for(let k in prepop_data){ - const div = this.add_item_prepopulate(node, prepop_data[k]); - this.updateObjectResult(node, div.id, prepop_data[k]); - } - } - - markAndSweep(root){ - for(let i in this.filter_items) { - const node = this.filter_items[i]; - node['marked'] = true; //mark all nodes - } - - const toCheck = [root]; - while(toCheck.length > 0){ - const node = toCheck.pop(); - - if(!node['marked']) { - continue; //already visited, just continue - } - - node['marked'] = false; //mark as visited - if(node['follow'] || node == root){ //add neighbors if we want to follow this node - const neighbors = this.graph_neighbors[node.id]; - for(let neighId of neighbors) { - const neighbor = this.filter_items[neighId]; - toCheck.push(neighbor); - } - } - } - - //now remove all nodes still marked - for(let i in this.filter_items){ - const node = this.filter_items[i]; - if(node['marked']){ - this.disable_node(node); - } - } - } - - process(node) { - if(node['selected']) { - this.markAndSweep(node); - } - else { //TODO: make this not dumb - const selected = [] - //remember the currently selected, then reset everything and reselect one at a time - for(let nodeId in this.filter_items) { - node = this.filter_items[nodeId]; - if(node['selected']) { - selected.push(node); - } - this.clear(node); - } - for(let node of selected) { - this.select(node); - this.markAndSweep(node); - } - } - } - - select(node) { - const elem = document.getElementById(node['id']); - node['selected'] = true; - elem.classList.remove('bg-white', 'not-allowed', 'bg-light'); - elem.classList.add('selected_node'); - - if(node['class'] == 'resource') - this.reserveResource(node); - - } - - clear(node) { - const elem = document.getElementById(node['id']); - node['selected'] = false; - node['selectable'] = true; - elem.classList.add('bg-white') - elem.classList.remove('not-allowed', 'bg-light', 'selected_node'); - } - - disable_node(node) { - const elem = document.getElementById(node['id']); - node['selected'] = false; - node['selectable'] = false; - elem.classList.remove('bg-white', 'selected_node'); - elem.classList.add('not-allowed', 'bg-light'); - } - - labCheck(node){ - // if lab is not already selected update available resources - if(!node['selected']) { - this.currentLab = node; - this.available_resources = JSON.parse(node['available_resources']); - this.updateAvailibility(); - } else { - // a lab is already selected, clear already selected resources - if(confirm('Unselecting a lab will reset all selected resources, are you sure?')) { - location.reload(); - return false; - } - } - return true; - } - - updateAvailibility() { - const lab_resources = this.graph_neighbors[this.currentLab.id]; - - // need to loop through and update all quantities - for(let i in lab_resources) { - const resource_node = this.filter_items[lab_resources[i]]; - const required_resources = JSON.parse(resource_node['required_resources']); - let elem = document.getElementById(resource_node.id).getElementsByClassName("grid-item-description")[0]; - let leastAvailable = 100; - let currCount; - let quantityDescription; - let quantityNode; - - for(let resource in required_resources) { - currCount = Math.floor(this.available_resources[resource] / required_resources[resource]); - if(currCount < leastAvailable) - leastAvailable = currCount; - - if(!currCount || currCount < 0) { - leastAvailable = 0 - break; - } - } - - if (elem.children[0]){ - elem.removeChild(elem.children[0]); - } - - quantityDescription = '<br> Quantity Currently Available: ' + leastAvailable; - quantityNode = document.createElement('P'); - if (leastAvailable > 0) { - quantityDescription = quantityDescription.fontcolor('green'); - } else { - quantityDescription = quantityDescription.fontcolor('red'); - } - - quantityNode.innerHTML = quantityDescription; - elem.appendChild(quantityNode) - } - } - - reserveResource(node){ - const required_resources = JSON.parse(node['required_resources']); - let hostname = document.getElementById('id_hostname'); - let image = document.getElementById('id_image'); - let cnt = 0 - - - for(let resource in required_resources){ - this.available_resources[resource] -= required_resources[resource]; - cnt += required_resources[resource]; - } - - if (cnt > 1 && hostname) { - hostname.readOnly = true; - // we only disable hostname modification because there is no sane case where you want all hosts to have the same hostname - // image is still allowed to be set across all hosts, but is filtered to the set of images that are commonly applicable still - // if no images exist that would apply to all hosts in a pod, then the user is restricted to not setting an image - // and the default image for each host is used - } - - this.updateAvailibility(); - } - - releaseResource(node){ - const required_resources = JSON.parse(node['required_resources']); - let hostname = document.getElementById('id_hostname'); - let image = document.getElementById('id_image'); - - for(let resource in required_resources){ - this.available_resources[resource] += required_resources[resource]; - } - - if (hostname && image) { - hostname.readOnly = false; - image.disabled = false; - } - - this.updateAvailibility(); - } - - processClick(id){ - let lab_check; - const node = this.filter_items[id]; - if(!node['selectable']) - return; - - // If they are selecting a lab, update accordingly - if (node['class'] == 'lab') { - lab_check = this.labCheck(node); - if (!lab_check) - return; - } - - // Can only select a resource if a lab is selected - if (!this.currentLab) { - alert('You must select a lab before selecting a resource'); - return; - } - - if(node['multiple']){ - return this.processClickMultiple(node); - } else { - return this.processClickSingle(node); - } - } - - processClickSingle(node){ - node['selected'] = !node['selected']; //toggle on click - if(node['selected']) { - this.select(node); - } else { - this.clear(node); - this.releaseResource(node); // can't do this in clear since clear removes border - } - this.process(node); - this.updateResult(node); - } - - processClickMultiple(node){ - this.select(node); - const div = this.add_item_prepopulate(node, false); - this.process(node); - this.updateObjectResult(node, div.id, ""); - } - - restrictchars(input){ - if( input.validity.patternMismatch ){ - input.setCustomValidity("Only alphanumeric characters (a-z, A-Z, 0-9), underscore(_), and hyphen (-) are allowed"); - input.reportValidity(); - } - input.value = input.value.replace(/([^A-Za-z0-9-_.])+/g, ""); - this.checkunique(input); - } - - checkunique(tocheck){ //TODO: use set - const val = tocheck.value; - for( let input of this.inputs ){ - if( input.value == val && input != tocheck){ - tocheck.setCustomValidity("All hostnames must be unique"); - tocheck.reportValidity(); - return; - } - } - tocheck.setCustomValidity(""); - } - - make_remove_button(div, node){ - const button = document.createElement("BUTTON"); - button.type = "button"; - button.appendChild(document.createTextNode("Remove")); - button.classList.add("btn", "btn-danger", "d-inline-block"); - const that = this; - button.onclick = function(){ that.remove_dropdown(div.id, node.id); } - return button; - } - - make_input(div, node, prepopulate){ - const input = document.createElement("INPUT"); - input.type = node.form.type; - input.name = node.id + node.form.name - input.classList.add("form-control", "w-auto", "d-inline-block"); - input.pattern = "(?=^.{1,253}$)(^([A-Za-z0-9-_]{1,62}\.)*[A-Za-z0-9-_]{1,63})"; - input.title = "Only alphanumeric characters (a-z, A-Z, 0-9), underscore(_), and hyphen (-) are allowed" - input.placeholder = node.form.placeholder; - this.inputs.push(input); - const that = this; - input.onchange = function() { that.updateObjectResult(node, div.id, input.value); that.restrictchars(this); }; - input.oninput = function() { that.restrictchars(this); }; - if(prepopulate) - input.value = prepopulate; - return input; - } - - add_item_prepopulate(node, prepopulate){ - const div = document.createElement("DIV"); - div.id = "dropdown_" + this.dropdown_count; - div.classList.add("card", "flex-row", "d-flex", "mb-2"); - this.dropdown_count++; - const label = document.createElement("H5") - label.appendChild(document.createTextNode(node['name'])) - label.classList.add("p-1", "m-1", "flex-grow-1"); - div.appendChild(label); - let remove_btn = this.make_remove_button(div, node); - remove_btn.classList.add("p-1", "m-1"); - div.appendChild(remove_btn); - document.getElementById("dropdown_wrapper").appendChild(div); - return div; - } - - remove_dropdown(div_id, node_id){ - const div = document.getElementById(div_id); - const node = this.filter_items[node_id] - const parent = div.parentNode; - div.parentNode.removeChild(div); - this.result[node.class][node.id]['count']--; - this.releaseResource(node); // This can't be done on clear b/c clear removes border - - //checks if we have removed last item in class - if(this.result[node.class][node.id]['count'] == 0){ - delete this.result[node.class][node.id]; - this.clear(node); - } - } - - updateResult(node){ - if(!node['multiple']){ - this.result[node.class][node.id] = {selected: node.selected, id: node.model_id} - if(!node.selected) - delete this.result[node.class][node.id]; - } - } - - updateObjectResult(node, childKey, childValue){ - if(!this.result[node.class][node.id]) - this.result[node.class][node.id] = {selected: true, id: node.model_id, count: 0} - - this.result[node.class][node.id]['count']++; - } - - finish(){ - document.getElementById("filter_field").value = JSON.stringify(this.result); - } -} - -class NetworkStep { - // expects: - // - // debug: bool - // resources: { - // id: { - // id: int, - // value: { - // description: string, - // }, - // interfaces: [ - // id: int, - // name: str, - // description: str, - // connections: [ - // { - // network: int, [networks.id] - // tagged: bool - // } - // ], - // ], - // } - // } - // networks: { - // id: { - // id: int, - // name: str, - // public: bool, - // } - // } - // - constructor(debug, resources, networks, graphContainer, overviewContainer, toolbarContainer){ - if(!this.check_support()) { - console.log("Aborting, browser is not supported"); - return; - } - - this.currentWindow = null; - this.netCount = 0; - this.netColors = ['red', 'blue', 'purple', 'green', 'orange', '#8CCDF5', '#1E9BAC']; - this.hostCount = 0; - this.lastHostBottom = 100; - this.networks = new Set(); - this.has_public_net = false; - this.debug = debug; - this.editor = new mxEditor(); - this.graph = this.editor.graph; - - window.global_graph = this.graph; - window.network_rr_index = 5; - - this.editor.setGraphContainer(graphContainer); - this.doGlobalConfig(); - - let mx_networks = {} - - for(const network_id in networks) { - let network = networks[network_id]; - - mx_networks[network_id] = this.populateNetwork(network); - } - - this.prefillHosts(resources, mx_networks); - - //this.addToolbarButton(this.editor, toolbarContainer, 'zoomIn', '', "/static/img/mxgraph/zoom_in.png", true); - //this.addToolbarButton(this.editor, toolbarContainer, 'zoomOut', '', "/static/img/mxgraph/zoom_out.png", true); - this.addToolbarButton(this.editor, toolbarContainer, 'zoomIn', 'fa-search-plus'); - this.addToolbarButton(this.editor, toolbarContainer, 'zoomOut', 'fa-search-minus'); - - if(this.debug){ - this.editor.addAction('printXML', function(editor, cell) { - mxLog.write(this.encodeGraph()); - mxLog.show(); - }.bind(this)); - this.addToolbarButton(this.editor, toolbarContainer, 'printXML', 'fa-file-code'); - } - - new mxOutline(this.graph, overviewContainer); - //sets the edge color to be the same as the network - this.graph.addListener(mxEvent.CELL_CONNECTED, function(sender, event) {this.cellConnectionHandler(sender, event)}.bind(this)); - //hooks up double click functionality - this.graph.dblClick = function(evt, cell) {this.doubleClickHandler(evt, cell);}.bind(this); - } - - check_support(){ - if (!mxClient.isBrowserSupported()) { - mxUtils.error('Browser is not supported', 200, false); - return false; - } - return true; - } - - /** - * Expects - * mx_interface: mxCell for the interface itself - * network: mxCell for the outer network - * tagged: bool - */ - connectNetwork(mx_interface, network, tagged) { - var cell = new mxCell( - "connection from " + network + " to " + mx_interface, - new mxGeometry(0, 0, 50, 50)); - cell.edge = true; - cell.geometry.relative = true; - cell.setValue(JSON.stringify({tagged: tagged})); - - let terminal = this.getClosestNetworkCell(mx_interface.geometry.y, network); - let edge = this.graph.addEdge(cell, null, mx_interface, terminal); - this.colorEdge(edge, terminal, true); - this.graph.refresh(edge); - } - - /** - * Expects: - * - * to: desired y axis position of the matching cell - * within: graph cell for a full network, with all child cells - * - * Returns: - * an mx cell, the one vertically closest to the desired value - * - * Side effect: - * modifies the <rr_index> on the <within> parameter - */ - getClosestNetworkCell(to, within) { - if(window.network_rr_index === undefined) { - window.network_rr_index = 5; - } - - let child_keys = within.children.keys(); - let children = Array.from(within.children); - let index = (window.network_rr_index++) % children.length; - - let child = within.children[child_keys[index]]; - - return children[index]; - } - - /** Expects - * - * hosts: { - * id: { - * id: int, - * value: { - * description: string, - * }, - * interfaces: [ - * id: int, - * name: str, - * description: str, - * connections: [ - * { - * network: int, [networks.id] - * tagged: bool - * } - * ], - * ], - * } - * } - * - * network_mappings: { - * <django network id>: <mxnetwork id> - * } - * - * draws given hosts into the mxgraph - */ - prefillHosts(hosts, network_mappings){ - for(const host_id in hosts) { - this.makeHost(hosts[host_id], network_mappings); - } - } - - cellConnectionHandler(sender, event){ - const edge = event.getProperty('edge'); - const terminal = event.getProperty('terminal') - const source = event.getProperty('source'); - if(this.checkAllowed(edge, terminal, source)) { - this.colorEdge(edge, terminal, source); - this.alertVlan(edge, terminal, source); - } - } - - doubleClickHandler(evt, cell) { - if( cell != null ){ - if( cell.getParent() != null && cell.getParent().getId().indexOf("network") > -1) { - cell = cell.getParent(); - } - if( cell.isEdge() || cell.getId().indexOf("network") > -1 ) { - this.createDeleteDialog(cell.getId()); - } - else { - this.showDetailWindow(cell); - } - } - } - - alertVlan(edge, terminal, source) { - if( terminal == null || edge.getTerminal(!source) == null) { - return; - } - const form = document.createElement("form"); - const tagged = document.createElement("input"); - tagged.type = "radio"; - tagged.name = "tagged"; - tagged.value = "True"; - tagged.checked = "True"; - form.appendChild(tagged); - form.appendChild(document.createTextNode(" Tagged")); - form.appendChild(document.createElement("br")); - - const untagged = document.createElement("input"); - untagged.type = "radio"; - untagged.name = "tagged"; - untagged.value = "False"; - form.appendChild(untagged); - form.appendChild(document.createTextNode(" Untagged")); - form.appendChild(document.createElement("br")); - - const yes_button = document.createElement("button"); - yes_button.onclick = function() {this.parseVlanWindow(edge.getId());}.bind(this); - yes_button.appendChild(document.createTextNode("Okay")); - - const cancel_button = document.createElement("button"); - cancel_button.onclick = function() {this.deleteVlanWindow(edge.getId());}.bind(this); - cancel_button.appendChild(document.createTextNode("Cancel")); - - const error_div = document.createElement("div"); - error_div.id = "current_window_errors"; - form.appendChild(error_div); - - const content = document.createElement('div'); - content.appendChild(form); - content.appendChild(yes_button); - content.appendChild(cancel_button); - this.showWindow("Vlan Selection", content, 200, 200); - } - - createDeleteDialog(id) { - const content = document.createElement('div'); - const remove_button = document.createElement("button"); - remove_button.style.width = '46%'; - remove_button.onclick = function() { this.deleteCell(id);}.bind(this); - remove_button.appendChild(document.createTextNode("Remove")); - const cancel_button = document.createElement("button"); - cancel_button.style.width = '46%'; - cancel_button.onclick = function() { this.closeWindow();}.bind(this); - cancel_button.appendChild(document.createTextNode("Cancel")); - - content.appendChild(remove_button); - content.appendChild(cancel_button); - this.showWindow('Do you want to delete this network?', content, 200, 62); - } - - checkAllowed(edge, terminal, source) { - //check if other terminal is null, and that they are different - const otherTerminal = edge.getTerminal(!source); - if(terminal != null && otherTerminal != null) { - if( terminal.getParent().getId().split('_')[0] == //'host' or 'network' - otherTerminal.getParent().getId().split('_')[0] ) { - //not allowed - this.graph.removeCells([edge]); - return false; - } - } - return true; - } - - colorEdge(edge, terminal, source) { - if(terminal.getParent().getId().indexOf('network') >= 0) { - const styles = terminal.getParent().getStyle().split(';'); - let color = 'black'; - for(let style of styles){ - const kvp = style.split('='); - if(kvp[0] == "fillColor"){ - color = kvp[1]; - } - } - - edge.setStyle('strokeColor=' + color); - } else { - console.log("Failed to color " + edge + ", " + terminal + ", " + source); - } - } - - showDetailWindow(cell) { - const info = JSON.parse(cell.getValue()); - const content = document.createElement("div"); - const pre_tag = document.createElement("pre"); - pre_tag.appendChild(document.createTextNode("Name: " + info.name + "\nDescription:\n" + info.description)); - const ok_button = document.createElement("button"); - ok_button.onclick = function() { this.closeWindow();}; - content.appendChild(pre_tag); - content.appendChild(ok_button); - this.showWindow('Details', content, 400, 400); - } - - restoreFromXml(xml, editor) { - const doc = mxUtils.parseXml(xml); - const node = doc.documentElement; - editor.readGraphModel(node); - - //Iterate over all children, and parse the networks to add them to the sidebar - for( const cell of this.graph.getModel().getChildren(this.graph.getDefaultParent())) { - if(cell.getId().indexOf("network") > -1) { - const info = JSON.parse(cell.getValue()); - const name = info['name']; - this.networks.add(name); - const styles = cell.getStyle().split(";"); - let color = null; - for(const style of styles){ - const kvp = style.split('='); - if(kvp[0] == "fillColor") { - color = kvp[1]; - break; - } - } - if(info.public){ - this.has_public_net = true; - } - this.netCount++; - this.makeSidebarNetwork(name, color, cell.getId()); - } - } - } - - deleteCell(cellId) { - var cell = this.graph.getModel().getCell(cellId); - if( cellId.indexOf("network") > -1 ) { - let elem = document.getElementById(cellId); - elem.parentElement.removeChild(elem); - } - this.graph.removeCells([cell]); - this.currentWindow.destroy(); - } - - newNetworkWindow() { - const input = document.createElement("input"); - input.type = "text"; - input.name = "net_name"; - input.maxlength = 100; - input.id = "net_name_input"; - input.style.margin = "5px"; - - const yes_button = document.createElement("button"); - yes_button.onclick = function() {this.parseNetworkWindow();}.bind(this); - yes_button.appendChild(document.createTextNode("Okay")); - - const cancel_button = document.createElement("button"); - cancel_button.onclick = function() {this.closeWindow();}.bind(this); - cancel_button.appendChild(document.createTextNode("Cancel")); - - const error_div = document.createElement("div"); - error_div.id = "current_window_errors"; - - const content = document.createElement("div"); - content.appendChild(document.createTextNode("Name: ")); - content.appendChild(input); - content.appendChild(document.createElement("br")); - content.appendChild(yes_button); - content.appendChild(cancel_button); - content.appendChild(document.createElement("br")); - content.appendChild(error_div); - - this.showWindow("Network Creation", content, 300, 300); - } - - parseNetworkWindow() { - const net_name = document.getElementById("net_name_input").value - const error_div = document.getElementById("current_window_errors"); - if( this.networks.has(net_name) ){ - error_div.innerHTML = "All network names must be unique"; - return; - } - this.addNetwork(net_name); - this.currentWindow.destroy(); - } - - addToolbarButton(editor, toolbar, action, image) { - const button = document.createElement('button'); - button.setAttribute('class', 'btn btn-sm m-1'); - if (image != null) { - const icon = document.createElement('i'); - icon.setAttribute('class', 'fas ' + image); - button.appendChild(icon); - } - mxEvent.addListener(button, 'click', function(evt) { - editor.execute(action); - }); - mxUtils.write(button, ''); - toolbar.appendChild(button); - }; - - encodeGraph() { - const encoder = new mxCodec(); - const xml = encoder.encode(this.graph.getModel()); - return mxUtils.getXml(xml); - } - - doGlobalConfig() { - //general graph stuff - this.graph.setMultigraph(false); - this.graph.setCellsSelectable(false); - this.graph.setCellsMovable(false); - - //testing - this.graph.vertexLabelIsMovable = true; - - //edge behavior - this.graph.setConnectable(true); - this.graph.setAllowDanglingEdges(false); - mxEdgeHandler.prototype.snapToTerminals = true; - mxConstants.MIN_HOTSPOT_SIZE = 16; - mxConstants.DEFAULT_HOTSPOT = 1; - //edge 'style' (still affects behavior greatly) - const style = this.graph.getStylesheet().getDefaultEdgeStyle(); - style[mxConstants.STYLE_EDGE] = mxConstants.EDGESTYLE_ELBOW; - style[mxConstants.STYLE_ENDARROW] = mxConstants.NONE; - style[mxConstants.STYLE_ROUNDED] = true; - style[mxConstants.STYLE_FONTCOLOR] = 'black'; - style[mxConstants.STYLE_STROKECOLOR] = 'red'; - style[mxConstants.STYLE_LABEL_BACKGROUNDCOLOR] = '#FFFFFF'; - style[mxConstants.STYLE_STROKEWIDTH] = '3'; - style[mxConstants.STYLE_ROUNDED] = true; - style[mxConstants.STYLE_EDGE] = mxEdgeStyle.EntityRelation; - - const hostStyle = this.graph.getStylesheet().getDefaultVertexStyle(); - hostStyle[mxConstants.STYLE_ROUNDED] = 1; - - this.graph.convertValueToString = function(cell) { - try{ - //changes value for edges with xml value - if(cell.isEdge()) { - if(JSON.parse(cell.getValue())["tagged"]) { - return "tagged"; - } - return "untagged"; - } - else{ - return JSON.parse(cell.getValue())['name']; - } - } - catch(e){ - return cell.getValue(); - } - }; - } - - showWindow(title, content, width, height) { - //create transparent black background - const background = document.createElement('div'); - background.style.position = 'absolute'; - background.style.left = '0px'; - background.style.top = '0px'; - background.style.right = '0px'; - background.style.bottom = '0px'; - background.style.background = 'black'; - mxUtils.setOpacity(background, 50); - document.body.appendChild(background); - - const x = Math.max(0, document.body.scrollWidth/2-width/2); - const y = Math.max(10, (document.body.scrollHeight || - document.documentElement.scrollHeight)/2-height*2/3); - - const wnd = new mxWindow(title, content, x, y, width, height, false, true); - wnd.setClosable(false); - - wnd.addListener(mxEvent.DESTROY, function(evt) { - this.graph.setEnabled(true); - mxEffects.fadeOut(background, 50, true, 10, 30, true); - }.bind(this)); - this.currentWindow = wnd; - - this.graph.setEnabled(false); - this.currentWindow.setVisible(true); - }; - - closeWindow() { - //allows the current window to be destroyed - this.currentWindow.destroy(); - }; - - othersUntagged(edgeID) { - const edge = this.graph.getModel().getCell(edgeID); - const end1 = edge.getTerminal(true); - const end2 = edge.getTerminal(false); - - if( end1.getParent().getId().split('_')[0] == 'host' ){ - var netint = end1; - } else { - var netint = end2; - } - - var edges = netint.edges; - for( let edge of edges) { - if( edge.getValue() ) { - var tagged = JSON.parse(edge.getValue()).tagged; - } else { - var tagged = true; - } - if( !tagged ) { - return true; - } - } - - return false; - }; - - - deleteVlanWindow(edgeID) { - const cell = this.graph.getModel().getCell(edgeID); - this.graph.removeCells([cell]); - this.currentWindow.destroy(); - } - - parseVlanWindow(edgeID) { - //do parsing and data manipulation - const radios = document.getElementsByName("tagged"); - const edge = this.graph.getModel().getCell(edgeID); - - for(let radio of radios){ - if(radio.checked) { - //set edge to be tagged or untagged - if( radio.value == "False") { - if( this.othersUntagged(edgeID) ) { - document.getElementById("current_window_errors").innerHTML = "Only one untagged vlan per interface is allowed."; - return; - } - } - const edgeVal = {tagged: radio.value == "True"}; - edge.setValue(JSON.stringify(edgeVal)); - break; - } - } - this.graph.refresh(edge); - this.closeWindow(); - } - - makeMxNetwork(net_name, is_public = false) { - const model = this.graph.getModel(); - const width = 10; - const height = 1700; - const xoff = 400 + (30 * this.netCount); - const yoff = -10; - let color = this.netColors[this.netCount]; - if( this.netCount > (this.netColors.length - 1)) { - color = Math.floor(Math.random() * 16777215); //int in possible color space - color = '#' + color.toString(16).toUpperCase(); //convert to hex - } - const net_val = { name: net_name, public: is_public}; - const net = this.graph.insertVertex( - this.graph.getDefaultParent(), - 'network_' + this.netCount, - JSON.stringify(net_val), - xoff, - yoff, - width, - height, - 'fillColor=' + color, - false - ); - const num_ports = 45; - for(var i=0; i<num_ports; i++){ - let port = this.graph.insertVertex( - net, - null, - '', - 0, - (1/num_ports) * i, - 10, - height / num_ports, - 'fillColor=black;opacity=0', - true - ); - } - - const ret_val = { color: color, element_id: "network_" + this.netCount }; - - this.networks.add(net_name); - this.netCount++; - return ret_val; - } - - // expects: - // - // { - // id: int, - // name: str, - // public: bool, - // } - // - // returns: - // mxgraph id of network - populateNetwork(network) { - let mxNet = this.makeMxNetwork(network.name, network.public); - this.makeSidebarNetwork(network.name, mxNet.color, mxNet.element_id); - - if( network.public ) { - this.has_public_net = true; - } - - return mxNet.element_id; - } - - addPublicNetwork() { - const net = this.makeMxNetwork("public", true); - this.makeSidebarNetwork("public", net['color'], net['element_id']); - this.has_public_net = true; - } - - addNetwork(net_name) { - const ret = this.makeMxNetwork(net_name); - this.makeSidebarNetwork(net_name, ret.color, ret.element_id); - } - - updateHosts(removed) { - const cells = [] - for(const hostID of removed) { - cells.push(this.graph.getModel().getCell("host_" + hostID)); - } - this.graph.removeCells(cells); - - const hosts = this.graph.getChildVertices(this.graph.getDefaultParent()); - let topdist = 100; - for(const i in hosts) { - const host = hosts[i]; - if(host.id.startsWith("host_")){ - const geometry = host.getGeometry(); - geometry.y = topdist + 50; - topdist = geometry.y + geometry.height; - host.setGeometry(geometry); - } - } - } - - makeSidebarNetwork(net_name, color, net_id){ - const colorBlob = document.createElement("div"); - colorBlob.className = "square-20 rounded-circle"; - colorBlob.style['background'] = color; - - const textContainer = document.createElement("span"); - textContainer.className = "ml-2"; - textContainer.appendChild(document.createTextNode(net_name)); - - const timesIcon = document.createElement("i"); - timesIcon.classList.add("fas", "fa-times"); - - const deletebutton = document.createElement("button"); - deletebutton.className = "btn btn-danger ml-auto square-20 p-0 d-flex justify-content-center"; - deletebutton.appendChild(timesIcon); - deletebutton.addEventListener("click", function() { this.createDeleteDialog(net_id); }.bind(this), false); - - const newNet = document.createElement("li"); - newNet.classList.add("list-group-item", "d-flex", "bg-light"); - newNet.id = net_id; - newNet.appendChild(colorBlob); - newNet.appendChild(textContainer); - - if( net_name != "public" ) { - newNet.appendChild(deletebutton); - } - document.getElementById("network_list").appendChild(newNet); - } - - /** - * Expects format: - * { - * 'id': int, - * 'value': { - * 'description': string, - * }, - * 'interfaces': [ - * { - * id: int, - * name: str, - * description: str, - * connections: [ - * { - * network: int, <django network id>, - * tagged: bool - * } - * ] - * } - * ] - * } - * - * network_mappings: { - * <django network id>: <mxnetwork id> - * } - */ - makeHost(hostInfo, network_mappings) { - const value = JSON.stringify(hostInfo['value']); - const interfaces = hostInfo['interfaces']; - const width = 100; - const height = (25 * interfaces.length) + 25; - const xoff = 75; - const yoff = this.lastHostBottom + 50; - this.lastHostBottom = yoff + height; - const host = this.graph.insertVertex( - this.graph.getDefaultParent(), - 'host_' + hostInfo['id'], - value, - xoff, - yoff, - width, - height, - 'editable=0', - false - ); - host.getGeometry().offset = new mxPoint(-50,0); - host.setConnectable(false); - this.hostCount++; - - for(var i=0; i<interfaces.length; i++) { - const port = this.graph.insertVertex( - host, - null, - JSON.stringify(interfaces[i]), - 90, - (i * 25) + 12, - 20, - 20, - 'fillColor=blue;editable=0', - false - ); - port.getGeometry().offset = new mxPoint(-4*interfaces[i].name.length -2,0); - const iface = interfaces[i]; - for( const connection of iface.connections ) { - const network = this - .graph - .getModel() - .getCell(network_mappings[connection.network]); - - this.connectNetwork(port, network, connection.tagged); - } - this.graph.refresh(port); - } - this.graph.refresh(host); - } - - prepareForm() { - const input_elem = document.getElementById("hidden_xml_input"); - input_elem.value = this.encodeGraph(this.graph); - } -} - -class SearchableSelectMultipleWidget { - constructor(format_vars, field_dataset, field_initial) { - this.format_vars = format_vars; - this.items = field_dataset; - this.initial = field_initial; - - this.expanded_name_trie = {"isComplete": false}; - this.small_name_trie = {"isComplete": false}; - this.string_trie = {"isComplete": false}; - - this.added_items = new Set(); - - for( let e of ["show_from_noentry", "show_x_results", "results_scrollable", "selectable_limit", "placeholder"] ) - { - this[e] = format_vars[e]; - } - - this.search_field_init(); - - if( this.show_from_noentry ) - { - this.search(""); - } - } - - disable() { - const textfield = document.getElementById("user_field"); - const drop = document.getElementById("drop_results"); - - textfield.disabled = "True"; - drop.style.display = "none"; - - const btns = document.getElementsByClassName("btn-remove"); - for( const btn of btns ) - { - btn.classList.add("disabled"); - btn.onclick = ""; - } - } - - search_field_init() { - this.build_all_tries(this.items); - - for( const elem of this.initial ) - { - this.select_item(elem); - } - if(this.initial.length == 1) - { - this.search(this.items[this.initial[0]]["small_name"]); - document.getElementById("user_field").value = this.items[this.initial[0]]["small_name"]; - } - } - - build_all_tries(dict) - { - for( const key in dict ) - { - this.add_item(dict[key]); - } - } - - add_item(item) - { - const id = item['id']; - this.add_to_tree(item['expanded_name'], id, this.expanded_name_trie); - this.add_to_tree(item['small_name'], id, this.small_name_trie); - this.add_to_tree(item['string'], id, this.string_trie); - } - - add_to_tree(str, id, trie) - { - let inner_trie = trie; - while( str ) - { - if( !inner_trie[str.charAt(0)] ) - { - var new_trie = {}; - inner_trie[str.charAt(0)] = new_trie; - } - else - { - var new_trie = inner_trie[str.charAt(0)]; - } - - if( str.length == 1 ) - { - new_trie.isComplete = true; - if( !new_trie.ids ) - { - new_trie.ids = []; - } - new_trie.ids.push(id); - } - inner_trie = new_trie; - str = str.substring(1); - } - } - - search(input) - { - if( input.length == 0 && !this.show_from_noentry){ - this.dropdown([]); - return; - } - else if( input.length == 0 && this.show_from_noentry) - { - this.dropdown(this.items); //show all items - } - else - { - const trees = [] - const tr1 = this.getSubtree(input, this.expanded_name_trie); - trees.push(tr1); - const tr2 = this.getSubtree(input, this.small_name_trie); - trees.push(tr2); - const tr3 = this.getSubtree(input, this.string_trie); - trees.push(tr3); - const results = this.collate(trees); - this.dropdown(results); - } - } - - getSubtree(input, given_trie) - { - /* - recursive function to return the trie accessed at input - */ - - if( input.length == 0 ){ - return given_trie; - } - - else{ - const substr = input.substring(0, input.length - 1); - const last_char = input.charAt(input.length-1); - const subtrie = this.getSubtree(substr, given_trie); - - if( !subtrie ) //substr not in the trie - { - return {}; - } - - const indexed_trie = subtrie[last_char]; - return indexed_trie; - } - } - - serialize(trie) - { - /* - takes in a trie and returns a list of its item id's - */ - let itemIDs = []; - if ( !trie ) - { - return itemIDs; //empty, base case - } - for( const key in trie ) - { - if(key.length > 1) - { - continue; - } - itemIDs = itemIDs.concat(this.serialize(trie[key])); - } - if ( trie.isComplete ) - { - itemIDs.push(...trie.ids); - } - - return itemIDs; - } - - collate(trees) - { - /* - takes a list of tries - returns a list of ids of objects that are available - */ - const results = []; - for( const tree of trees ) - { - const available_IDs = this.serialize(tree); - - for( const itemID of available_IDs ) { - results[itemID] = this.items[itemID]; - } - } - return results; - } - - generate_element_text(obj) - { - const content_strings = [obj.expanded_name, obj.small_name, obj.string].filter(x => Boolean(x)); - const result = content_strings.shift(); - if( result == null || content_strings.length < 1) { - return result; - } else { - return result + " (" + content_strings.join(", ") + ")"; - } - } - - dropdown(ids) - { - /* - takes in a mapping of ids to objects in items - and displays them in the dropdown - */ - const drop = document.getElementById("drop_results"); - while(drop.firstChild) - { - drop.removeChild(drop.firstChild); - } - - for( const id in ids ) - { - const obj = this.items[id]; - const result_text = this.generate_element_text(obj); - const result_entry = document.createElement("a"); - result_entry.href = "#"; - result_entry.innerText = result_text; - result_entry.title = result_text; - result_entry.classList.add("list-group-item", "list-group-item-action", "overflow-ellipsis", "flex-shrink-0"); - result_entry.onclick = function() { searchable_select_multiple_widget.select_item(obj.id); }; - const tooltip = document.createElement("span"); - const tooltiptext = document.createTextNode(result_text); - tooltip.appendChild(tooltiptext); - tooltip.classList.add("d-none"); - result_entry.appendChild(tooltip); - drop.appendChild(result_entry); - } - - const scroll_restrictor = document.getElementById("scroll_restrictor"); - - if( !drop.firstChild ) - { - scroll_restrictor.style.visibility = 'hidden'; - } - else - { - scroll_restrictor.style.visibility = 'inherit'; - } - } - - select_item(item_id) - { - if( (this.selectable_limit > -1 && this.added_items.size < this.selectable_limit) || this.selectable_limit < 0 ) - { - this.added_items.add(item_id); - } - this.update_selected_list(); - // clear search bar contents - document.getElementById("user_field").value = ""; - document.getElementById("user_field").focus(); - this.search(""); - } - - remove_item(item_id) - { - this.added_items.delete(item_id); - - this.update_selected_list() - document.getElementById("user_field").focus(); - } - - update_selected_list() - { - document.getElementById("added_number").innerText = this.added_items.size; - const selector = document.getElementById('selector'); - selector.value = JSON.stringify([...this.added_items]); - const added_list = document.getElementById('added_list'); - - while(selector.firstChild) - { - selector.removeChild(selector.firstChild); - } - while(added_list.firstChild) - { - added_list.removeChild(added_list.firstChild); - } - - const list_html = document.createElement("div"); - list_html.classList.add("list-group"); - - for( const item_id of this.added_items ) - { - const times = document.createElement("li"); - times.classList.add("fas", "fa-times"); - - const deleteButton = document.createElement("a"); - deleteButton.href = "#"; - deleteButton.innerHTML = "<i class='fas fa-times'></i>" - // Setting .onclick/.addEventListener does not work, - // which is why I took the setAttribute approach - // If anyone knows why, please let me know :] - deleteButton.setAttribute("onclick", `searchable_select_multiple_widget.remove_item(${item_id});`); - deleteButton.classList.add("btn"); - const deleteColumn = document.createElement("div"); - deleteColumn.classList.add("col-auto"); - deleteColumn.append(deleteButton); - - const item = this.items[item_id]; - const element_entry_text = this.generate_element_text(item); - const textColumn = document.createElement("div"); - textColumn.classList.add("col", "overflow-ellipsis"); - textColumn.innerText = element_entry_text; - textColumn.title = element_entry_text; - - const itemRow = document.createElement("div"); - itemRow.classList.add("list-group-item", "d-flex", "p-0", "align-items-center"); - itemRow.append(textColumn, deleteColumn); - - list_html.append(itemRow); - } - added_list.innerHTML = list_html.innerHTML; - } -} diff --git a/src/static/js/workflows/book-a-pod.js b/src/static/js/workflows/book-a-pod.js new file mode 100644 index 0000000..d573342 --- /dev/null +++ b/src/static/js/workflows/book-a-pod.js @@ -0,0 +1,708 @@ +/** + * book-a-pod.js + */ + +const steps = { + SELECT_TEMPLATE: 0, + CLOUD_INIT: 1, + BOOKING_DETAILS: 2, + ADD_COLLABS: 3, + BOOKING_SUMMARY: 4 + } + + class BookingWorkflow extends Workflow { + constructor(savedBookingBlob) { + super(["select_template", "cloud_init", "booking_details" ,"add_collabs", "booking_summary"]) + + // if (savedBookingBlob) { + // this.resume_workflow() + // } + + this.bookingBlob = new BookingBlob({}); + this.userTemplates = null; + } + + async startWorkflow() { + this.userTemplates = await LibLaaSAPI.getTemplatesForUser() // List<TemplateBlob> + GUI.displayTemplates(this.userTemplates); + GUI.modifyCollabWidget(); + this.setEventListeners(); + document.getElementById(this.sections[0]).scrollIntoView({behavior: 'smooth'}); + } + + setEventListeners() { + const ci_textarea = document.getElementById('ci-textarea'); + ci_textarea.value = "" + ci_textarea.addEventListener('focusin', this.onFocusInCIFile); + ci_textarea.addEventListener('focusout', this.onFocusOutCIFile); + + const input_purpose = document.getElementById('input_purpose'); + input_purpose.value = "" + input_purpose.addEventListener('focusin', this.onFocusInPurpose); + input_purpose.addEventListener('focusout', this.onFocusOutPurpose) + + const input_project = document.getElementById('input_project'); + input_project.value = "" + input_project.addEventListener('focusin', this.onFocusInProject); + input_project.addEventListener('focusout', this.onFocusOutProject); + + const input_length = document.getElementById('input_length'); + input_length.value = 1; + } + + getTemplateBlobFromId(templateId) { + for (const t of this.userTemplates) { + if (t.id == templateId) return t + } + + return null + } + + onclickSelectTemplate(templateCard, templateId) { + this.step = steps.SELECT_TEMPLATE + const oldHighlight = document.querySelector("#default_templates_list .selected_node") + if (oldHighlight) { + GUI.unhighlightCard(oldHighlight) + } + + GUI.highlightCard(templateCard); + this.bookingBlob.template_id = templateId; + GUI.refreshSummaryHosts(this.getTemplateBlobFromId(templateId)); + } + + isValidCIFile(ci_file) { + // todo + return true; + } + + isValidProject(project) { + let passed = true + let message = "success" + + if (project == "") { + passed = false; + message = "Project field cannot be empty." + return[passed, message] + } + + if (!(project.match(/^[a-z0-9~@#$^*()_+=[\]{}|,.?': -]+$/i))) { + passed = false; + message = "Project field contains invalid characters" + return[passed, message] + } + + return [passed, message] + } + + isValidPurpose(purpose) { + let passed = true + let message = "success" + + if (purpose == "") { + passed = false; + message = "Purpose field cannot be empty." + return[passed, message] + } + + if (!(purpose.match(/^[a-z0-9~@#$^*()_+=[\]{}|,.?': -]+$/i))) { + passed = false; + message = "Purpose field contains invalid characters" + return[passed, message] + } + + return [passed, message] + } + + // Ci FIle + onFocusOutCIFile() { + const ci_textarea = document.getElementById('ci-textarea'); + if (workflow.isValidCIFile(ci_textarea.value)) { + workflow.bookingBlob.global_cifile = ci_textarea.value; + } else { + GUI.highlightError(ci_textarea); + } + } + + onFocusInCIFile() { + this.step = steps.CLOUD_INIT + const ci_textarea = document.getElementById('ci-textarea') + GUI.unhighlightError(ci_textarea) + } + + // Purpose + onFocusOutPurpose() { + const input = document.getElementById('input_purpose'); + const valid = workflow.isValidPurpose(input.value); + if (valid[0]) { + workflow.bookingBlob.metadata.purpose = input.value; + GUI.refreshSummaryDetails(workflow.bookingBlob.metadata) + } else { + GUI.showDetailsError(valid[1]) + GUI.highlightError(input); + } + } + + onFocusInPurpose() { + this.step = steps.BOOKING_DETAILS + const input = document.getElementById('input_purpose'); + GUI.hideDetailsError() + GUI.unhighlightError(input) + } + + // Project + onFocusOutProject() { + const input = document.getElementById('input_project'); + const valid = workflow.isValidProject(input.value); + if (valid[0]) { + workflow.bookingBlob.metadata.project = input.value; + GUI.refreshSummaryDetails(workflow.bookingBlob.metadata) + } else { + GUI.showDetailsError(valid[1]) + GUI.highlightError(input); + } + } + + onFocusInProject() { + this.step = steps.BOOKING_DETAILS + const input = document.getElementById('input_project'); + GUI.hideDetailsError() + GUI.unhighlightError(input) + } + + onchangeDays() { + this.step = steps.BOOKING_DETAILS + const counter = document.getElementById("booking_details_day_counter") + const input = document.getElementById('input_length') + workflow.bookingBlob.metadata.length = input.value + GUI.refreshSummaryDetails(workflow.bookingBlob.metadata) + counter.innerText = "Days: " + input.value + } + + add_collaborator(username) { + this.step = steps.ADD_COLLABS; + + for (const c of this.bookingBlob.allowed_users) { + if (c == username) { + return; + } + } + + this.bookingBlob.allowed_users.push(username) + GUI.refreshSummaryCollabs(this.bookingBlob.allowed_users) + } + + remove_collaborator(username) { + // Removes collab from collaborators list and updates summary + this.step = steps.ADD_COLLABS + + const temp = []; + + for (const c of this.bookingBlob.allowed_users) { + if (c != username) { + temp.push(c); + } + } + + this.bookingBlob.allowed_users = temp; + GUI.refreshSummaryCollabs(this.bookingBlob.allowed_users) + } + + isCompleteBookingInfo() { + let passed = true + let message = "success" + let section = steps.BOOKING_SUMMARY + + const blob = this.bookingBlob; + const meta = blob.metadata; + + if (blob.template_id == null) { + passed = false; + message = "Please select a template." + section = steps.SELECT_TEMPLATE + return [passed, message, section] + } + + if (meta.purpose == null || meta.project == null || meta.length == 0) { + passed = false + message = "Please finish adding booking details." + section = steps.BOOKING_DETAILS + return [passed, message, section] + } + + return[passed, message, section]; + } + + onclickCancel() { + if (confirm("Are you sure you wish to discard this booking?")) { + location.reload(); + } + } + + /** Async / await is more infectious than I thought, so all functions that rely on an API call will need to be async */ + async onclickConfirm() { + const complete = this.isCompleteBookingInfo(); + if (!complete[0]) { + alert(complete[1]); + this.step = complete[2] + document.getElementById(this.sections[complete[2]]).scrollIntoView({behavior: 'smooth'}); + return + } + if (confirm("Are you sure you would like to create this booking?")) { + const response = await LibLaaSAPI.makeBooking(this.bookingBlob); + if (response.bookingId) { + alert("The booking has been successfully created.") + window.location.href = "../../"; + } else { + alert("The booking could not be created at this time.") + } + } + } + + + + } + + +/** View class that displays cards and generates HTML + * Functions as a namespace, does not hold state +*/ +class GUI { + + static highlightCard(card) { + card.classList.add('selected_node'); + } + + static unhighlightCard(card) { + card.classList.remove('selected_node'); + } + + static highlightError(element) { + element.classList.add('invalid_field'); + } + + static unhighlightError(element) { + element.classList.remove("invalid_field"); + } + + /** Takes a list of templateBlobs and creates a selectable card for each of them */ + static displayTemplates(templates) { + const templates_list = document.getElementById("default_templates_list"); + + for (const t of templates) { + const newCard = this.makeTemplateCard(t); + templates_list.appendChild(newCard); + } + } + + static makeTemplateCard(templateBlob) { + const col = document.createElement('div'); + col.classList.add('col-3', 'my-1'); + col.innerHTML= ` + <div class="card"> + <div class="card-header"> + <p class="h5 font-weight-bold mt-2">` + templateBlob.pod_name + `</p> + </div> + <div class="card-body"> + <p class="grid-item-description">` + templateBlob.pod_desc +`</p> + </div> + <div class="card-footer"> + <button type="button" class="btn btn-success grid-item-select-btn w-100 stretched-link" + onclick="workflow.onclickSelectTemplate(this.parentNode.parentNode, '` + templateBlob.id +`')">Select</button> + </div> + </div> + ` + return col; + } + + /** Removes default styling applied by django */ + static modifyCollabWidget() { + document.getElementsByTagName('label')[0].setAttribute('hidden', ''); + document.getElementById('addable_limit').setAttribute('hidden', ''); + document.getElementById('added_number').setAttribute('hidden', ''); + const user_field = document.getElementById('user_field'); + user_field.classList.add('border-top-0'); + document.querySelector('.form-group').classList.add('mb-0'); + + const added_list = document.getElementById('added_list'); + added_list.remove(); + document.getElementById('search_select_outer').appendChild(added_list); + } + + static showDetailsError(message) { + document.getElementById("booking_details_error").innerText = message; + } + + static hideDetailsError() { + document.getElementById("booking_details_error").innerText = ''; + } + + static refreshSummaryDetails(bookingMetaData) { + const ul = document.getElementById("booking_summary_booking_details") + ul.innerHTML = ''; + + if (bookingMetaData.project) { + const project_li = document.createElement('li'); + project_li.innerText = 'Project: ' + bookingMetaData.project; + ul.appendChild(project_li); + } + + if (bookingMetaData.purpose) { + const project_li = document.createElement('li'); + project_li.innerText = 'Purpose: ' + bookingMetaData.purpose; + ul.appendChild(project_li); + } + + if (bookingMetaData.length) { + const project_li = document.createElement('li'); + project_li.innerText = 'Length: ' + bookingMetaData.length + ' days'; + ul.appendChild(project_li); + } + } + + static refreshSummaryCollabs(collaborators) { + const collabs_ul = document.getElementById('booking_summary_collaborators'); + collabs_ul.innerHTML = ''; + for (const u of collaborators) { + const collabs_li = document.createElement('li'); + collabs_li.innerText = u + collabs_ul.appendChild(collabs_li); + } + } + + static refreshSummaryHosts(templateBlob) { + const hosts_ul = document.getElementById('booking_summary_hosts'); + hosts_ul.innerHTML = ''; + for (const h of templateBlob.host_list) { + const hosts_li = document.createElement('li'); + hosts_li.innerText = h.hostname; + hosts_ul.appendChild(hosts_li); + } + } +} + + + // Search widget for django forms (taken from dashboard.js and slightly modified) + class SearchableSelectMultipleWidget { + constructor(format_vars, field_dataset, field_initial) { + this.format_vars = format_vars; + this.items = field_dataset; + this.initial = field_initial; + + this.expanded_name_trie = {"isComplete": false}; + this.small_name_trie = {"isComplete": false}; + this.string_trie = {"isComplete": false}; + + this.added_items = new Set(); + + for( let e of ["show_from_noentry", "show_x_results", "results_scrollable", "selectable_limit", "placeholder"] ) + { + this[e] = format_vars[e]; + } + + this.search_field_init(); + + if( this.show_from_noentry ) + { + this.search(""); + } + } + + disable() { + const textfield = document.getElementById("user_field"); + const drop = document.getElementById("drop_results"); + + textfield.disabled = "True"; + drop.style.display = "none"; + + const btns = document.getElementsByClassName("btn-remove"); + for( const btn of btns ) + { + btn.classList.add("disabled"); + btn.onclick = ""; + } + } + + search_field_init() { + this.build_all_tries(this.items); + + for( const elem of this.initial ) + { + this.select_item(elem); + } + if(this.initial.length == 1) + { + this.search(this.items[this.initial[0]]["small_name"]); + document.getElementById("user_field").value = this.items[this.initial[0]]["small_name"]; + } + } + + build_all_tries(dict) + { + for( const key in dict ) + { + this.add_item(dict[key]); + } + } + + add_item(item) + { + const id = item['id']; + this.add_to_tree(item['expanded_name'], id, this.expanded_name_trie); + this.add_to_tree(item['small_name'], id, this.small_name_trie); + this.add_to_tree(item['string'], id, this.string_trie); + } + + add_to_tree(str, id, trie) + { + let inner_trie = trie; + while( str ) + { + if( !inner_trie[str.charAt(0)] ) + { + var new_trie = {}; + inner_trie[str.charAt(0)] = new_trie; + } + else + { + var new_trie = inner_trie[str.charAt(0)]; + } + + if( str.length == 1 ) + { + new_trie.isComplete = true; + if( !new_trie.ids ) + { + new_trie.ids = []; + } + new_trie.ids.push(id); + } + inner_trie = new_trie; + str = str.substring(1); + } + } + + search(input) + { + if( input.length == 0 && !this.show_from_noentry){ + this.dropdown([]); + return; + } + else if( input.length == 0 && this.show_from_noentry) + { + this.dropdown(this.items); //show all items + } + else + { + const trees = [] + const tr1 = this.getSubtree(input, this.expanded_name_trie); + trees.push(tr1); + const tr2 = this.getSubtree(input, this.small_name_trie); + trees.push(tr2); + const tr3 = this.getSubtree(input, this.string_trie); + trees.push(tr3); + const results = this.collate(trees); + this.dropdown(results); + } + } + + getSubtree(input, given_trie) + { + /* + recursive function to return the trie accessed at input + */ + + if( input.length == 0 ){ + return given_trie; + } + + else{ + const substr = input.substring(0, input.length - 1); + const last_char = input.charAt(input.length-1); + const subtrie = this.getSubtree(substr, given_trie); + + if( !subtrie ) //substr not in the trie + { + return {}; + } + + const indexed_trie = subtrie[last_char]; + return indexed_trie; + } + } + + serialize(trie) + { + /* + takes in a trie and returns a list of its item id's + */ + let itemIDs = []; + if ( !trie ) + { + return itemIDs; //empty, base case + } + for( const key in trie ) + { + if(key.length > 1) + { + continue; + } + itemIDs = itemIDs.concat(this.serialize(trie[key])); + } + if ( trie.isComplete ) + { + itemIDs.push(...trie.ids); + } + + return itemIDs; + } + + collate(trees) + { + /* + takes a list of tries + returns a list of ids of objects that are available + */ + const results = []; + for( const tree of trees ) + { + const available_IDs = this.serialize(tree); + + for( const itemID of available_IDs ) { + results[itemID] = this.items[itemID]; + } + } + return results; + } + + generate_element_text(obj) + { + const content_strings = [obj.expanded_name, obj.small_name, obj.string].filter(x => Boolean(x)); + const result = content_strings.shift(); + if( result == null || content_strings.length < 1) { + return result; + } else { + return result + " (" + content_strings.join(", ") + ")"; + } + } + + dropdown(ids) + { + /* + takes in a mapping of ids to objects in items + and displays them in the dropdown + */ + const drop = document.getElementById("drop_results"); + while(drop.firstChild) + { + drop.removeChild(drop.firstChild); + } + + for( const id in ids ) + { + const obj = this.items[id]; + const result_text = this.generate_element_text(obj); + const result_entry = document.createElement("a"); + result_entry.href = "#"; + result_entry.innerText = result_text; + result_entry.title = result_text; + result_entry.classList.add("list-group-item", "list-group-item-action", "overflow-ellipsis", "flex-shrink-0"); + result_entry.onclick = function() { searchable_select_multiple_widget.select_item(obj.id); }; + const tooltip = document.createElement("span"); + const tooltiptext = document.createTextNode(result_text); + tooltip.appendChild(tooltiptext); + tooltip.classList.add("d-none"); + result_entry.appendChild(tooltip); + drop.appendChild(result_entry); + } + + const scroll_restrictor = document.getElementById("scroll_restrictor"); + + if( !drop.firstChild ) + { + scroll_restrictor.style.visibility = 'hidden'; + } + else + { + scroll_restrictor.style.visibility = 'inherit'; + } + } + + select_item(item_id) + { + if( (this.selectable_limit > -1 && this.added_items.size < this.selectable_limit) || this.selectable_limit < 0 ) + { + this.added_items.add(item_id); + } + this.update_selected_list(); + // clear search bar contents + document.getElementById("user_field").value = ""; + document.getElementById("user_field").focus(); + this.search(""); + + const item = this.items[item_id]; + const element_entry_text = this.generate_element_text(item); + const username = item.small_name; + workflow.add_collaborator(username, element_entry_text); + } + + remove_item(item_id) + { + this.added_items.delete(item_id); // delete from set + + const item = this.items[item_id]; + workflow.remove_collaborator(item.small_name); + + this.update_selected_list(); + document.getElementById("user_field").focus(); + } + + update_selected_list() + { + document.getElementById("added_number").innerText = this.added_items.size; + const selector = document.getElementById('selector'); + selector.value = JSON.stringify([...this.added_items]); + const added_list = document.getElementById('added_list'); + + while(selector.firstChild) + { + selector.removeChild(selector.firstChild); + } + while(added_list.firstChild) + { + added_list.removeChild(added_list.firstChild); + } + + const list_html = document.createElement("div"); + list_html.classList.add("list-group"); + + for( const item_id of this.added_items ) + { + const times = document.createElement("li"); + times.classList.add("fas", "fa-times"); + + const deleteButton = document.createElement("a"); + deleteButton.href = "#"; + deleteButton.innerHTML = "<i class='fas fa-times'></i>" + deleteButton.setAttribute("onclick", `searchable_select_multiple_widget.remove_item(${item_id});`); + deleteButton.classList.add("btn"); + const deleteColumn = document.createElement("div"); + deleteColumn.classList.add("col-auto"); + deleteColumn.append(deleteButton); + + const item = this.items[item_id]; + const element_entry_text = this.generate_element_text(item); + const textColumn = document.createElement("div"); + textColumn.classList.add("col", "overflow-ellipsis"); + textColumn.innerText = element_entry_text; + textColumn.title = element_entry_text; + textColumn.id = `coldel-${item_id}`; // Needed for book a pod + + const itemRow = document.createElement("div"); + itemRow.classList.add("list-group-item", "d-flex", "p-0", "align-items-center", "my-2", "border"); + itemRow.append(textColumn, deleteColumn); + + list_html.append(itemRow); + } + added_list.innerHTML = list_html.innerHTML; + } +}
\ No newline at end of file diff --git a/src/static/js/workflows/common-models.js b/src/static/js/workflows/common-models.js new file mode 100644 index 0000000..65fedb1 --- /dev/null +++ b/src/static/js/workflows/common-models.js @@ -0,0 +1,189 @@ +/* +common-models.js +Defines classes used by the workflows +Functions as the "model" part of MVC +*/ + +// Provided by the LibLaaS API +// TemplateBlob classes +class TemplateBlob { + constructor(incomingBlob) { + this.id = incomingBlob.id; // UUID (String) + this.owner = incomingBlob.owner; // String + this.lab_name = incomingBlob.lab_name; // String + this.pod_name = incomingBlob.pod_name; // String + this.pod_desc = incomingBlob.pod_desc; // String + this["public"] = incomingBlob["public"]; // bool + this.host_list = []; // List<HostConfigBlob> + this.networks = []; // List<NetworkBlob> + + if (incomingBlob.host_list) { + this.host_list = incomingBlob.host_list; + } + + if (incomingBlob.networks) { + this.networks = incomingBlob.networks; + } + } + + /** + * Takes a network name (string) and returns the network stored in the template, or null if it does not exist + * @param {String} network_name + */ + findNetwork(network_name) { + for (const network of this.networks) { + if (network.name == network_name) { + return network; + } + } + + // Did not find it + return null; + } + + + /** + * Takes a hostname (string) and returns the host stored in the template, or null if it does not exist + * @param {String} hostname + */ + findHost(hostname) { + for (const host of this.host_list) { + if (host.hostname == hostname) { + return host; + } + } + + // Did not find it + return null; + } +} + +class HostConfigBlob { + constructor(incomingBlob) { + this.hostname = incomingBlob.hostname; // String + this.flavor = incomingBlob.flavor; // UUID (String) + this.image = incomingBlob.image; // UUID (String) + this.cifile = []; // List<String> + this.bondgroups = []; // List<BondgroupBlob> + + if (incomingBlob.cifile) { + this.cifile = incomingBlob.cifile; + } + + if (incomingBlob.bondgroups) { + this.bondgroups = incomingBlob.bondgroups; + } + } +} + +class NetworkBlob { + constructor(incomingBlob) { + this.name = incomingBlob.name; + this['public'] = incomingBlob['public']; + + } +} + +/** One bondgroup per interface at this time. */ +class BondgroupBlob { + constructor(incomingBlob) { + this.connections = []; //List<ConnectionBlob> + this.ifaces = []; // List<IfaceBlob> (will only contain the one iface for now) + + if (incomingBlob.connections) { + this.connections = incomingBlob.connections; + } + + if (incomingBlob.ifaces) { + this.ifaces = incomingBlob.ifaces; + } + } + +} + +class ConnectionBlob { + constructor(incomingBlob) { + this.tagged = incomingBlob.tagged; // bool, + this.connects_to = incomingBlob.connects_to; // String + } +} + +class InterfaceBlob { + constructor(incomingBlob) { + this.name = incomingBlob.name; // String, + this.speed = incomingBlob.speed; + this.cardtype = incomingBlob.cardtype; + } +} + +// BookingClasses +class BookingBlob { + // constructor({template_id, allowed_users, global_cifile}) { + constructor(incomingBlob) { + + this.template_id = incomingBlob.template_id; // UUID (String) + this.allowed_users = []; // List<String> + this.global_cifile = ""; // String + this.metadata = new BookingMetaDataBlob({}); + + if (incomingBlob.allowed_users) { + this.allowed_users = incomingBlob.allowed_users; + } + + if (incomingBlob.global_cifile) { + this.global_cifile = incomingBlob.global_cifile; + } + + if (incomingBlob.metadata) { + this.metadata = incomingBlob.metadata; + } + } +} + + +class BookingMetaDataBlob { + constructor(incomingBlob) { + this.booking_id = incomingBlob.booking_id; // String + this.owner = incomingBlob.owner; // String + this.lab = incomingBlob.lab; // String + this.purpose = incomingBlob.purpose; // String + this.project = incomingBlob.project; // String + this.length = 1 // Number + + if (incomingBlob.length) { + this.length = incomingBlob.length; + } + } +} + +// Utility Classes +class ImageBlob { + constructor(incomingBlob) { + this.image_id = incomingBlob.image_id; // UUID (String) + this.name = incomingBlob.name; // String, + } +} + +class FlavorBlob { + constructor(incomingBlob) { + this.flavor_id = incomingBlob.flavor_id; // UUID (String) + this.name = incomingBlob.name; // String + this.interfaces = []; // List<String> + // images are added after + + if (incomingBlob.interfaces) { + this.interfaces = incomingBlob.interfaces; + } + } + +} + +class LabBlob { + constructor(incomingBlob) { + this.name = incomingBlob.name; // String + this.description = incomingBlob.description; // String + this.location = incomingBlob.location; //String + this.status = incomingBlob.status; // Number + + } +}
\ No newline at end of file diff --git a/src/static/js/workflows/design-a-pod.js b/src/static/js/workflows/design-a-pod.js new file mode 100644 index 0000000..7632537 --- /dev/null +++ b/src/static/js/workflows/design-a-pod.js @@ -0,0 +1,1186 @@ +/* +design-a-pod.js + +Functions as the "controller" part of MVC +*/ + + +const steps = { + SELECT_LAB: 0, + ADD_RESOURCES: 1, + ADD_NETWORKS: 2, + CONFIGURE_CONNECTIONS: 3, + POD_DETAILS: 4, + POD_SUMMARY: 5 +} + +/** Concrete controller class that handles button inputs from the user. + * Holds the in-progress TemplateBlob. + * Click methods are prefaced with 'onclick'. + * Step initialization methods are prefaced with 'step'. + */ +class DesignWorkflow extends Workflow { + constructor(savedTemplateBlob) { + super(["select_lab", "add_resources", "add_networks", "configure_connections", "pod_details", "pod_summary"]) + + // if(savedTemplateBlob) { + // this.resume_workflow(); + // } + + this.templateBlob = new TemplateBlob({}); + this.labFlavors; // Map<UUID, FlavorBlob> + this.userTemplates; // List<TemplateBlob> + this.resourceBuilder; // ResourceBuilder + + this.templateBlob.public = false; + } + + /** Finds the templateBlob object in the userTemplates list based on a given uuid */ + getTemplateById(template_id) { + for (let template of this.userTemplates) { + if (template.id == template_id) { + return template; + } + } + return null; + } + + resumeWorkflow() { + todo() + } + + /** Initializes the select_lab step */ + async startWorkflow() { + this.setPodDetailEventListeners(); + const labs = await LibLaaSAPI.getLabs(); + GUI.display_labs(labs); + document.getElementById(this.sections[0]).scrollIntoView({behavior: 'smooth'}); + } + + /** Adds the public network on start */ + addDefaultNetwork() { + const new_network = new NetworkBlob({}); + new_network.name = "public"; + new_network.public = true; + this.addNetworkToPod(new_network); + GUI.refreshNetworkStep(this.templateBlob.networks); + } + + /** Takes an HTML element */ + async onclickSelectLab(lab_card) { + this.step = steps.SELECT_LAB; + + if (this.templateBlob.lab_name == null) { // Lab has not been selected yet + this.templateBlob.lab_name = lab_card.id; + lab_card.classList.add("selected_node"); + await this.setLabDetails(this.templateBlob.lab_name); + this.addDefaultNetwork(); + } else { // Lab has been selected + if(confirm('Unselecting a lab will reset all selected resources, are you sure?')) { + location.reload(); + } + } + } + + /** Calls the API to fetch flavors and images for a lab */ + async setLabDetails(lab_name) { + const flavorsList = await LibLaaSAPI.getLabFlavors(lab_name); + this.labFlavors = new Map(); // Map<UUID, FlavorBlob> + this.labImages = new Map(); // Map<UUID, ImageBlob> + + for (const fblob of flavorsList) { + fblob.images = await LibLaaSAPI.getImagesForFlavor(fblob.flavor_id); + for (const iblob of fblob.images) { + this.labImages.set(iblob.image_id, iblob) + } + this.labFlavors.set(fblob.flavor_id, fblob); + } + + this.userTemplates = await LibLaaSAPI.getTemplatesForUser(); + } + + /** Prepopulates fields and launches the modal */ + onclickAddResource() { + // Set step + // Check prerequisites + // Reset resourceBuilder + // Generate template cards + // Show modal + + this.step = steps.ADD_RESOURCES; + + if (this.templateBlob.lab_name == null) { + alert("Please select a lab before adding resources."); + this.goTo(steps.SELECT_LAB); + return; + } + + if (this.templateBlob.host_list.length >= 8) { + alert("You may not add more than 8 hosts to a single pod.") + return; + } + + this.resourceBuilder = null; + GUI.refreshAddHostModal(this.userTemplates); + $("#resource_modal").modal('toggle'); + + } + + onclickSelectTemplate(template_id, card) { + + // Do nothing on reselect + if (this.resourceBuilder && this.resourceBuilder.template_id == template_id) { + return; + } + + if (this.resourceBuilder) { + GUI.unhighlightCard(document.querySelector('#template-cards .selected_node')); + } + + this.resourceBuilder = new ResourceBuilder(this.getTemplateById(template_id)); + GUI.highlightCard(card); + GUI.refreshConfigSection(this.resourceBuilder, this.labFlavors); + GUI.refreshInputSection(this.resourceBuilder, this.labFlavors); + } + + onclickSelectNode(index) { + this.resourceBuilder.tab = index; + GUI.refreshInputSection(this.resourceBuilder, this.labFlavors); + } + + onclickSelectImage(image_id, card) { + const old_selection = document.querySelector("#image-cards .selected_node"); + if (old_selection) { + GUI.unhighlightCard(old_selection); + } + this.resourceBuilder.user_configs[this.resourceBuilder.tab].image = image_id; + GUI.highlightCard(card.childNodes[1]); + } + + /** Takes a string and returns a tuple containing the result and the error message (bool, string)*/ + isValidHostname(hostname) { + let result = true; + let message = "success"; + + if (hostname == null || hostname == '') { + result = false; + message = 'Please enter a hostname'; + + } else if (hostname.length > 25) { + result = false; + message = 'Hostnames cannot exceed 25 characters'; + + } else if (!(hostname.match(/^[0-9a-z-]+$/i))) { + result = false; + message = 'Hostnames must only contain alphanumeric characters and dashes'; + + } else if ((hostname.charAt(0).match(/^[0-9-]+$/)) || (hostname.charAt(hostname.length - 1) == '-')) { + result = false; + message = 'Hostnames must start with a letter and end with a letter or digit.'; + } + + return [result, message]; + } + + /** Takes a hostname and a list of existing hosts and checks for duplicates in the existing hostlist*/ + isUniqueHostname(hostname, existing_hosts) { + for (const existing_host of existing_hosts) { + if (hostname == existing_host.hostname) { + return false; + } + } + + return true; + } + + onclickSubmitHostConfig() { + // Validate form fields + // Create host config blobs + // Apply networking + // Create cards (refresh hostcard view) + // Refresh networks view + // Refresh connections view + + // Validate Configs + for (const [index, host] of this.resourceBuilder.user_configs.entries()) { + let result = this.isValidHostname(host.hostname); + if (!result[0]) { + this.resourceBuilder.tab = index; + GUI.refreshConfigSection(this.resourceBuilder, this.labFlavors); + GUI.refreshInputSection(this.resourceBuilder, this.labFlavors); + GUI.showHostConfigErrorMessage(result[1]); + return; + } + + let uniqueHost = this.isUniqueHostname(host.hostname, this.templateBlob.host_list); + if (!uniqueHost) { + this.resourceBuilder.tab = index; + GUI.refreshConfigSection(this.resourceBuilder, this.labFlavors); + GUI.refreshInputSection(this.resourceBuilder, this.labFlavors); + GUI.showHostConfigErrorMessage("Hostname '"+ host.hostname + "' already exists in Pod."); + return; + } + + if (index < this.resourceBuilder.user_configs.length - 1) { + let uniqueConfigName = true; + for (let i = index + 1; i < this.resourceBuilder.user_configs.length; i++) { + if (host.hostname == this.resourceBuilder.user_configs[i].hostname) { + uniqueConfigName = false; + break; + } + } + + if (!uniqueConfigName) { + this.resourceBuilder.tab = index; + GUI.refreshConfigSection(this.resourceBuilder, this.labFlavors); + GUI.refreshInputSection(this.resourceBuilder, this.labFlavors); + GUI.showHostConfigErrorMessage("Hostname '"+ host.hostname + "' is a duplicate hostname."); + return; + } + } + + // todo + // let result2 = isValidCIFile(host.cifile[0]); + } + + + // Add host configs to TemplateBlob + for (const [index, host] of this.resourceBuilder.user_configs.entries()) { + const new_host = new HostConfigBlob(host); + this.templateBlob.host_list.push(new_host); + } + + // Add networks + for (const n of this.resourceBuilder.networks) { + if (!this.templateBlob.findNetwork(n.name)) { + this.templateBlob.networks.push(n); + } + } + + // We are done + GUI.refreshHostStep(this.templateBlob.host_list, this.labFlavors, this.labImages); + GUI.refreshNetworkStep(this.templateBlob.networks); + GUI.refreshConnectionStep(this.templateBlob.host_list); + GUI.refreshPodSummaryHosts(this.templateBlob.host_list, this.labFlavors, this.labImages) + $('#resource_modal').modal('hide') + } + + /** + * Takes a hostname, looks for the matching HostConfigBlob in the TemplateBlob, removes it from the list, and refreshes the appropriate views + * @param {String} hostname + */ + onclickDeleteHost(hostname) { + this.step = steps.ADD_RESOURCES; + for (let existing_host of this.templateBlob.host_list) { + if (hostname == existing_host.hostname) { + this.removeHostFromTemplateBlob(existing_host); + GUI.refreshHostStep(this.templateBlob.host_list, this.labFlavors, this.labImages); + GUI.refreshNetworkStep(this.templateBlob.networks); + GUI.refreshConnectionStep(this.templateBlob.host_list); + GUI.refreshPodSummaryHosts(this.templateBlob.host_list, this.labFlavors, this.labImages); + return; + } + } + + alert("didnt remove"); + } + + + + + /** onclick handler for the add_network_plus_card */ + onclickAddNetwork() { + // Set step + // Prerequisite step checks + // GUI stuff + + this.step = steps.ADD_NETWORKS; + + if (this.templateBlob.lab_name == null) { + alert("Please select a lab before adding networks."); + this.goTo(steps.SELECT_LAB); + return; + } + + if (document.querySelector('#new_network_card') != null) { + alert("Please finish adding the current network before adding a new one."); + return; + } + + GUI.display_network_input(); + } + + /** onclick handler for the adding_network_confirm button */ + onclickConfirmNetwork() { + this.step = steps.ADD_NETWORKS; + + // Add the network + // call the GUI to make the card (refresh the whole view to make it easier) + + const new_network = new NetworkBlob({}); + new_network.name = document.getElementById('network_input').value; + new_network.public = document.getElementById('network-public-input').checked; + const error_message = this.addNetworkToPod(new_network); + + if (error_message == null) { + GUI.refreshNetworkStep(this.templateBlob.networks); + GUI.refreshConnectionStep(this.templateBlob.host_list); + } else { + GUI.display_add_network_error(error_message); + } + } + + /** Takes a NetworkBlob and tries to add to the TemplateBlob. + * Fails if input validation fails. + * Returns error message or null. + */ + addNetworkToPod(networkBlob) { + if (networkBlob.name == '' || networkBlob.name == null) { + return "Network name cannot be empty."; + } + + if (networkBlob.name.length > 25) { + return 'Network names cannot exceed 25 characters'; + } + + if (!(networkBlob.name.match(/^[0-9a-z-]+$/i))) { + return 'Network names must only contain alphanumeric characters and dashes'; + } + + if ((networkBlob.name.charAt(0).match(/^[0-9-]+$/)) || (networkBlob.name.charAt(networkBlob.name.length - 1) == '-')) { + return 'Network names must start with a letter and end with a letter or digit.'; + } + + for (let existing_network of this.templateBlob.networks) { + if (networkBlob.name == existing_network.name) { + return 'Networks must have unique names'; + } + } + + this.templateBlob.networks.push(networkBlob); + return null; + } + + /** Iterates through the templateBlob looking for the correct network to delete + * Takes a network name as a parameter. + */ + onclickDeleteNetwork(network_name) { + this.step = steps.ADD_NETWORKS; + + for (let existing_network of this.templateBlob.networks) { + if (network_name == existing_network.name) { + this.removeNetworkFromTemplateBlob(existing_network); + this.removeConnectionsOnNetwork(existing_network.name) + GUI.refreshNetworkStep(this.templateBlob.networks); + GUI.refreshConnectionStep(this.templateBlob.host_list); + return; + } + } + + alert("didnt remove"); + } + + /** Rebuilds the list without the chosen template */ + removeNetworkFromTemplateBlob(network_to_remove) { + this.templateBlob.networks = this.templateBlob.networks.filter(network => network !== network_to_remove); + } + + removeConnectionsOnNetwork(network_name) { + for (const host of this.templateBlob.host_list) { + for (const bg of host.bondgroups) { + bg.connections = bg.connections.filter((connection) => connection.connects_to != network_name) + } + } + } + + /** + * Rebuilds the hostlist without the chosen host + * Also removes all connections from this host's interfaces + * @param {HostConfigBlob} hostBlob + */ + removeHostFromTemplateBlob(hostBlob) { + this.templateBlob.host_list = this.templateBlob.host_list.filter(host => host !== hostBlob); + } + + onclickConfigureConnection(hostname) { + this.step = steps.CONFIGURE_CONNECTIONS; + + const host = this.templateBlob.findHost(hostname); + if (!host) { + alert("host not found error"); + } + + this.connectionTemp = new ConnectionTemp(host, this.templateBlob.networks, this.labFlavors.get(host.flavor).interfaces); + GUI.refreshConnectionModal(this.connectionTemp); + $("#connection_modal").modal('toggle'); + } + + onclickSelectIfaceTab(tab_index) { + this.connectionTemp.selected_index = tab_index; + GUI.refreshConnectionModal(this.connectionTemp); + } + + onclickSelectVlan(network_name, tagged, iface_name) { + const x = this.connectionTemp.config.get(iface_name); + if (x.get(network_name) === tagged) { + x.set(network_name, null); + } else { + x.set(network_name, tagged); + } + + GUI.refreshConnectionTable(this.connectionTemp); + } + + onclickSubmitConnectionConfig() { + this.connectionTemp.applyConfigs(); + GUI.refreshConnectionStep(this.templateBlob.host_list); + } + + /** Sets input validation event listeners and clears the value in case of caching*/ + setPodDetailEventListeners() { + const pod_name_input = document.getElementById("pod-name-input"); + const pod_desc_input = document.getElementById("pod-desc-input"); + const pod_public_input = document.getElementById("pod-public-input"); + + pod_name_input.value = ""; + pod_desc_input.value = ""; + pod_public_input.checked = false; + + pod_name_input.addEventListener('focusout', (event)=> { + workflow.onFocusOutPodNameInput(pod_name_input); + }); + + pod_name_input.addEventListener('focusin', (event)=> { + this.step = steps.POD_DETAILS; + GUI.unhighlightError(pod_name_input); + GUI.hidePodDetailsError(); + }); + + pod_desc_input.addEventListener('focusout', (event)=> { + workflow.onFocusOutPodDescInput(pod_desc_input); + }); + + pod_desc_input.addEventListener('focusin', (event)=> { + this.step = steps.POD_DETAILS; + GUI.unhighlightError(pod_desc_input); + GUI.hidePodDetailsError(); + }); + + pod_public_input.addEventListener('focusout', (event)=> { + this.step = steps.POD_DETAILS; + workflow.onFocusOutPodPublicInput(pod_public_input); + }); + } + + onFocusOutPodNameInput(element) { + const pod_name = element.value; + const validator = this.validatePodInput(pod_name, 53, "Pod name"); + + if (validator[0]) { + this.templateBlob.pod_name = pod_name; + GUI.refreshPodSummaryDetails(this.templateBlob.pod_name, this.templateBlob.pod_desc, this.templateBlob.public) + } else { + GUI.highlightError(element); + GUI.showPodDetailsError(validator[1]); + } + } + + onFocusOutPodDescInput(element) { + const pod_desc = element.value; + const validator = this.validatePodInput(pod_desc, 255, "Pod description"); + + if (validator[0]) { + this.templateBlob.pod_desc = pod_desc; + GUI.refreshPodSummaryDetails(this.templateBlob.pod_name, this.templateBlob.pod_desc, this.templateBlob.public) + } else { + GUI.highlightError(element); + GUI.showPodDetailsError(validator[1]); + } + + } + + onFocusOutPodPublicInput(element) { + this.templateBlob.public = element.checked; + GUI.refreshPodSummaryDetails(this.templateBlob.pod_name, this.templateBlob.pod_desc, this.templateBlob.public) + } + + /** Returns a tuple containing result and message (bool, String) */ + validatePodInput(input, maxCharCount, form_name) { + let result = true; + let message = "valid" + + if (input === '') { + message = form_name + ' cannot be empty.'; + result = false; + } + else if (input.length > maxCharCount) { + message = form_name + ' cannot exceed ' + maxCharCount + ' characters.'; + result = false; + } else if (!(input.match(/^[a-z0-9~@#$^*()_+=[\]{}|,.?': -!]+$/i))) { + message = form_name + ' contains invalid characters.'; + result = false; + } + + return [result, message] + } + + async onclickDiscardTemplate() { + this.step = steps.POD_SUMMARY; + if(confirm('Are you sure you wish to delete this Pod?')) { + await LibLaaSAPI.deleteTemplate(this.templateBlob); + location.reload(); + } + } + + simpleStepValidation() { + let passed = true; + let message = "done"; + let step = steps.POD_SUMMARY; + + if (this.templateBlob.lab_name == null) { + passed = false; + message = "Please select a lab"; + step = steps.SELECT_LAB; + } else if (this.templateBlob.host_list.length < 1 || this.templateBlob.host_list.length > 8) { + passed = false; + message = "Pods must contain 1 to 8 hosts"; + step = steps.ADD_RESOURCES; + } else if (this.templateBlob.networks.length < 1) { + passed = false; + message = "Pods must contain at least one network."; + step = steps.ADD_NETWORKS; + } else if (this.templateBlob.pod_name == null || this.templateBlob.pod_desc == null) { + passed = false; + message = "Please add a valid pod name and description."; + step = steps.POD_DETAILS; + } + return [passed, message, step]; + } + + async onclickSubmitTemplate() { + this.step = steps.POD_SUMMARY; + const simpleValidation = this.simpleStepValidation(); + if (!simpleValidation[0]) { + alert(simpleValidation[1]) + this.goTo(simpleValidation[2]); + return; + } + + // todo - make sure each host has at least one connection on any network. + + if (confirm("Are you sure you wish to create this pod?")) { + let success = await LibLaaSAPI.makeTemplate(this.templateBlob); + if (success) { + window.location.href = "../../accounts/my/resources/"; + } else { + alert("Could not create template.") + } + } + } +} + +/** View class that displays cards and generates HTML + * Functions as a namespace, does not hold state +*/ +class GUI { + /** Takes a list of LabBlobs and creates a card for each of them on the screen */ + static display_labs(labs) { + const card_deck = document.getElementById('lab_cards'); + for (let lab of labs) { + const new_col = document.createElement('div'); + new_col.classList.add('col-xl-3','col-md-6','col-11'); + let status; + if (lab.status == 0) { + status = "Up"; + } else if (lab.status == 100) { + status = "Down for Maintenance"; + } else if (lab.status == 200) { + status = "Down"; + } else { + status = "Unknown"; + } + + new_col.innerHTML = ` + <div class="card" id= ` + lab.name + `> + <div class="card-header"> + <h3 class="mt-2">` + lab.name + `</h3> + </div> + <ul class="list-group list-group-flush h-100"> + <li class="list-group-item">Name: ` + lab.name + `</li> + <li class="list-group-item">Description: ` + lab.description + `</li> + <li class="list-group-item">Location: ` + lab.location + `</li> + <li class="list-group-item">Status: `+ status + `</li> + </ul> + <div class="card-footer"> + <btn class="btn btn-success w-100 stretched-link" href="#" onclick="workflow.onclickSelectLab(this.parentNode.parentNode)">Select</btn> + </div> + </div> + ` + card_deck.appendChild(new_col); + } + } + + static highlightCard(card) { + card.classList.add('selected_node'); + } + + static unhighlightCard(card) { + card.classList.remove('selected_node'); + } + + /** Resets the host modal inner html + * Takes a list of templateBlobs + */ + static refreshAddHostModal(template_list) { + document.getElementById('add_resource_modal_body').innerHTML = ` + <h2>Resource</h2> + <div id="template-cards" class="row align-items-center justify-content-start"> + </div> + + <div id="template-config-section"> + <ul class="nav nav-tabs" role="tablist" id="add_resource_tablist"> + <!-- add a tab per host in template --> + </ul> + <!-- tabs --> + <div id="resource_config_section" hidden="true"> + <h2>Image</h2> + <div id="image-cards" class="row justify-content-start align-items-center"> + </div> + <div class="form-group"> + <h2>Hostname</h2> + <input type="text" class="form-control" id="hostname-input" placeholder="Enter Hostname"> + <h2>Cloud Init</h2> + <div class="d-flex justify-content-center align-items-center"> + <textarea name="ci-textarea" id="ci-textarea" rows="5" class="w-100"></textarea> + </div> + </div> + </div> + </div> + <p id="add-host-error-msg" class="text-danger"></p> + ` + + const template_cards = document.getElementById('template-cards'); + + for (let template of template_list) { + template_cards.appendChild(this.makeTemplateCard(template)); + } + } + + + /** Makes a card to be displayed in the add resource modal for a given templateBlob */ + static makeTemplateCard(templateBlob) { + const col = document.createElement('div'); + col.classList.add('col-12', 'col-md-6', 'col-xl-3', 'my-3'); + col.innerHTML= ` + <div class="card" id="card-" ` + templateBlob.id + `> + <div class="card-header"> + <p class="h5 font-weight-bold mt-2">` + templateBlob.pod_name + `</p> + </div> + <div class="card-body"> + <p class="grid-item-description">` + templateBlob.pod_desc +`</p> + </div> + <div class="card-footer"> + <button type="button" class="btn btn-success grid-item-select-btn w-100 stretched-link" + onclick="workflow.onclickSelectTemplate('` + templateBlob.id + `', this.parentNode.parentNode)">Select</button> + </div> + </div> + ` + return col; + } + + /** Takes a ResourceBuilder and generates form fields */ + static refreshConfigSection(resourceBuilder, flavors) { + // Create a tab for head host in the selected template + const tablist = document.getElementById('add_resource_tablist'); // ul + tablist.innerHTML = ""; + for (const [index, host] of resourceBuilder.user_configs.entries()) { + const li_interface = document.createElement('li'); + li_interface.classList.add('nav-item'); + const btn_interface = document.createElement('a'); + btn_interface.classList.add('nav-link', 'interface-btn'); + btn_interface.id = "select-node-" + index; + btn_interface.setAttribute("onclick", "workflow.onclickSelectNode("+ index + ")"); + btn_interface.setAttribute('href', "#"); + btn_interface.setAttribute('role', 'tab'); + btn_interface.setAttribute('data-toggle', 'tab'); + btn_interface.innerText = flavors.get(host.flavor).name; + + if (index == resourceBuilder.tab) { + btn_interface.classList.add('active'); + } + li_interface.appendChild(btn_interface); + tablist.appendChild(li_interface); + } + } + + static refreshInputSection(resourceBuilder, flavor_map) { + // config stuff + const image_cards = document.getElementById('image-cards'); + const hostname_input = document.getElementById('hostname-input'); + const ci_textarea = document.getElementById('ci-textarea'); + + const tab_flavor_id = resourceBuilder.original_configs[resourceBuilder.tab].flavor; + const tab_flavor = flavor_map.get(tab_flavor_id); + const image_list = tab_flavor.images; + image_cards.innerHTML = ""; + for (let imageBlob of image_list) { + const new_image_card = this.makeImageCard(imageBlob); + new_image_card.setAttribute("onclick", "workflow.onclickSelectImage('" + imageBlob.image_id + "', this)"); + if (resourceBuilder.user_configs[resourceBuilder.tab].image == imageBlob.image_id) { + GUI.highlightCard(new_image_card.childNodes[1]); + } + image_cards.appendChild(new_image_card); + } + + // Hostname input + hostname_input.value = resourceBuilder.user_configs[resourceBuilder.tab].hostname; + hostname_input.addEventListener('focusout', (event)=> { + resourceBuilder.user_configs[resourceBuilder.tab].hostname = hostname_input.value; + }); + + hostname_input.addEventListener('focusin', (event)=> { + this.removeHostConfigErrorMessage(); + }); + + // CI input + let ci_value = resourceBuilder.user_configs[resourceBuilder.tab].cifile[0]; + if (!ci_value) { + ci_value = ""; + } + ci_textarea.value = ci_value; + ci_textarea.addEventListener('focusout', (event)=> { + resourceBuilder.user_configs[resourceBuilder.tab].cifile[0] = ci_textarea.value; + }) + this.removeHostConfigErrorMessage(); + document.getElementById('resource_config_section').removeAttribute('hidden'); + } + + static showHostConfigErrorMessage(message) { + document.getElementById("hostname-input").classList.add("invalid_field"); + document.getElementById('add-host-error-msg').innerText = message; + } + + static removeHostConfigErrorMessage() { + document.getElementById("hostname-input").classList.remove("invalid_field"); + document.getElementById('add-host-error-msg').innerText = ""; + } + + static makeImageCard(imageBlob) { + const col = document.createElement('div'); + col.classList.add('col-12', 'col-md-6', 'col-xl-3', 'my-3'); + col.innerHTML = ` + <div class="btn border w-100">` + imageBlob.name +`</div> + ` + + return col; + } + + static highlightError(element) { + element.classList.add('invalid_field'); + } + + static unhighlightError(element) { + element.classList.remove("invalid_field"); + } + + static showPodDetailsError(message) { + document.getElementById('pod_details_error').innerText = message; + } + + static hidePodDetailsError() { + document.getElementById('pod_details_error').innerText = "" + } + + /** + * Refreshes the step and creates a card for each host in the hostlist + * @param {List<HostConfigBlob>} hostlist + */ + static refreshHostStep(hostlist, flavors, images) { + const host_cards = document.getElementById('host_cards'); + host_cards.innerHTML = ""; + for (const host of hostlist) { + host_cards.appendChild(this.makeHostCard(host, flavors, images)); + } + + let span_class = '' + if (hostlist.length == 8) { + span_class = 'text-primary' + } else if (hostlist.length > 8) { + span_class = 'text-danger' + } + const plus_card = document.createElement("div"); + plus_card.classList.add("col-xl-3", "col-md-6", "col-12"); + plus_card.id = "add_resource_plus_card"; + plus_card.innerHTML = ` + <div class="card align-items-center border-0"> + <span class="` + span_class + `" id="resource-count">` + hostlist.length + `/ 8</span> + <button class="btn btn-success add-button p-0" onclick="workflow.onclickAddResource()">+</button> + </div> + ` + + host_cards.appendChild(plus_card); + } + + /** + * Makes a host card element for a given host and returns a reference to the card + * @param {HostConfigBlob} host + */ + static makeHostCard(host, flavors, images) { + const new_card = document.createElement("div"); + new_card.classList.add("col-xl-3", "col-md-6","col-12", "my-3"); + new_card.innerHTML = ` + <div class="card"> + <div class="card-header"> + <h3 class="mt-2">` + flavors.get(host.flavor).name + `</h3> + </div> + <ul class="list-group list-group-flush h-100"> + <li class="list-group-item">Hostname: ` + host.hostname + `</li> + <li class="list-group-item">Image: ` + images.get(host.image).name + `</li> + </ul> + <div class="card-footer border-top-0"> + <button class="btn btn-danger w-100" id="delete-host-` + host.hostname + `" onclick="workflow.onclickDeleteHost('` + host.hostname +`')">Delete</button> + </div> + </div> + `; + + return new_card; + } + + + /** Shows the input card for adding a new network */ + // Don't forget to redisable + static display_network_input() { + // New empty card + const network_plus_card = document.getElementById('add_network_plus_card'); + const new_card = document.createElement('div'); + new_card.classList.add("col-xl-3", "col-md-6","col-12"); + new_card.innerHTML = + `<div class="card pb-0" id="new_network_card"> + <div class="card-body pb-0"> + <div class="justify-content-center my-5 mx-2"> + <input type="text" class="form-control col-12 mb-2 text-center" id="network_input" style="font-size: 1.75rem;" placeholder="Enter Network Name"> + <div class="custom-control custom-switch"> + <input type="checkbox" class="custom-control-input" id="network-public-input"> + <label class="custom-control-label" for="network-public-input">public?</label> + </div> + </br> + <p class="text-danger mt-n2" id="adding_network_error"></p> + </div> + <div class="row mb-3"> + <div class="col-6"><button class="btn btn-danger w-100" onclick="GUI.hide_network_input()">Delete</button></div> + <div class="col-6"><button class="btn btn-success w-100" id="adding_network_confirm" onclick="workflow.onclickConfirmNetwork()">Confirm</button></div> + </div> + </div> + </div>`; + network_plus_card.parentNode.insertBefore(new_card, network_plus_card); + + document.getElementById('network_input').addEventListener('focusin', e => { + document.getElementById('adding_network_error').innerText = ''; + }) + } + + static hide_network_input() { + document.getElementById('new_network_card').parentElement.remove(); + document.getElementById('add_network_plus_card').hidden = false; + } + + /** Redraws all the cards on the network step. + * Takes a list of networks to display + */ + static refreshNetworkStep(network_list) { + + document.getElementById('network_card_deck').innerHTML = ` + <div class="col-xl-3 col-md-6 col-12" id="add_network_plus_card"> + <div class="card align-items-center border-0"> + <button class="btn btn-success add-button p-0" onclick="workflow.onclickAddNetwork()">+</button> + </div> + </div> + ` + + const network_plus_card = document.getElementById('add_network_plus_card'); + for (let network of network_list) { // NetworkBlobs + + let pub_str = ' (private)'; + if (network.public) { + pub_str = ' (public)' + } + const new_card = document.createElement('div'); + new_card.classList.add("col-xl-3", "col-md-6","col-12", "my-3"); + new_card.innerHTML = ` + <div class="card"> + <div class="text-center"> + <h3 class="py-5 my-4">` + network.name + pub_str +`</h3> + </div> + <div class="row mb-3 mx-3"> + <button class="btn btn-danger w-100" id="delete_network_` + network.name + `" onclick="workflow.onclickDeleteNetwork('`+ network.name +`')">Delete</button> + </div> + </div>`; + network_plus_card.parentNode.insertBefore(new_card, network_plus_card); + } + } + + /** Displays an error message on the add network card */ + static display_add_network_error(error_message) { + document.getElementById("adding_network_error").innerHTML = error_message; + } + + static refreshConnectionStep(host_list) { + const connection_cards = document.getElementById('connection_cards'); + connection_cards.innerHTML = ""; + + for (const host of host_list) { + connection_cards.appendChild(this.makeConnectionCard(host)); + } + + } + + + /** Makes a blank connection card that does not contain interface details */ + static makeConnectionCard(host) { + const new_card = document.createElement('div'); + new_card.classList.add("col-xl-3", "col-md-6","col-11", "my-3"); + + const card_div = document.createElement('div'); + card_div.classList.add('card'); + new_card.appendChild(card_div); + + const card_header = document.createElement('div'); + card_header.classList.add('card-header', 'text-center', 'p-0'); + card_header.innerHTML = `<h3 class="mt-2">` + host.hostname + `</h3>` + + const card_body = document.createElement('div'); + card_body.classList.add('card-body', 'card-body-scroll', 'p-0'); + const bondgroup_list = document.createElement('ul'); + bondgroup_list.classList.add('list-group', 'list-group-flush', 'h-100') + card_body.appendChild(bondgroup_list) + + const card_footer = document.createElement('div'); + card_footer.classList.add('card-footer'); + card_footer.innerHTML = `<button class="btn btn-info w-100" onclick="workflow.onclickConfigureConnection('` + host.hostname + `')">Configure</button>` + + card_div.appendChild(card_header); + card_div.appendChild(card_body); + card_div.appendChild(card_footer); + + for (const bg of host.bondgroups) { + const outer_block = document.createElement('li'); + outer_block.classList.add('list-group-item'); + outer_block.innerHTML = ` + <h5>` + bg.ifaces[0].name + `</h5>` + + const inner_block = document.createElement('ul'); + inner_block.classList.add('connection-holder'); + outer_block.appendChild(inner_block) + for (const c of bg.connections) { + const connection_li = document.createElement('li'); + connection_li.innerText = c.connects_to + `: ` + c.tagged; + inner_block.appendChild(connection_li); + } + bondgroup_list.appendChild(outer_block) + } + + return new_card; + } + + /** */ + static refreshConnectionModal(connectionTemp) { + this.refreshConnectionTabs(connectionTemp.iface_list, connectionTemp.selected_index); + this.refreshConnectionTable(connectionTemp); + } + + /** Displays a tab in the connections modal for each interface + * Returns the name of the currently selected interface for use in the connections table + */ + static refreshConnectionTabs (iface_list, iface_index) { + const tablist_ul = document.getElementById('configure-connections-tablist'); + tablist_ul.innerHTML = ''; + for (const [index, iface_name] of iface_list.entries()) { + const li_interface = document.createElement('li'); + li_interface.classList.add('nav-item'); + const btn_interface = document.createElement('a'); + btn_interface.classList.add('nav-link', 'interface-btn'); + btn_interface.setAttribute("onclick", "workflow.onclickSelectIfaceTab(" + index +")"); + btn_interface.setAttribute('href', "#"); + btn_interface.setAttribute('role', 'tab'); + btn_interface.setAttribute('data-toggle', 'tab'); + btn_interface.innerText = iface_name.name; + li_interface.appendChild(btn_interface); + tablist_ul.appendChild(li_interface); + if (index == iface_index) { + btn_interface.classList.add('active'); + } + } + } + + static refreshConnectionTable(connectionTemp) { + const connections_table = document.getElementById('connections_widget'); + connections_table.innerHTML =` + <tr> + <th>Network</th> + <th colspan='2'>Vlan</th> + </tr> + `; + + const selected_iface_name = connectionTemp.iface_list[connectionTemp.selected_index].name; + const iface_config = connectionTemp.config.get(selected_iface_name); + for (const network of connectionTemp.networks) { + const tagged = iface_config.get(network.name); + const new_row = document.createElement('tr'); + const td_network = document.createElement('td'); + td_network.innerText = network.name; + new_row.appendChild(td_network); + new_row.appendChild(this.makeTagTd(true, network.name, tagged === true, selected_iface_name)); + new_row.appendChild(this.makeTagTd(false, network.name, tagged === false, selected_iface_name)); + connections_table.appendChild(new_row); + } + + // If an untagged is selected, disable all buttons that are not the selected button + if (document.querySelector(".vlan-radio.untagged.btn-success")) { + const other_buttons = document.querySelectorAll(".vlan-radio.untagged:not(.btn-success"); + for (const btn of other_buttons) { + btn.setAttribute("disabled", "true") + } + } + } + + static makeTagTd(tagged, network_name, isSelected, selected_iface_name) { + let tagged_as_str = "untagged" + if (tagged) { + tagged_as_str = "tagged" + } + + const td = document.createElement('td'); + const btn = document.createElement('button'); + btn.classList.add("btn", "w-100", "h-100", "vlan-radio", "border", tagged_as_str); + btn.setAttribute("onclick" ,"workflow.onclickSelectVlan('"+ network_name + "'," + tagged + ", '" + selected_iface_name +"')"); + if (isSelected) { + btn.classList.add('btn-success'); + } + btn.innerText = tagged_as_str; + td.appendChild(btn); + return td; + } + + static refreshPodSummaryDetails(pod_name, pod_desc, isPublic) { + const list = document.getElementById('pod_summary_pod_details'); + list.innerHTML = ''; + const name_li = document.createElement('li'); + name_li.innerText = 'Pod name: ' + pod_name; + list.appendChild(name_li); + + const desc_li = document.createElement('li') + desc_li.innerText = 'Description: ' + pod_desc; + list.appendChild(desc_li); + + const public_li = document.createElement('li'); + public_li.innerText = 'Public: ' + isPublic; + list.appendChild(public_li); + } + + static refreshPodSummaryHosts(host_list, flavors, images) { + const list = document.getElementById('pod_summary_hosts'); + list.innerHTML = ''; + + for (const host of host_list) { + const new_li = document.createElement('li'); + // new_li.innerText = hosts[i].hostname + ': ' + this.lab_flavor_from_uuid(hosts[i].flavor).name + ' (' + hosts[i].image + ')'; + const details = `${host.hostname}: ${flavors.get(host.flavor).name}, ${images.get(host.image).name}` + new_li.innerText = details; + list.appendChild(new_li); + } + } + + static update_pod_summary() { + // Takes a section (string) and updates the appropriate element's innertext + + if (section == 'pod_details') { + const list = document.getElementById('pod_summary_pod_details'); + list.innerHTML = ''; + const name_li = document.createElement('li'); + name_li.innerText = 'Pod name: ' + this.pod.pod_name; + list.appendChild(name_li); + + const desc_li = document.createElement('li') + desc_li.innerText = 'Description: ' + this.pod.pod_desc; + list.appendChild(desc_li); + + const public_li = document.createElement('li'); + public_li.innerText = 'Public: ' + this.pod.is_public; + list.appendChild(public_li); + } else if (section == 'hosts') { + const list = document.getElementById('pod_summary_hosts'); + list.innerHTML = ''; + const hosts = this.pod.host_list; + for (let i = 0; i < this.pod.host_list.length; i++) { + const new_li = document.createElement('li'); + new_li.innerText = hosts[i].hostname + ': ' + this.lab_flavor_from_uuid(hosts[i].flavor).name + ' (' + hosts[i].image + ')'; + list.appendChild(new_li); + } + } else { + console.log(section + ' is not a valid section.'); + } + } +} + +/** Holds in-memory configurations for the add resource step */ +class ResourceBuilder { + constructor(templateBlob) { + this.template_id = templateBlob.id; // UUID (String) + this.networks = templateBlob.networks; + this.original_configs = templateBlob.host_list; // List<HostConfigBlob> + this.user_configs = []; // List<HostConfigBlob> + this.tab = 0; // Currently selected tab index where configs will be saved to + + // Create deep copies of the hosts + for (let host of this.original_configs) { + const copied_host = new HostConfigBlob(host); + this.user_configs.push(copied_host); + } + } +} + +class ConnectionTemp { + // keep track of user inputs, commits to host bondgroups after user clicks submit + constructor(host, networks, iface_list) { + this.host = host; // reference + this.config = new Map(); // Map<iface_name, Map<network_name, tagged>}> + this.iface_list = iface_list; // List<IfaceBlob> + for (const i of iface_list) { + this.config.set(i.name, new Map()) + } + + // set initial mappings + for (const ebg of host.bondgroups) { + // if (ebg.ifaces[0].name) + const iface_config = this.config.get(ebg.ifaces[0].name); + for (const c of ebg.connections) { + iface_config.set(c.connects_to, c.tagged) + } + } + this.networks = networks; // List<NetworkBlob> + this.selected_index = 0; + } + + + /** Replaces the old configs in the hostconfigblob with the ones set in this.config */ + applyConfigs() { + this.host.bondgroups = []; + for (const [key, value] of this.config) { + if (value.size > 0) { + const full_iface = workflow.labFlavors.get(this.host.flavor).interfaces.filter((iface) => iface.name == key)[0]; + const new_bg = new BondgroupBlob({}); + this.host.bondgroups.push(new_bg) + new_bg.ifaces.push(full_iface); + for (const [network, tagged] of value) { + if (tagged != null) { + new_bg.connections.push(new ConnectionBlob({"tagged": tagged, "connects_to": network})) + } + } + } + } + } +} + +function todo() { + alert('todo'); +}
\ No newline at end of file diff --git a/src/static/js/workflows/workflow.js b/src/static/js/workflows/workflow.js new file mode 100644 index 0000000..745a706 --- /dev/null +++ b/src/static/js/workflows/workflow.js @@ -0,0 +1,246 @@ +/* +Defines a common interface for creating workflows +Functions as the "view" part of MVC, or the "controller" part. Not really sure tbh +*/ + + +const HTTP = { + GET: "GET", + POST: "POST", + DELETE: "DELETE", + PUT: "PUT" +} + +const endpoint = { + LABS: "todo", // Not implemented + FLAVORS: "flavor/", + IMAGES: "images/", + TEMPLATES: "template/list/[username]", + SAVE_DESIGN_WORKFLOW: "todo", // Post MVP + SAVE_BOOKING_WORKFLOW: "todo", // Post MVP + MAKE_TEMPLATE: "template/create", + DELETE_TEMPLATE: "template", + MAKE_BOOKING: "booking/create", +} + +/** Functions as a namespace for static methods that post to the dashboard, then send an HttpRequest to LibLaas, then receive the response */ +class LibLaaSAPI { + + /** POSTs to dashboard, which then auths and logs the requests, makes the request to LibLaaS, and passes the result back to here. + Treat this as a private function. Only use the async functions when outside of this class */ + static makeRequest(method, endpoint, workflow_data) { + console.log("Making request: %s, %s, %s", method, endpoint, workflow_data.toString()) + const token = document.getElementsByName('csrfmiddlewaretoken')[0].value + return new Promise((resolve, reject) => {// -> HttpResponse + $.ajax( + { + crossDomain: true, // might need to change this back to true + method: "POST", + contentType: "application/json; charset=utf-8", + dataType : 'json', + headers: { + 'X-CSRFToken': token + }, + data: JSON.stringify( + { + "method": method, + "endpoint": endpoint, + "workflow_data": workflow_data + } + ), + timeout: 10000, + success: (response) => { + resolve(response); + }, + error: (response) => { + reject(response); + } + } + ) + }) + } + + static async getLabs() { // -> List<LabBlob> + // return this.makeRequest(HTTP.GET, endpoint.LABS, {}); + let jsonObject = JSON.parse('{"name": "UNH_IOL","description": "University of New Hampshire InterOperability Lab","location": "NH","status": 0}'); + return [new LabBlob(jsonObject)]; + } + + static async getLabFlavors(lab_name) { // -> List<FlavorBlob> + const data = await this.handleResponse(this.makeRequest(HTTP.GET, endpoint.FLAVORS, {"lab_name": lab_name})); + let flavors = []; + if (data) { + for (const d of data) { + flavors.push(new FlavorBlob(d)) + } + } else { + apiError("flavors") + } + return flavors; + // let jsonObject = JSON.parse('{"flavor_id": "aaa-bbb-ccc", "name": "HPE Gen 9", "description": "placeholder", "interfaces": ["ens1", "ens2", "ens3"]}') + // return [new FlavorBlob(jsonObject)]; + } + + static async getImagesForFlavor(flavor_id) { + let full_endpoint = endpoint.FLAVORS + flavor_id + '/[username]/' + endpoint.IMAGES; + const data = await this.handleResponse(this.makeRequest(HTTP.GET, full_endpoint, {})); + let images = [] + + if (data) { + for (const d of data) { + images.push(new ImageBlob(d)); + } + } else { + apiError("images") + } + + return images; + // let jsonObject = JSON.parse('{"image_id": "111-222-333", "name": "Arch Linux"}') + // let jsonObject2 = JSON.parse('{"image_id": "444-555-666", "name": "Oracle Linux"}') + // return [new ImageBlob(jsonObject), new ImageBlob(jsonObject2)]; + } + + /** Doesn't need to be passed a username because django will pull this from the request */ + static async getTemplatesForUser() { // -> List<TemplateBlob> + const data = await this.handleResponse(this.makeRequest(HTTP.GET, endpoint.TEMPLATES, {})) + let templates = [] + + if (data) + for (const d of data) { + templates.push(new TemplateBlob(d)) + } else { + apiError("templates") + } + return templates; + // let jsonObject = JSON.parse('{"id": "12345", "owner":"jchoquette", "lab_name":"UNH_IOL","pod_name":"test pod","pod_desc":"for e2e testing","public":false,"host_list":[{"hostname":"test-node","flavor":"1ca6169c-a857-43c6-80b7-09b608c0daec","image":"3fc3833e-7b8b-4748-ab44-eacec8d14f8b","cifile":[],"bondgroups":[{"connections":[{"tagged":true,"connects_to":"public"}],"ifaces":[{"name":"eno49","speed":{"value":10000,"unit":"BitsPerSecond"},"cardtype":"Unknown"}]}]}],"networks":[{"name":"public","public":true}]}') + // let jsonObject2 = JSON.parse('{"id":6789,"owner":"jchoquette","lab_name":"UNH_IOL","pod_name":"Other Host","pod_desc":"Default Template","public":false,"host_list":[{"cifile":["some ci data goes here"],"hostname":"node","flavor":"aaa-bbb-ccc","image":"111-222-333", "bondgroups":[{"connections": [{"tagged": false, "connects_to": "private"}], "ifaces": [{"name": "ens2"}]}]}],"networks":[{"name": "private", "public": false}]}'); + + return [new TemplateBlob(jsonObject)]; + } + + static async saveDesignWorkflow(templateBlob) { // -> bool + templateBlob.owner = user; + return await this.handleResponse(this.makeRequest(HTTP.PUT, endpoint.SAVE_DESIGN_WORKFLOW)) + } + + static async saveBookingWorkflow(bookingBlob) { // -> bool + bookingBlob.owner = user; + return await this.handleResponse(this.makeRequest(HTTP.PUT, endpoint.SAVE_BOOKING_WORKFLOW, bookingBlob)); + } + + static async makeTemplate(templateBlob) { // -> UUID or null + templateBlob.owner = user; + console.log(JSON.stringify(templateBlob)) + return await this.handleResponse(this.makeRequest(HTTP.POST, endpoint.MAKE_TEMPLATE, templateBlob)); + } + + static async deleteTemplate(template_id) { // -> UUID or null + return await this.handleResponse(this.makeRequest(HTTP.DELETE, endpoint.DELETE_TEMPLATE + "/" + template_id, {})); + } + + /** PUT to the dashboard with the bookingBlob. Dashboard will fill in lab and owner, make the django model, then hit liblaas, then come back and fill in the agg_id */ + static async makeBooking(bookingBlob) { + return await this.handleResponse(this.createDashboardBooking(bookingBlob)); + } + + /** Wraps a call in a try / catch, processes the result, and returns the response or null if it failed */ + static async handleResponse(promise) { + try { + let x = await promise; + return x; + } catch(e) { + console.log(e) + return null; + } + } + + /** Uses PUT instead of POST to tell the dashboard that we want to create a dashboard booking instead of a liblaas request */ + static createDashboardBooking(bookingBlob) { + const token = document.getElementsByName('csrfmiddlewaretoken')[0].value + return new Promise((resolve, reject) => { // -> HttpResponse + $.ajax( + { + crossDomain: false, + method: "PUT", + contentType: "application/json; charset=utf-8", + dataType : 'json', + headers: { + 'X-CSRFToken': token + }, + data: JSON.stringify( + bookingBlob), + timeout: 10000, + success: (response) => { + resolve(response); + }, + error: (response) => { + reject(response); + } + } + ) + }) + } +} + + +/** Controller class that handles button inputs to navigate through the workflow and generate HTML dynamically + * Treat this as an abstract class and extend it in the appropriate workflow module. +*/ +class Workflow { + constructor(sections_list) { + this.sections = []; // List of strings + this.step = 0; // Current step of the workflow + this.sections = sections_list; + } + + /** Advances the workflow by one step and scrolls to that section + * Disables the previous button if the step becomes 0 after executing + * Enables the next button if the step is less than sections.length after executing + */ + goPrev() { + + if (workflow.step <= 0) { + return; + } + + this.step--; + + document.getElementById(this.sections[this.step]).scrollIntoView({behavior: 'smooth'}); + + if (this.step == 0) { + document.getElementById('prev').setAttribute('disabled', ''); + } else if (this.step == this.sections.length - 2) { + document.getElementById('next').removeAttribute('disabled'); + } + } + + goNext() { + if (this.step >= this.sections.length - 1 ) { + return; + } + + this.step++; + document.getElementById(this.sections[this.step]).scrollIntoView({behavior: 'smooth'}); + + if (this.step == this.sections.length - 1) { + document.getElementById('next').setAttribute('disabled', ''); + } else if (this.step == 1) { + document.getElementById('prev').removeAttribute('disabled'); + } + } + + goTo(step_number) { + while (step_number > this.step) { + this.goNext(); + } + + while (step_number < this.step) { + this.goPrev(); + } + } + +} + +function apiError(info) { + alert("Unable to fetch " + info +". Please try again later or contact support.") + } |