diff options
author | Justin Choquette <jchoquette@iol.unh.edu> | 2023-06-08 12:46:53 -0400 |
---|---|---|
committer | Justin Choquette <jchoquette@iol.unh.edu> | 2023-07-21 13:17:51 -0400 |
commit | a09db9f287a02873c0226759f8ea444bb304cd59 (patch) | |
tree | 59e744e4b998973a808abbae2d21fbdd6201d829 /src/static/js/workflows | |
parent | 8ddc7e820e120f1dde4e901d3cb6f1dd3f281e65 (diff) |
LaaS 3.0 Almost MVP
Change-Id: Ided9a43cf3088bb58a233dc459711c03f43e11b8
Signed-off-by: Justin Choquette <jchoquette@iol.unh.edu>
Diffstat (limited to 'src/static/js/workflows')
-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 |
4 files changed, 2329 insertions, 0 deletions
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.") + } |