Bläddra i källkod

Redo app user interface

Shiv Tyagi 3 år sedan
förälder
incheckning
9cddb9ac18

+ 4 - 5
app.py

@@ -640,12 +640,12 @@ def generate():
 
         base_url = request.url_root
         app.logger.info(base_url)
-        app.logger.info('Rendering generate.html')
-        return render_template('generate.html', token=token)
+        app.logger.info('Rendering index.html')
+        return render_template('index.html', token=token)
 
     except Exception as ex:
         app.logger.error(ex)
-        return render_template('generate.html', error='Error occured: ', ex=ex)
+        return render_template('error.html', ex=ex)
 
 @app.route('/view', methods=['GET'])
 def view():
@@ -672,8 +672,7 @@ def parse_build_categories(build_options):
 def home():
     app.logger.info('Rendering index.html')
     return render_template('index.html',
-                           get_vehicle_names=get_vehicle_names,
-                           get_default_vehicle_name=get_default_vehicle_name)
+                           token=None)
 
 @app.route("/builds/<path:name>")
 def download_file(name):

BIN
static/images/ardupilot_logo.png


+ 0 - 2
static/js/CollapsibleLists.js

@@ -1,2 +0,0 @@
-//code.iamkate.com
-var CollapsibleLists=function(){function e(b,c){[].forEach.call(b.getElementsByTagName("li"),function(a){c&&b!==a.parentNode||(a.style.userSelect="none",a.style.MozUserSelect="none",a.style.msUserSelect="none",a.style.WebkitUserSelect="none",a.addEventListener("click",g.bind(null,a)),f(a))})}function g(b,c){for(var a=c.target;"LI"!==a.nodeName;)a=a.parentNode;a===b&&f(b)}function f(b){var c=b.classList.contains("collapsibleListClosed"),a=b.getElementsByTagName("ul");[].forEach.call(a,function(a){for(var d=a;"LI"!==d.nodeName;)d=d.parentNode;d===b&&(a.style.display=c?"block":"none")});b.classList.remove("collapsibleListOpen");b.classList.remove("collapsibleListClosed");0<a.length&&b.classList.add("collapsibleList"+(c?"Open":"Closed"))}return{apply:function(b){[].forEach.call(document.getElementsByTagName("ul"),function(c){c.classList.contains("collapsibleList")&&(e(c,!0),b||[].forEach.call(c.getElementsByTagName("ul"),function(a){a.classList.add("collapsibleList")}))})},applyTo:e}}();

+ 0 - 116
static/js/CollapsibleLists.src.js

@@ -1,116 +0,0 @@
-/*
-
-CollapsibleLists.js
-
-An object allowing lists to dynamically expand and collapse
-
-Created by Kate Morley - http://code.iamkate.com/ - and released under the terms
-of the CC0 1.0 Universal legal code:
-
-http://creativecommons.org/publicdomain/zero/1.0/legalcode
-
-*/
-
-const CollapsibleLists = (function(){
-
-  // Makes all lists with the class 'collapsibleList' collapsible. The
-  // parameter is:
-  //
-  // doNotRecurse - true if sub-lists should not be made collapsible
-  function apply(doNotRecurse){
-
-    [].forEach.call(document.getElementsByTagName('ul'), node => {
-
-      if (node.classList.contains('collapsibleList')){
-
-        applyTo(node, true);
-
-        if (!doNotRecurse){
-
-          [].forEach.call(node.getElementsByTagName('ul'), subnode => {
-            subnode.classList.add('collapsibleList')
-          });
-
-        }
-
-      }
-
-    })
-
-  }
-
-  // Makes the specified list collapsible. The parameters are:
-  //
-  // node         - the list element
-  // doNotRecurse - true if sub-lists should not be made collapsible
-  function applyTo(node, doNotRecurse){
-
-    [].forEach.call(node.getElementsByTagName('li'), li => {
-
-      if (!doNotRecurse || node === li.parentNode){
-
-        li.style.userSelect       = 'none';
-        li.style.MozUserSelect    = 'none';
-        li.style.msUserSelect     = 'none';
-        li.style.WebkitUserSelect = 'none';
-
-        li.addEventListener('click', handleClick.bind(null, li));
-
-        toggle(li);
-
-      }
-
-    });
-
-  }
-
-  // Handles a click. The parameter is:
-  //
-  // node - the node for which clicks are being handled
-  function handleClick(node, e){
-
-    let li = e.target;
-    while (li.nodeName !== 'LI'){
-      li = li.parentNode;
-    }
-
-    if (li === node){
-      toggle(node);
-    }
-
-  }
-
-  // Opens or closes the unordered list elements directly within the
-  // specified node. The parameter is:
-  //
-  // node - the node containing the unordered list elements
-  function toggle(node){
-
-    const open = node.classList.contains('collapsibleListClosed');
-    const uls  = node.getElementsByTagName('ul');
-
-    [].forEach.call(uls, ul => {
-
-      let li = ul;
-      while (li.nodeName !== 'LI'){
-        li = li.parentNode;
-      }
-
-      if (li === node){
-        ul.style.display = (open ? 'block' : 'none');
-      }
-
-    });
-
-    node.classList.remove('collapsibleListOpen');
-    node.classList.remove('collapsibleListClosed');
-
-    if (uls.length > 0){
-      node.classList.add('collapsibleList' + (open ? 'Open' : 'Closed'));
-    }
-
-  }
-
-  return {apply, applyTo};
-
-})();

+ 449 - 0
static/js/add_build.js

@@ -0,0 +1,449 @@
+const Features = (() => {
+    let features = {};
+    let defines_dictionary = {};
+    let labels_dictionary = {};
+
+    function resetDictionaries() {
+        defines_dictionary = {}; // clear old dictionary
+        labels_dictionary = {}; // clear old dictionary
+        features.forEach((category, cat_idx) => {
+            category['options'].forEach((option, opt_idx) => {
+                defines_dictionary[option.define] = labels_dictionary[option.label] = {
+                    'category_index' : cat_idx,
+                    'option_index' : opt_idx,
+                };
+            });
+        });
+    }
+
+    function updateRequiredFor() {
+        features.forEach((category) => {
+            category['options'].forEach((option) => {
+                if (option.dependency != null) {
+                    option.dependency.split(',').forEach((dependency) => {
+                        let dep = getByLabel(dependency);
+                        if (dep.requiredFor == undefined) {
+                            dep.requiredFor = [];
+                        }
+                        dep.requiredFor.push(option.label);
+                    });
+                }
+            });
+        });
+    }
+
+    function reset(new_features) {
+        features = new_features;
+        resetDictionaries();
+        updateRequiredFor();
+    }
+
+    function getByDefine(define) {
+        let dict_value = defines_dictionary[define];
+        if (dict_value == undefined) {
+            return null;
+        }
+        return features[dict_value['category_index']]['options'][dict_value['option_index']];
+    }
+
+    function getByLabel(label) {
+        let dict_value = labels_dictionary[label];
+        if (dict_value == undefined) {
+            return null;
+        }
+        return features[dict_value['category_index']]['options'][dict_value['option_index']];
+    }
+
+    function updateDefaults(defines_array) {
+        // updates default on the basis of define array passed
+        // the define array consists define in format, EXAMPLE_DEFINE or !EXAMPLE_DEFINE
+        // we update the defaults in features object by processing those defines
+        for (let i=0; i<defines_array.length; i++) {
+            let select_opt = (defines_array[i][0] != '!');
+            let sanitised_define = (select_opt ? defines_array[i] : defines_array[i].substring(1)); // this removes the leading '!' from define if it contatins
+            if (getByDefine(sanitised_define)) {
+                let cat_idx = defines_dictionary[sanitised_define]['category_index'];
+                let opt_idx = defines_dictionary[sanitised_define]['option_index'];
+                getByDefine(sanitised_define).default = select_opt ? 1 : 0;
+            }
+        }
+    }
+
+    function fixDepencencyHelper(feature_label, visited) {
+        if (visited[feature_label] != undefined ) {
+            return;
+        }
+
+        visited[feature_label] = true;
+        document.getElementById(feature_label).checked = true;
+        let feature = getByLabel(feature_label);
+
+        if (feature.dependency == null) {
+            return;
+        }
+
+        let children = feature.dependency.split(',');
+        children.forEach((child) => {
+            fixDepencencyHelper(child, visited);
+        });
+    }
+
+    function fixAllDependencies() {
+        var visited = {};
+        Object.keys(labels_dictionary).forEach((label) => {
+            if (document.getElementById(label).checked) {
+                fixDepencencyHelper(label, visited);
+            }
+        });
+    }
+
+    function handleDependenciesForFeature(feature_label) {
+        var visited = {};
+        if (document.getElementById(feature_label).checked) {
+            fixDepencencyHelper(feature_label, visited);
+        } else {
+            enabled_dependent_features = getEnabledDependentFeaturesFor(feature_label);
+            if (enabled_dependent_features.length > 0) {
+                document.getElementById('modalBody').innerHTML = "The feature(s) <strong>"+enabled_dependent_features.join(", ")+"</strong> is/are dependant on <strong>"+feature_label+"</strong>" +
+                                                                 " and hence will be disabled too.<br><strong>Do you want to continue?</strong>";
+                document.getElementById('modalDisableButton').onclick = () => { disableCheckboxesByIds(enabled_dependent_features); }
+                document.getElementById('modalCancelButton').onclick = document.getElementById('modalCloseButton').onclick = () => { document.getElementById(feature_label).checked = true; };
+                var confirmationModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('dependencyCheckModal'));
+                confirmationModal.show();
+            }
+        }
+    }
+
+    function getEnabledDependentFeaturesHelper(feature_label,  visited, dependent_features) {
+        if (visited[feature_label] != undefined || document.getElementById(feature_label).checked == false) {
+            return;
+        }
+
+        visited[feature_label] = true;
+        dependent_features.push(feature_label);
+
+        let feature = getByLabel(feature_label);
+        if (feature.requiredFor == null) {
+            return;
+        }
+
+        feature.requiredFor.forEach((dependent_feature) => {
+            getEnabledDependentFeaturesHelper(dependent_feature, visited, dependent_features);
+        });
+    }
+
+    function getEnabledDependentFeaturesFor(feature_label) {
+        let dependent_features = [];
+        let visited = {};
+
+        if (getByLabel(feature_label).requiredFor) {
+            getByLabel(feature_label).requiredFor.forEach((dependent_feature) => {
+                getEnabledDependentFeaturesHelper(dependent_feature, visited, dependent_features);
+            });
+        }
+
+        return dependent_features;
+    }
+
+    function disableDependents(feature_label) {
+        if (getByLabel(feature_label).requiredFor == undefined) {
+            return;
+        }
+
+        getByLabel(feature_label).requiredFor.forEach((dependent_feature) => {
+            document.getElementById(dependent_feature).checked = false;
+        });
+    }
+
+    function applyDefaults() {
+        features.forEach(category => {
+            category['options'].forEach(option => {
+                element = document.getElementById(option['label']);
+                if (element != undefined) {
+                    element.checked = (option['default'] == 1);
+                }
+            });
+        });
+        fixAllDependencies();
+    }
+
+    return {reset, handleDependenciesForFeature, disableDependents, updateDefaults, applyDefaults};
+})();
+
+var all_categories_expanded = false;
+
+var pending_update_calls = 0;   // to keep track of unresolved Promises
+
+function init() {
+    onVehicleChange(document.getElementById("vehicle").value);
+}
+
+// enables or disables the elements with ids passed as an array
+// if enable is true, the elements are enabled and vice-versa
+function enableDisableElementsById(ids, enable) {
+    for (let i=0; i<ids.length; i++) {
+        let element = document.getElementById(ids[i]);
+        if (element) {
+            element.disabled = (!enable);
+        }
+    }
+}
+
+// sets a spinner inside the division with given id
+// also sets a custom message inside the division
+// this indicates that an ajax call related to that element is in progress
+function setSpinnerToDiv(id, message) {
+    let element = document.getElementById(id);
+    if (element) {
+        element.innerHTML = '<div class="container-fluid d-flex align-content-between">' +
+                                '<strong>'+message+'</strong>' +
+                                '<div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>' +
+                            '</div>';
+    }
+}
+
+function disableCheckboxesByIds(ids) {
+    ids.forEach((id) => {
+        box_element = document.getElementById(id);
+        if (box_element) {
+            box_element.checked = false;
+        }
+    })
+}
+
+function onVehicleChange(new_vehicle) {
+    // following elemets will be blocked (disabled) when we make the request
+    let elements_to_block = ['vehicle', 'branch', 'board', 'submit', 'reset_def', 'exp_col_button'];
+    enableDisableElementsById(elements_to_block, false);
+    let request_url = '/get_allowed_branches/'+new_vehicle;
+    setSpinnerToDiv('branch_list', 'Fetching branches...');
+    pending_update_calls += 1;
+    sendAjaxRequestForJsonResponse(request_url)
+        .then((json_response) => {
+            let new_branch = json_response.default_branch;
+            let all_branches = json_response.branches;
+            updateBranches(all_branches, new_branch);
+        })
+        .catch((message) => {
+            console.log("Branch update failed. "+message);
+        })
+        .finally(() => {
+            enableDisableElementsById(elements_to_block, true);
+            pending_update_calls -= 1;
+            fetchAndUpdateDefaults();
+        });
+}
+
+function updateBranches(all_branches, new_branch) {
+    let branch_element = document.getElementById('branch');
+    let old_branch = branch_element ? branch_element.value : '';
+    fillBranches(all_branches, new_branch);
+    if (old_branch != new_branch) {
+        onBranchChange(new_branch);
+    }
+}
+
+function onBranchChange(new_branch) {
+    // following elemets will be blocked (disabled) when we make the request
+    let elements_to_block = ['vehicle', 'branch', 'board', 'submit', 'reset_def', 'exp_col_button'];
+    enableDisableElementsById(elements_to_block, false);
+    let request_url = '/boards_and_features/'+new_branch;
+
+    // create a temporary container to set spinner inside it
+    let temp_container = document.createElement('div');
+    temp_container.id = "temp_container";
+    temp_container.setAttribute('class', 'container-fluid w-25 mt-3');
+    let features_list_element = document.getElementById('build_options');   // append the temp container to the main features_list container
+    features_list_element.innerHTML = "";
+    features_list_element.appendChild(temp_container);
+    setSpinnerToDiv('temp_container', 'Fetching features...');
+    setSpinnerToDiv('board_list', 'Fetching boards...');
+    pending_update_calls += 1;
+    sendAjaxRequestForJsonResponse(request_url)
+        .then((json_response) => {
+            let boards = json_response.boards;
+            let new_board = json_response.default_board;
+            let new_features = json_response.features;
+            Features.reset(new_features);
+            updateBoards(boards, new_board);
+            fillBuildOptions(new_features);
+        })
+        .catch((message) => {
+            console.log("Boards and features update failed. "+message);
+        })
+        .finally(() => {
+            enableDisableElementsById(elements_to_block, true);
+            pending_update_calls -= 1;
+            fetchAndUpdateDefaults();
+        });
+}
+
+function updateBoards(all_boards, new_board) {
+    let board_element = document.getElementById('board');
+    let old_board = board_element ? board.value : '';
+    fillBoards(all_boards, new_board);
+    if (old_board != new_board) {
+        onBoardChange(new_board);
+    }
+}
+
+function onBoardChange(new_board) {
+    fetchAndUpdateDefaults();
+}
+
+function fetchAndUpdateDefaults() {
+    // return early if there is an unresolved promise (i.e., there is an ongoing ajax call)
+    if (pending_update_calls > 0) {
+        return;
+    }
+    elements_to_block = ['reset_def'];
+    document.getElementById('reset_def').innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Fetching defaults';
+    enableDisableElementsById(elements_to_block, false);
+    let branch = document.getElementById('branch').value;
+    let vehicle = document.getElementById('vehicle').value;
+    let board = document.getElementById('board').value;
+    let request_url = '/get_defaults/'+vehicle+'/'+branch+'/'+board;
+    sendAjaxRequestForJsonResponse(request_url)
+        .then((json_response) => {
+            Features.updateDefaults(json_response);
+        })
+        .catch((message) => {
+            console.log("Default reset failed. "+message);
+        })
+        .finally(() => {
+            if (document.getElementById('auto_apply_def').checked) {
+                Features.applyDefaults();
+            }
+            enableDisableElementsById(elements_to_block, true);
+            document.getElementById('reset_def').innerHTML = '<i class="bi bi-arrow-counterclockwise me-2"></i>Reset feature defaults';
+        });
+}
+
+function fillBoards(boards, default_board) {
+    let output = document.getElementById('board_list');
+    output.innerHTML =  '<label for="board" class="form-label"><strong>Select Board</strong></label>' +
+                        '<select name="board" id="board" class="form-select" aria-label="Select Board" onchange="onBoardChange(this.value);"></select>';
+    let boardList = document.getElementById("board")
+    boards.forEach(board => {
+        let opt = document.createElement('option');
+        opt.value = board;
+        opt.innerHTML = board;
+        opt.selected = (board === default_board);
+        boardList.appendChild(opt);
+    });
+}
+
+function toggle_all_categories() {
+    // toggle global state
+    all_categories_expanded = !all_categories_expanded;
+
+    all_collapse_elements = document.getElementsByClassName('collapse');
+
+    for (let i=0; i<all_collapse_elements.length;) {
+        collapse_instance = bootstrap.Collapse.getOrCreateInstance(all_collapse_elements[i]);
+        if (all_categories_expanded) {
+            collapse_instance.show();
+        } else {
+            collapse_instance.hide();
+        }
+    }
+}
+
+function createCategoryCard(category_name, options, expanded) {
+    options_html = "";
+    options.forEach(option => {
+        options_html += '<div class="form-check">' +
+                            '<input class="form-check-input" type="checkbox" value="1" name="'+option['label']+'" id="'+option['label']+'" onclick="Features.handleDependenciesForFeature(this.id);">' +
+                            '<label class="form-check-label" for="'+option['label']+'">' +
+                                option['description'].replace(/enable/i, "") +
+                            '</label>' +
+                        '</div>';
+    });
+
+    let id_prefix = category_name.split(" ").join("_");
+    let card_element = document.createElement('div');
+    card_element.setAttribute('class', 'card ' + (expanded == true ? 'h-100' : ''));
+    card_element.id = id_prefix + '_card';
+    card_element.innerHTML =    '<div class="card-header">' +
+                                    '<div class="d-flex justify-content-between">' +
+                                        '<span class="d-flex align-items-center"><strong>'+category_name+'</strong></span>' +
+                                        '<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#'+id_prefix+'_collapse" aria-expanded="false" aria-controls="'+id_prefix+'_collapse">' +
+                                            '<i class="bi bi-chevron-'+(expanded == true ? 'up' : 'down')+'" id="'+id_prefix+'_icon'+'"></i>' +
+                                        '</button>' +
+                                    '</div>' +
+                                '</div>';
+    let collapse_element = document.createElement('div');
+    collapse_element.setAttribute('class', 'collapse '+(expanded == true ? 'show' : ''));
+    collapse_element.id = id_prefix + '_collapse';
+    collapse_element.innerHTML = '<div class="container-fluid px-2 py-2">'+options_html+'</div>';
+    card_element.appendChild(collapse_element);
+
+    // add relevent event listeners
+    collapse_element.addEventListener('hide.bs.collapse', () => {
+        card_element.classList.remove('h-100');
+        document.getElementById(id_prefix+'_icon').setAttribute('class', 'bi bi-chevron-down');
+    });
+    collapse_element.addEventListener('shown.bs.collapse', () => {
+        card_element.classList.add('h-100');
+        document.getElementById(id_prefix+'_icon').setAttribute('class', 'bi bi-chevron-up');
+    });
+    return card_element;                  
+}
+
+function fillBuildOptions(buildOptions) {
+    let output = document.getElementById('build_options');
+    output.innerHTML =  '<p class="card-text"><strong>Available features for the current selection are:</strong></p>';
+
+    buildOptions.forEach((category, cat_idx) => {
+        if (cat_idx % 4 == 0) {
+            let new_row = document.createElement('div');
+            new_row.setAttribute('class', 'row');
+            new_row.id = 'category_'+parseInt(cat_idx/4)+'_row';
+            output.appendChild(new_row);
+        }
+        let col_element = document.createElement('div');
+        col_element.setAttribute('class', 'col-md-3 col-sm-6 mb-2');
+        col_element.appendChild(createCategoryCard(category['name'], category['options'], all_categories_expanded));
+        document.getElementById('category_'+parseInt(cat_idx/4)+'_row').appendChild(col_element);
+    });
+}
+
+// returns a Promise
+// the promise is resolved when we recieve status code 200 from the AJAX request
+// the JSON response for the request is returned in such case
+// the promise is rejected when the status code is not 200
+// the status code is returned in such case
+function sendAjaxRequestForJsonResponse(url) {
+    return new Promise((resolve, reject) => {
+        var xhr = new XMLHttpRequest();
+        xhr.open('GET', url);
+
+        // disable cache, thanks to: https://stackoverflow.com/questions/22356025/force-cache-control-no-cache-in-chrome-via-xmlhttprequest-on-f5-reload
+        xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0");
+        xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT");
+        xhr.setRequestHeader("Pragma", "no-cache");
+
+        xhr.onload = function () {
+            if (xhr.status == 200) {
+                resolve(JSON.parse(xhr.response));
+            } else {
+                reject("Got response:"+xhr.response+" (Status Code: "+xhr.status+")");
+            }
+        }
+        xhr.send();
+    });
+}
+
+function fillBranches(branches, branch_to_select) {
+    var output = document.getElementById('branch_list');
+    output.innerHTML =  '<label for="branch" class="form-label"><strong>Select Branch</strong></label>' +
+                        '<select name="branch" id="branch" class="form-select" aria-label="Select Branch" onchange="onBranchChange(this.value);"></select>';
+    branchList = document.getElementById("branch");
+    branches.forEach(branch => {
+        opt = document.createElement('option');
+        opt.value = branch['full_name'];
+        opt.innerHTML = branch['label'];
+        opt.selected = (branch['full_name'] === branch_to_select);
+        branchList.appendChild(opt);
+    });
+}

+ 176 - 0
static/js/index.js

@@ -0,0 +1,176 @@
+function init() {
+    refresh_builds();
+    // initialise tooltips by selector
+    $('body').tooltip({
+        selector: '[data-bs-toggle="tooltip"]'
+    });
+}
+
+function refresh_builds() {
+    var xhr = new XMLHttpRequest();
+    xhr.open('GET', "/builds/status.json");
+
+    // disable cache, thanks to: https://stackoverflow.com/questions/22356025/force-cache-control-no-cache-in-chrome-via-xmlhttprequest-on-f5-reload
+    xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0");
+    xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT");
+    xhr.setRequestHeader("Pragma", "no-cache");
+
+    xhr.onload = function () {
+        if (xhr.status === 200) {
+            updateBuildsTable(JSON.parse(xhr.response));
+        }
+        setTimeout(refresh_builds, 5000);
+    }
+    xhr.send();
+}
+
+function showFeatures(row_num) {
+    document.getElementById("featureModalBody").innerHTML = document.getElementById(`${row_num}_features_all`).innerHTML;
+    var feature_modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('featureModal'));
+    feature_modal.show();
+    return;
+}
+
+function updateBuildsTable(status_json) {
+    let output_container = document.getElementById('build_table_container');
+    if (Object.keys(status_json).length == 0) {
+        output_container.innerHTML = `<div class="alert alert-success" role="alert" id="welcome_alert">
+                                        <h4 class="alert-heading">Welcome!</h4>
+                                        <p>No builds were queued to run on the server recently. To queue one, please click <a href="./add_build" class="alert-link">add a build</a>.</p>
+                                      </div>`;
+        return;
+    }
+
+    // hide any tooltips which are currently open
+    // this is needed as they might get stuck 
+    // if the element to which they belong goes out of the dom tree
+    $('.tooltip-button').tooltip("hide");
+
+    let table_body_html = '';
+    let row_num = 0;
+    Object.keys(status_json).forEach((build_id) => {
+        let build_info = status_json[build_id];
+        let status_color = 'primary';
+        if (build_info['status'] == 'Finished') {
+            status_color = 'success';
+        } else if (build_info['status'] == 'Pending') {
+            status_color = 'warning';
+        } else if (build_info['status'] == 'Failed' || build_info['status'] == 'Error') {
+            status_color = 'danger';
+        }
+
+        table_body_html +=  `<tr>
+                                <td class="align-middle"><span class="badge text-bg-${status_color}">${build_info['status']}</span></td>
+                                <td class="align-middle">${build_info['age']}</td>
+                                <td class="align-middle"><a href="https://github.com/ArduPilot/ardupilot/commit/${build_info['git_hash_short']}">${build_info['git_hash_short']}</a></td>
+                                <td class="align-middle">${build_info['board']}</td>
+                                <td class="align-middle">${build_info['vehicle']}</td>
+                                <td class="align-middle" id="${row_num}_features">
+                                        ${build_info['features'].substring(0, 100)}... 
+                                        <span id="${row_num}_features_all" style="display:none;">${build_info['features']}</span>
+                                    <a href="javascript: showFeatures(${row_num});">show more</a>
+                                </td>
+                                <td class="align-middle">
+                                    <div class="progress border" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
+                                        <div class="progress-bar bg-${status_color}" style="width: ${build_info['progress']}%">${build_info['progress']}%</div>
+                                    </div>
+                                </td>
+                                <td class="align-middle">
+                                    <button class="btn btn-md btn-outline-primary m-1 tooltip-button" data-bs-toggle="tooltip" data-bs-animation="false" data-bs-title="View log" onclick="launchLogModal('${build_id}');">
+                                        <i class="bi bi-file-text"></i>
+                                    </button>
+                                    <button class="btn btn-md btn-outline-primary m-1 tooltip-button" data-bs-toggle="tooltip" data-bs-animation="false" data-bs-title="Open build directory" onclick="window.location.href = './builds/${build_id}';">
+                                        <i class="bi bi-folder2-open"></i>
+                                    </button>
+                                </td>
+                            </tr>`;
+        row_num += 1;
+    });
+
+    let table_html =    `<table class="table table-hover table-light">
+                            <thead class="table-dark">
+                                <th scope="col" style="width: 5%">Status</th>
+                                <th scope="col" style="width: 5%">Age (hr:min)</th>
+                                <th scope="col" style="width: 5%">Git Hash</th>
+                                <th scope="col" style="width: 5%">Board</th>
+                                <th scope="col" style="width: 5%">Vehicle</th>
+                                <th scope="col">Features</th>
+                                <th scope="col" style="width: 15%">Progress</th>
+                                <th scope="col" style="width: 15%">Actions</th>
+                            </thead>
+                            <tbody>${table_body_html}</tbody>
+                        </table>`;
+    output_container.innerHTML = table_html;
+}
+
+const LogFetch = (() => {
+    var stopFetch = true;
+    var build_id = null;
+    var scheduled_fetches = 0;
+
+    function startLogFetch(new_build_id) {
+        build_id = new_build_id;
+        stopFetch = false;
+        if (scheduled_fetches <= 0) {
+            scheduled_fetches = 1;
+            fetchLogFile();
+        }
+    }
+
+    function stopLogFetch() {
+        stopFetch = true;
+    }
+
+    function getBuildId() {
+        return build_id;
+    }
+
+    function fetchLogFile() {
+        if (stopFetch || !build_id) {
+            scheduled_fetches -= 1;
+            return;
+        }
+
+        var xhr = new XMLHttpRequest();
+        xhr.open('GET', `/builds/${build_id}/build.log`);
+
+        // disable cache, thanks to: https://stackoverflow.com/questions/22356025/force-cache-control-no-cache-in-chrome-via-xmlhttprequest-on-f5-reload
+        xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0");
+        xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT");
+        xhr.setRequestHeader("Pragma", "no-cache");
+
+        xhr.onload = () => {
+            if (xhr.status == 200) {
+                let logTextArea = document.getElementById('logTextArea');
+                let autoScrollSwitch = document.getElementById('autoScrollSwitch');
+                logTextArea.textContent = xhr.responseText;
+                if (autoScrollSwitch.checked) {
+                    logTextArea.scrollTop = logTextArea.scrollHeight;
+                }
+
+                if (xhr.responseText.includes('BUILD_FINISHED')) {
+                    stopFetch = true;
+                }
+            }
+            if (!stopFetch) {
+                setTimeout(fetchLogFile, 3000);
+            } else {
+                scheduled_fetches -= 1;
+            }
+        }
+        xhr.send();
+    }
+
+    return {startLogFetch, stopLogFetch, getBuildId};
+})();
+
+function launchLogModal(build_id) {
+    document.getElementById('logTextArea').textContent = `Fetching build log...\nBuild ID: ${build_id}`;
+    LogFetch.startLogFetch(build_id);
+    let logModalElement = document.getElementById('logModal');
+    logModalElement.addEventListener('hide.bs.modal', () => {
+        LogFetch.stopLogFetch();
+    });
+    let logModal = bootstrap.Modal.getOrCreateInstance(logModalElement);
+    logModal.show();
+}

+ 130 - 0
templates/add_build.html

@@ -0,0 +1,130 @@
+<!doctype html>
+<html lang="en">
+    
+<head>
+    <meta charset="utf-8">
+    <title>ArduPilot Custom Firmware Builder</title>
+    <meta name="description"
+          content="ArduPilot Custom Firmware Builder. It allows to build custom ArduPilot firmware by selecting the wanted features.">
+    <meta name="author" content="ArduPilot Team">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+
+    <!-- OG Meta Tags to improve the way the post looks when you share the page on LinkedIn, Facebook, Google+ -->
+    <meta property="og:site_name" content="ArduPilot"/>
+    <meta property="og:site" content=""/>
+    <meta property="og:title" content="ArduPilot Custom Firmware Builder"/>
+    <meta property="og:description"
+          content="ArduPilot Custom Firmware Builder. It allows to build custom ArduPilot firmware by selecting the wanted features."/>
+    <!-- description shown in the actual shared post -->
+    <meta property="og:type" content="website">
+    <meta property="og:url" content="https://custom.ardupilot.org/">
+    <meta property="og:image" content="https://ardupilot.org/application/files/6315/7552/1962/ArduPilot-Motto.png">
+
+    <link rel="stylesheet" type="text/css" href="{{ url_for('static',filename='styles/main.css') }}">
+
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css">
+    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js" integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.min.js" integrity="sha384-mQ93GR66B00ZXjt0YO5KlohRA5SY2XofN4zfuZxLkoj1gXtW8ANNCe9d5Y3eG5eD" crossorigin="anonymous"></script>
+</head>
+
+<body class="bg-light" onload="javascript: init()">
+    <nav class="navbar bg-dark">
+        <div class="container-fluid">
+            <div>
+                <a class="navbar-brand" href="./" >
+                    <img src="{{ url_for('static', filename='images/ardupilot_logo.png')}}" alt="ArduPilot" height="24" class="d-inline-block align-text-top">
+                    <span class="ms-2 text-white" style="font-size: 25px;">Custom Firmware Builder</span>
+                </a>
+            </div>
+            <div>
+                <a href="https://github.com/ArduPilot/CustomBuild/issues/new" class="btn btn-outline-light me-1"><i class="bi bi-github me-2"></i>Report an issue</a>
+                <a href="./builds/" class="btn btn-outline-light me-1"><i class="bi bi-folder2-open me-2"></i>Go to builds directory</a>
+                <a href="./" class="btn btn-primary"><i class="bi bi-eye me-2"></i>View all builds</a>     
+            </div>
+        </div>
+    </nav>
+    <div class="container-fluid px-3 py-3">
+        <div class="card">
+            <div class="card-header d-flex justify-content-between">
+              <span class="d-flex align-items-center"><i class="bi bi-hammer me-2"></i><strong>ADD NEW BUILD</strong></span>
+              <button class="btn btn-primary" id="exp_col_button" onclick="toggle_all_categories();"><i class="bi bi-chevron-expand me-2"></i>Expand/Collapse all categories</button>    
+            </div>
+            <div class="card-body">
+              <form id="build-form" action="/generate" method="post">
+                <div class="row">
+                    <div class="col-md-4 col-sm-6 mb-2 d-flex align-items-end">
+                        <div class="container-fluid">
+                            <label for="vehicle" class="form-label"><strong>Select vehicle</strong></label>
+                            <select name="vehicle" id="vehicle" class="form-select" aria-label="Select vehicle" onchange="onVehicleChange(this.value);">
+                                {% for vehicle in get_vehicle_names() %}
+                                <option value="{{vehicle}}" {% if vehicle == get_default_vehicle_name() %} selected {% endif %}>{{vehicle}}</option>
+                                {% endfor %}
+                            </select>
+                        </div>
+                    </div>
+                    <div class="col-md-4 col-sm-6 mb-2 d-flex align-items-end">
+                        <div class="container-fluid" id="branch_list">
+                            <div class="container-fluid d-flex align-content-between">
+                                <strong>Fetching branches...</strong>
+                                <div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>
+                            </div>
+                        </div>
+                    </div>
+                    <div class="col-md-4 col-sm-12 mb-2 d-flex align-items-end">
+                        <div class="container-fluid" id="board_list">
+                            <div class="container-fluid d-flex align-content-between">
+                                <strong>Fetching boards...</strong>
+                                <div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+                <div class="container-fluid" id="build_options">
+                    <div class="container-fluid w-25">
+                        <div class="container-fluid d-flex align-content-between">
+                            <strong>Fetching features...</strong>
+                            <div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>
+                        </div>
+                    </div>
+                </div>
+              </form>
+            </div>
+            <div class="card-footer">
+                <div class="d-flex justify-content-between p-0">
+                    <div class="d-flex align-items-center">
+                        <div class="form-check form-switch">
+                            <input class="form-check-input" type="checkbox" role="switch" id="auto_apply_def" checked>
+                            <label class="form-check-label" for="auto_apply_def">Auto-apply feature defaults</label>
+                        </div>
+                    </div>
+                    <div>
+                        <button class="btn btn-outline-primary me-2" id="reset_def" onclick="Features.applyDefaults();"><i class="bi bi-arrow-counterclockwise me-2"></i>Reset feature defaults</button>
+                        <button type="submit" form="build-form" class="btn btn-primary" id="submit"><i class="bi bi-hammer me-2"></i>Generate build</button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+  
+    <!-- Dependency check modal -->
+    <div class="modal fade" id="dependencyCheckModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false" aria-labelledby="dependencyCheckModalLabel" aria-hidden="true">
+        <div class="modal-dialog">
+        <div class="modal-content">
+            <div class="modal-header">
+            <h5 class="modal-title" id="dependencyCheckModalLabel">Attention!</h5>
+            <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close" id="modalCloseButton"></button>
+            </div>
+            <div class="modal-body" id="modalBody">
+            </div>
+            <div class="modal-footer">
+            <button type="button" class="btn btn-danger" data-bs-dismiss="modal" id="modalDisableButton">Disable all</button>
+            <button type="button" class="btn btn-primary" data-bs-dismiss="modal" id="modalCancelButton">Cancel</button>
+            </div>
+        </div>
+        </div>
+    </div>
+  
+    <script type="text/javascript" src="{{ url_for('static', filename='js/add_build.js')}}"></script>
+</body>
+</html>

+ 13 - 0
templates/error.html

@@ -0,0 +1,13 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8">
+    <title>ArduPilot Custom Firmware Builder</title>
+    <link rel="stylesheet" type="text/css" href="{{ url_for('static',filename='styles/main.css') }}">
+</head>
+
+<h2>ArduPilot Custom Firmware Builder</h2>
+<p>Error Occured: {{ex}}</p>
+
+</html>

+ 0 - 71
templates/generate.html

@@ -1,71 +0,0 @@
-<!doctype html>
-<html lang="en">
-
-<head>
-    <meta charset="utf-8">
-    <title>ArduPilot Custom Firmware Builder</title>
-    <link rel="stylesheet" type="text/css" href="{{ url_for('static',filename='styles/main.css') }}">
-</head>
-
-{% if error %}
-<h2>ArduPilot Custom Firmware Builder</h2>
-<p>{{ error }} {{ex}}</p>
-{% else %}
-<body onload="javascript: reload()">
-
-<div id="main">
-    <a href="https://custom.ardupilot.org/">
-        <div id="logo">
-        </div>
-    </a>
-    <div id="menu">
-        <h2>ArduPilot Custom Firmware Builder</h2>
-        <p>Build in progress...</p>
-        <form action="/builds/{{token}}" target="_blank">
-            <input type="submit" value="Go to build directory"/>
-        </form>
-        <form action="/builds" target="_blank">
-            <input type="submit" value="See all builds"/>
-        </form>
-        <form action="/">
-            <input type="submit" value="Queue another build">
-        </form>
-    </div>
-    <p>Build ID: {{ token }}</p>
-    <p>Build progress:</p>
-    <textarea id="build_output" rows="30" cols="100" readonly>
-    </textarea>
-    <br><input type="checkbox" id="AutoScroll" checked>AutoScroll
-    <script>
-        function reload() {
-            var output = document.getElementById('build_output');
-            var xhr = new XMLHttpRequest();
-            xhr.open('GET', "/builds/{{token}}/build.log");
-
-            // disable cache, thanks to: https://stackoverflow.com/questions/22356025/force-cache-control-no-cache-in-chrome-via-xmlhttprequest-on-f5-reload
-            xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0");
-            xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT");
-            xhr.setRequestHeader("Pragma", "no-cache");
-
-            xhr.onload = function () {
-                if (xhr.status === 200) {
-                    output.textContent = xhr.responseText;
-
-                    var scrollcheck = document.getElementById('AutoScroll');
-                    if (scrollcheck.checked) {
-                        output.scrollTop = output.scrollHeight;
-                    }
-                    if (xhr.responseText.includes("BUILD_FINISHED")) {
-                        return;
-                    }
-                }
-                setTimeout(reload, 3000)
-            }
-            xhr.send();
-        }
-    </script>
-</div>
-</body>
-{% endif %}
-
-</html>

+ 91 - 338
templates/index.html

@@ -5,364 +5,117 @@
     <meta charset="utf-8">
     <title>ArduPilot Custom Firmware Builder</title>
     <meta name="description"
-          content="ArduPilot Custom Firmware Builder. It allows to build custom ArduPilot firmware by selecting the wanted features.">
+        content="ArduPilot Custom Firmware Builder. It allows to build custom ArduPilot firmware by selecting the wanted features.">
     <meta name="author" content="ArduPilot Team">
     <meta name="viewport" content="width=device-width, initial-scale=1">
 
     <!-- OG Meta Tags to improve the way the post looks when you share the page on LinkedIn, Facebook, Google+ -->
-    <meta property="og:site_name" content="ArduPilot"/>
-    <meta property="og:site" content=""/>
-    <meta property="og:title" content="ArduPilot Custom Firmware Builder"/>
+    <meta property="og:site_name" content="ArduPilot" />
+    <meta property="og:site" content="" />
+    <meta property="og:title" content="ArduPilot Custom Firmware Builder" />
     <meta property="og:description"
-          content="ArduPilot Custom Firmware Builder. It allows to build custom ArduPilot firmware by selecting the wanted features."/>
+        content="ArduPilot Custom Firmware Builder. It allows to build custom ArduPilot firmware by selecting the wanted features." />
     <!-- description shown in the actual shared post -->
     <meta property="og:type" content="website">
     <meta property="og:url" content="https://custom.ardupilot.org/">
     <meta property="og:image" content="https://ardupilot.org/application/files/6315/7552/1962/ArduPilot-Motto.png">
 
     <link rel="stylesheet" type="text/css" href="{{ url_for('static',filename='styles/main.css') }}">
-    <script type="text/javascript" src="{{ url_for('static', filename='js/CollapsibleLists.js')}}"></script>
+
+    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet"
+        integrity="sha384-GLhlTQ8iRABdZLl6O3oVMWSktQOp6b7In1Zl3/Jr59b6EGGoI1aFkw7cmDA6j6gD" crossorigin="anonymous">
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.3/font/bootstrap-icons.css">
 </head>
 
-<body onload="javascript: init()">
-<div id="main">
-    <a href="https://custom.ardupilot.org/">
-        <div id="logo">
+<body class="bg-light" onload="javascript: init()">
+    <nav class="navbar bg-dark">
+        <div class="container-fluid">
+            <div>
+                <a class="navbar-brand" href="./">
+                    <img src="{{ url_for('static', filename='images/ardupilot_logo.png')}}" alt="ArduPilot" height="24"
+                        class="d-inline-block align-text-top">
+                    <span class="ms-2 text-white" style="font-size: 25px;">Custom Firmware Builder</span>
+                </a>
+            </div>
+            <div>
+                <a href="https://github.com/ArduPilot/CustomBuild/issues/new" class="btn btn-outline-light me-1"><i
+                        class="bi bi-github me-2"></i>Report an issue</a>
+                <a href="./builds" class="btn btn-outline-light me-1"><i class="bi bi-folder2-open me-2"></i>Go to
+                    builds directory</a>
+                <a href="./add_build" class="btn btn-success"><i class="bi bi-plus-square me-2"></i>Add a build</a>
+            </div>
         </div>
-    </a>
-
-    <div id="menu">
-        <h2>ArduPilot Custom Firmware Builder</h2>
-        <p style="color: red;">To try out the newest features of the application, visit our beta server at <a href="https://custom-beta.ardupilot.org">custom-beta.ardupilot.org</a></p>
-        <form action="/generate" method="post">
-            <div id="vehicle_list">
-                <label for="vehicle">Choose a vehicle:
-                    <select name="vehicle" id="vehicle" onchange="onVehicleChange(this.value);">
-                        {% for vehicle in get_vehicle_names() %}
-                        <option value="{{vehicle}}" {% if vehicle == get_default_vehicle_name() %} selected {% endif %}>{{vehicle}}</option>
-                        {% endfor %}
-                    </select>
-                </label>
+    </nav>
+    <div class="container-fluid px-3 py-3" id="build_table_container">
+        <div class="container-fluid d-flex align-items-center justify-content-center" style="height: 100vh;">
+            <div class="container-fluid w-25 d-flex align-content-between">
+                <strong>Fetching builds...</strong>
+                <div class="spinner-border ms-auto" role="status" aria-hidden="true"></div>
             </div>
-            <p></p>
-            <div id="branch_list"></div>
-            <p></p>
-            <div id="board_list"></div>
-            <p></p>
-            <div id="build_options"></div>
-            <br>
-            <div id="message" style="color:red"></div>
-            <br>
-            <input type="submit" value="Generate" id="submit">
-            <input type="button" value="Reset option defaults" id="reset_def" onclick="Features.applyDefaults();">
-        </form>
+        </div>
     </div>
-    <hr>
-    <p>Exisiting builds (click on the status of a build to view it):</p>
-    <div id="build_status"></div>
-    <br/>
-    <script>
-
-        const Features = (() => {
-            let features = {};
-            let features_dictionary = {};
-
-            function resetDictionary() {
-                features_dictionary = {};
-                features.forEach((category, cat_idx) => {
-                    category['options'].forEach((option, opt_idx) => {
-                        features_dictionary[option.define] = {
-                            'category_index' : cat_idx,
-                            'option_index' : opt_idx,
-                        };
-                    });
-                });
-            }
-
-            function reset(new_features) {
-                features = new_features;
-                resetDictionary();
-            }
-
-            function getByDefine(define) {
-                let dict_value = features_dictionary[define];
-                if (dict_value == undefined) {
-                    return null;
-                }
-                return features[dict_value['category_index']]['options'][dict_value['option_index']];
-            }
-
-            function updateDefaults(defines_array) {
-                // updates default on the basis of define array passed
-                // the define array consists define in format, EXAMPLE_DEFINE or !EXAMPLE_DEFINE
-                // we update the defaults in features object by processing those defines
-                for (let i=0; i<defines_array.length; i++) {
-                    let select_opt = (defines_array[i][0] != '!');
-                    let sanitised_define = (select_opt ? defines_array[i] : defines_array[i].substring(1)); // this removes the leading '!' from define if it contatins
-                    if (getByDefine(sanitised_define)) {
-                        let cat_idx = features_dictionary[sanitised_define]['category_index'];
-                        let opt_idx = features_dictionary[sanitised_define]['option_index'];
-                        getByDefine(sanitised_define).default = select_opt ? 1 : 0;
-                    }
-                }
-            }
-
-            function applyDefaults() {
-                features.forEach(category => {
-                    category['options'].forEach(option => {
-                        element = document.getElementById(option['label']);
-                        if (element != undefined) {
-                            element.checked = (option['default'] == 1);
-                        }
-                    });
-                });
-            }
-
-            return {reset, getByDefine, updateDefaults, applyDefaults};
-        })();
-        
-        var pending_update_calls = 0;   // to keep track of unresolved Promises
-
-        function init() {
-            refresh_builds();
-            onVehicleChange(document.getElementById("vehicle").value);
-        }
-
-        function setMessage (messageText) {
-            document.getElementById("message").innerHTML = messageText;
-        }
-
-        // enables or disables the elements with ids passed as an array
-        // if enable is true, the elements are enabled and vice-versa
-        function enableDisableElementsById(ids, enable) {
-            for (let i=0; i<ids.length; i++) {
-                let element = document.getElementById(ids[i]);
-                if (element) {
-                    element.disabled = (!enable);
-                }
-            }
-        }
-
-        function onVehicleChange(new_vehicle) {
-            // following elemets will be blocked (disabled) when we make the request
-            let elements_to_block = ['vehicle', 'submit', 'reset_def'];
-            enableDisableElementsById(elements_to_block, false);
-            let request_url = '/get_allowed_branches/'+new_vehicle;
-            setMessage("Fetching the list of available branches for "+new_vehicle);
-            pending_update_calls += 1;
-            sendAjaxRequestForJsonResponse(request_url)
-                .then((json_response) => {
-                    let new_branch = json_response.default_branch;
-                    let all_branches = json_response.branches;
-                    updateBranches(all_branches, new_branch);
-                })
-                .catch((message) => {
-                    console.log("Branch update failed. "+message);
-                })
-                .finally(() => {
-                    setMessage("");
-                    enableDisableElementsById(elements_to_block, true);
-                    pending_update_calls -= 1;
-                    fetchAndUpdateDefaults();
-                });
-        }
 
-        function updateBranches(all_branches, new_branch) {
-            let branch_element = document.getElementById('branch');
-            let old_branch = branch_element ? branch_element.value : '';
-            fillBranches(all_branches, new_branch);
-            if (old_branch != new_branch) {
-                onBranchChange(new_branch);
-            }
-        }
-
-        function onBranchChange(new_branch) {
-            // following elemets will be blocked (disabled) when we make the request
-            let elements_to_block = ['branch', 'submit', 'reset_def'];
-            enableDisableElementsById(elements_to_block, false);
-            let request_url = '/boards_and_features/'+new_branch;
-            setMessage("Fetching the list of boards and features for "+new_branch);
-            pending_update_calls += 1;
-            sendAjaxRequestForJsonResponse(request_url)
-                .then((json_response) => {
-                    let boards = json_response.boards;
-                    let new_board = json_response.default_board;
-                    let new_features = json_response.features;
-                    updateBoards(boards, new_board);
-                    Features.reset(new_features);
-                    fillBuildOptions(new_features);
-                })
-                .catch((message) => {
-                    console.log("Boards and features update failed. "+message);
-                })
-                .finally(() => {
-                    setMessage("");
-                    enableDisableElementsById(elements_to_block, true);
-                    pending_update_calls -= 1;
-                    fetchAndUpdateDefaults();
-                });
-        }
-
-        function updateBoards(all_boards, new_board) {
-            let board_element = document.getElementById('board');
-            let old_board = board_element ? board.value : '';
-            fillBoards(all_boards, new_board);
-            if (old_board != new_board) {
-                onBoardChange(new_board);
-            }
-        }
- 
-        function onBoardChange(new_board) {
-            fetchAndUpdateDefaults();
-        }
-
-        function fetchAndUpdateDefaults() {
-            // return early if there is an unresolved promise (i.e., there is an ongoing ajax call)
-            if (pending_update_calls > 0) {
-                return;
-            }
-            elements_to_block = ['reset_def']
-            enableDisableElementsById(elements_to_block, false);
-            let branch = document.getElementById('branch').value;
-            let vehicle = document.getElementById('vehicle').value;
-            let board = document.getElementById('board').value;
-            let request_url = '/get_defaults/'+vehicle+'/'+branch+'/'+board;
-            setMessage("Fetching defaults for "+vehicle+" and "+board+" on "+branch);
-            sendAjaxRequestForJsonResponse(request_url)
-                .then((json_response) => {
-                    Features.updateDefaults(json_response);
-                    Features.applyDefaults();
-                })
-                .catch((message) => {
-                    console.log("Default reset failed. "+message);
-                })
-                .finally(() => {
-                    setMessage("");
-                    enableDisableElementsById(elements_to_block, true);
-                });
-        }
-
-        function refresh_builds() {
-            var output = document.getElementById('build_status');
-            var xhr = new XMLHttpRequest();
-            xhr.open('GET', "/builds/status.html");
-
-            // disable cache, thanks to: https://stackoverflow.com/questions/22356025/force-cache-control-no-cache-in-chrome-via-xmlhttprequest-on-f5-reload
-            xhr.setRequestHeader("Cache-Control", "no-cache, no-store, max-age=0");
-            xhr.setRequestHeader("Expires", "Tue, 01 Jan 1980 1:00:00 GMT");
-            xhr.setRequestHeader("Pragma", "no-cache");
-
-            xhr.onload = function () {
-                if (xhr.status === 200) {
-                    output.innerHTML = xhr.responseText;
-                }
-                setTimeout(refresh_builds, 5000)
-            }
-            xhr.send();
-        }
-
-        function fillBoards(boards, default_board) {
-            let output = document.getElementById('board_list');
-            output.innerHTML =  "<p>Please select the required options for the custom firmware build, then hit 'Generate'.</p>"+
-                                "<label for='board'>Choose a board: "+
-                                    "<select name='board' id='board' onchange='onBoardChange(this.value)'>"+
-                                    "</select>"+
-                                "</label>";
-            let boardList = document.getElementById("board")
-            boards.forEach(board => {
-                let opt = document.createElement('option');
-                opt.value = board;
-                opt.innerHTML = board;
-                opt.selected = (board === default_board);
-                boardList.appendChild(opt);
-            });
-        }
-
-        function fillBuildOptions(buildOptions) {
-            let output = document.getElementById('build_options');
-            output.innerHTML =  "<label for='features'>Select Features: "+
-                                    "<ul class='collapsibleList' id='outer_list'></ul>"+
-                                "</label>";
-            let outerList = document.getElementById("outer_list");
-            buildOptions.forEach(category => {
-                let outerListItem = document.createElement('li');
-                outerListItem.innerHTML = category['name'];
-                let innerList = document.createElement('ul');
-                category['options'].forEach(option => {
-                    let innerListItem = document.createElement('li');
-                    let checkBox = document.createElement('input');
-                    checkBox.type = "checkbox";
-                    checkBox.name = option['label'];
-                    checkBox.id = option['label'];
-                    checkBox.value = "1";
-                    checkBox.checked = (option['default'] == 1);
-                    checkBox.addEventListener('click', function handleClick(event) {
-                        dependencies(option['label'], option['dependency']);
-                    });
-                    innerListItem.appendChild(checkBox);
-                    innerListItem.appendChild(document.createTextNode(option['description']));
-                    innerList.appendChild(innerListItem);
-                });
-                outerListItem.appendChild(innerList);
-                outerList.appendChild(outerListItem);
-            });
-            CollapsibleLists.apply();
-        }
+    <!-- Log Modal -->
+    <div class="modal fade" id="logModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false" aria-labelledby="logModalLabel" aria-hidden="true">
+        <div class="modal-dialog modal-lg">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="logModalLabel">Build log</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                </div>
+                <div class="modal-body">
+                    <textarea class="form-control" id="logTextArea" rows="30" col="100" readonly
+                        style="font-family: Consolas, monaco, monospace; font-size: smaller;"></textarea>
+                </div>
+                <div class="modal-footer">
+                    <div class="container-fluid d-flex justify-content-between">
+                        <div class="d-flex align-items-center">
+                            <div class="form-check form-switch">
+                                <input class="form-check-input" type="checkbox" role="switch" id="autoScrollSwitch" checked>
+                                <label class="form-check-label" for="autoScrollSwitch">Auto-Scroll</label>
+                            </div>
+                        </div>
+                        <div class="p-2">
+                            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
 
-        function dependencies(f_label, f_dependency1) {
-            cb = document.getElementById(f_label);
-            switch (cb.name) {
-                case f_label:
-                    const f_dependency = f_dependency1.split(",")
-                    var arrayLength = f_dependency.length;
-                    for (let i = 0; i < arrayLength; i++) {
-                        if (document.getElementById(f_dependency[i]).checked == false) {
-                            document.getElementById(f_dependency[i]).checked = cb.checked;
-                        }
-                    }
-                    break;
-            }
-        }
+    <!-- Features Modal -->
+    <div class="modal fade" id="featureModal" tabindex="-1" aria-labelledby="featureModalLabel" aria-hidden="true">
+        <div class="modal-dialog modal-lg">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title" id="featureModalLabel">Included features</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                </div>
+                <div class="modal-body p-4" id="featureModalBody"></div>
+                <div class="modal-footer">
+                    <div class="container-fluid d-flex justify-content-end">
+                        <div class="p-2">
+                            <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
 
-        // returns a Promise
-        // the promise is resolved when we recieve status code 200 from the AJAX request
-        // the JSON response for the request is returned in such case
-        // the promise is rejected when the status code is not 200
-        // the status code is returned in such case
-        function sendAjaxRequestForJsonResponse(url) {
-            return new Promise((resolve, reject) => {
-                var xhr = new XMLHttpRequest();
-                xhr.open('GET', url);           
-                xhr.onload = function () {
-                    if (xhr.status == 200) {
-                        resolve(JSON.parse(xhr.response));
-                    } else {
-                        reject("Got response:"+xhr.response+" (Status Code: "+xhr.status+")");
-                    }
-                }
-                xhr.send();
-            });
-        }
+    <script src="https://code.jquery.com/jquery-3.6.3.min.js" integrity="sha256-pvPw+upLPUjgMXY0G+8O0xUf+/Im1MZjXxxgOcBQBXU=" crossorigin="anonymous"></script>
+    <script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js" integrity="sha256-lSjKY0/srUM9BE3dPm+c4fBo1dky2v27Gdjm2uoZaL0=" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.11.6/dist/umd/popper.min.js" integrity="sha384-oBqDVmMz9ATKxIep9tiCxS/Z9fNfEXiDAYTujMAeBAsjFuCZSmKbSSUnQlmh/jp3" crossorigin="anonymous"></script>
+    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.min.js" integrity="sha384-mQ93GR66B00ZXjt0YO5KlohRA5SY2XofN4zfuZxLkoj1gXtW8ANNCe9d5Y3eG5eD" crossorigin="anonymous"></script>
+    <script type="text/javascript" src="{{ url_for('static', filename='js/index.js')}}"></script>
 
-        function fillBranches(branches, branch_to_select) {
-            var output = document.getElementById('branch_list');
-            output.innerHTML =  "<label for='branch'>Choose a branch: "+
-                                    "<select name='branch' id='branch' onchange='onBranchChange(this.value);'>"+
-                                    "</select>"+
-                                "</label>";
-            branchList = document.getElementById("branch");
-            branches.forEach(branch => {
-                opt = document.createElement('option');
-                opt.value = branch['full_name'];
-                opt.innerHTML = branch['label'];
-                opt.selected = (branch['full_name'] === branch_to_select);
-                branchList.appendChild(opt);
-            });
-        }
+    {% if token != None %}
+    <script>
+        document.addEventListener("load", launchLogModal('{{token}}'));
     </script>
-</div>
+    {% endif %}
+
 </body>
 
-<hr>
-<footer>Created by Will Piper, <a href=https://github.com/ArduPilot/CustomBuild>Source Code</a>.</footer>
-</html>
+</html>

+ 0 - 16
templates/status.html

@@ -1,16 +0,0 @@
-<table class="status-table">
-  <thead>
-  <tr><th>Status</th><th>Age (hr:min)</th><th>Board</th><th>Vehicle</th><th>Features</th><th>ArduPilot Git Hash</th></tr>
-  </thead>
-  <tbody>
-  {% for (status,age,board,vehicle,link,features,git_hash_short) in build_status %}
-  <tr class="{{status}}">
-    <td><a href="{{link}}" target="_blank">{{status}}</a></td>
-    <td>{{age}}</td><td>{{board}}</td>
-    <td>{{vehicle}}</td><td>{{features}}</td>
-    <td><a href="https://github.com/ArduPilot/ardupilot/commit/{{git_hash_short}}" target="_blank">{{git_hash_short}}</a></td>
-  </tr>
-  {% endfor %}
-  </tbody>
-</table>
-