%PDF- %PDF-
Mini Shell

Mini Shell

Direktori : /backups/router/usr/local/opnsense/www/js/
Upload File :
Create Path :
Current File : //backups/router/usr/local/opnsense/www/js/opnsense_ui.js

/*
 * Copyright (C) 2015-2024 Deciso B.V.
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice,
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 *
 * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
 * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
 * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
 * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 *
 * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 *
 * User interface shared components, requires opnsense.js for supporting functions.
 */

 /**
  * format bytes
  * @param bytes number of bytes to format
  * @param decimals decimal places
  * @return string
  */
 function byteFormat(bytes, decimals)
 {
     if (decimals === undefined) {
        decimals = 0;
     }
     const kb = 1024;
     const ndx = bytes === 0 ? 0 : Math.floor(Math.log(bytes) / Math.log(kb));
     const fileSizeTypes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
     return (bytes / Math.pow(kb, ndx)).toFixed(decimals) + ' ' + fileSizeTypes[ndx];
 }

/**
 * save form to server
 * @param url endpoint url
 * @param formid parent id to grep input data from
 * @param callback_ok
 * @param disable_dialog don't show input validation message box on failure
 * @param callback_fail
 */
function saveFormToEndpoint(url, formid, callback_ok, disable_dialog, callback_fail)
{
    disable_dialog = disable_dialog || false;
    const data = getFormData(formid);
    ajaxCall(url, data, function (data, status) {
        if ( status === "success" ) {
            // update field validation
            handleFormValidation(formid, data['validations']);

            // if there are validation issues, update our screen and show a dialog.
            if (data['validations'] !== undefined) {
                if (!disable_dialog) {
                    const detailsid = "errorfrm" + Math.floor((Math.random() * 10000000) + 1);
                    const errorMessage = $('<div></div>');
                    errorMessage.append('Please correct validation errors in form <br />');
                    errorMessage.append('<i class="fa fa-bug pull-right" aria-hidden="true" data-toggle="collapse" '+
                                        'data-target="#'+detailsid+'" aria-expanded="false" aria-controls="'+detailsid+'"></i>');
                    errorMessage.append('<div class="collapse" id="'+detailsid+'"><hr/><pre></pre></div>');

                    // validation message box is optional, form is already updated using handleFormValidation
                    BootstrapDialog.show({
                        type:BootstrapDialog.TYPE_WARNING,
                        title: 'Input validation',
                        message: errorMessage,
                        buttons: [{
                            label: 'Dismiss',
                            action: function(dialogRef){
                                dialogRef.close();
                            }
                        }],
                        onshown: function(){
                            // set debug information
                            $("#"+detailsid + " > pre").html(JSON.stringify(data, null, 2));
                        }
                    });
                }

                if ( callback_fail !== undefined ) {
                    // execute callback function
                    callback_fail(data);
                }
            } else if ( callback_ok !== undefined ) {
                // execute callback function
                callback_ok(data);
            }
        } else if ( callback_fail !== undefined ) {
            callback_fail(data);
        }
    });
}

/**
 * standard data mapper to map json request data to forms on this page
 * @param data_get_map named array containing form names and source url's to get data from {'frm_example':"/api/example/settings/get"};
 * @param server_params parameters to send to server
 * @return promise object, resolves when all are loaded
 */
function mapDataToFormUI(data_get_map, server_params) {
    const dfObj = new $.Deferred();

    // calculate number of items for deferred object to resolve
    let data_map_seq = 1;
    let data_map_count = 0;
    $.each(data_get_map, function(){
        data_map_count += 1;
    });

    if (server_params === undefined) {
        server_params = {};
    }

    const collected_data = {};
    $.each(data_get_map, function(data_index, data_url) {
        ajaxGet(data_url,server_params , function(data, status) {
            if (status === "success") {
                $("form").each(function() {
                    if ( $(this).attr('id') && $(this).attr('id').split('-')[0] === data_index) {
                        // related form found, load data
                        setFormData($(this).attr('id'), data);
                        collected_data[$(this).attr('id')] = data;
                    }
                });
            }
            if (data_map_seq === data_map_count) {
                dfObj.resolve(collected_data);
            }
            data_map_seq += 1;
        });
    });

    return dfObj;
}

/**
 * update service status buttons in user interface
 */
function updateServiceStatusUI(status)
{
    let status_html = '<span class="label label-opnsense label-opnsense-sm ';

    if (status === "running") {
        status_html += 'label-success';
    } else if (status === "stopped") {
        status_html += 'label-danger';
    } else {
        status_html += 'hidden';
    }

    status_html += '"><i class="fa fa-play fa-fw"></i></span>';

    $('#service_status_container').html(status_html);
}

/**
 * operate service status buttons in user interface
 */
function updateServiceControlUI(serviceName)
{
    if (serviceName == '') {
        return;
    }

    ajaxCall("/api/" + serviceName + "/service/status", {}, function(data) {
        let status_html = '<span class="label label-opnsense label-opnsense-sm ';
        let status_icon = '';
        let buttons = '';

        if (data['status'] === "running") {
            status_html += 'label-success';
            status_icon = 'play';
            buttons += '<span id="restartService" class="btn btn-sm btn-default"><i class="fa fa-repeat fa-fw"></i></span>';
            buttons += '<span id="stopService" class="btn btn-sm btn-default"><i class="fa fa-stop fa-fw"></span>';
        } else if (data['status'] === "stopped") {
            status_html += 'label-danger';
            status_icon = 'stop';
            buttons += '<span id="startService" class="btn btn-sm btn-default"><i class="fa fa-play fa-fw"></i></span>';
        } else {
            status_html += 'hidden';
        }

        status_html += '"><i class="fa fa-' + status_icon + ' fa-fw"></i></span>';

        $('#service_status_container').html(status_html + " " + buttons);

        if (data['widget'] !== undefined) {
            // tooltip service action widgets
            ['stop', 'start', 'restart'].forEach(function(action){
                let obj = $("#" + action + "Service");
                if (obj.length > 0) {
                    obj.tooltip({
                        'placement': 'bottom',
                        'title': data['widget']['caption_' + action]
                    });
                }
            });
        }

        const commands = ["start", "restart", "stop"];
        commands.forEach(function(command) {
            $("#" + command + "Service").click(function(){
                $('#OPNsenseStdWaitDialog').modal('show');
                ajaxCall("/api/" + serviceName + "/service/" + command, {},function() {
                    $('#OPNsenseStdWaitDialog').modal('hide');
                    ajaxCall("/api/" + serviceName + "/service/status", {}, function() {
                        updateServiceControlUI(serviceName);
                    });
                });
            });
        });
    });
}

/**
 * reformat all tokenizers on this document
 */
function formatTokenizersUI() {
    $('select.tokenize').each(function () {
        const sender = $(this);
        if (!sender.hasClass('tokenize2_init_done')) {
            // only init tokenize2 when not bound yet
            const hint = $(this).data("hint");
            const width = $(this).data("width");
            let number_of_items = 10;
            if (sender.data("size") !== undefined) {
                number_of_items = $(this).data("size");
            }
            sender.tokenize2({
                'tokensAllowCustom': $(this).data("allownew"),
                'placeholder': hint,
                'sortable': $(this).data('sortable') === true,
                'dropdownMaxItems': number_of_items
            });
            sender.parent().find('ul.tokens-container').css("width", width);

            // dropdown on focus (previous displayDropdownOnFocus)
            sender.on('tokenize:select', function(){
                $(this).tokenize2().trigger('tokenize:search', [$(this).tokenize2().input.val()]);
            });
            // bind add / remove events
            sender.on('tokenize:tokens:add', function(){
                sender.trigger("tokenize:tokens:change");
            });
            sender.on('tokenize:tokens:remove', function(){
                sender.trigger("tokenize:tokens:change");
            });

            // hook keydown -> tab to blur event
            sender.on('tokenize:deselect', function(){
                const e = $.Event("keydown");
                e.keyCode = 9;
                sender.tokenize2().trigger('tokenize:keydown', [e]);
            });

            sender.addClass('tokenize2_init_done');
        } else {
            // unbind change event while loading initial content
            sender.unbind('tokenize:tokens:change');

            // selected items
            const items = [];
            sender.find('option:selected').each(function () {
                items.push([$(this).val(), $(this).text()]);
            });

            // re-init tokenizer items
            sender.tokenize2().trigger('tokenize:clear');
            for (let i=0 ; i < items.length ; ++i) {
                sender.tokenize2().trigger('tokenize:tokens:add', items[i]);
            }
            sender.tokenize2().trigger('tokenize:select');
            sender.tokenize2().trigger('tokenize:dropdown:hide');
        }

        // propagate token changes to parent change()
        sender.on('tokenize:tokens:change', function(){
            sender.change();
        });

    });
}

/**
 * clear multiselect boxes on click event, works on standard and tokenized versions
 */
function addMultiSelectClearUI() {
    //enable Paste if supported
    if ((typeof navigator.clipboard === 'object') && (typeof navigator.clipboard.readText === 'function')) {
        $('.fa-paste').parent().show();
    }
    $('[id*="clear-options"]').each(function() {
        $(this).click(function() {
            const id = $(this).attr("id").replace(/_*clear-options_*/, '');
            let element = $('select[id="' + id + '"]');
            if (element.hasClass("tokenize")) {
                // trigger close on all Tokens
                element.tokenize2().trigger('tokenize:clear');
                element.change();
            } else {
                // remove options from selection
                element.find('option').prop('selected',false);
                if (element.hasClass('selectpicker')) {
                    element.selectpicker('refresh');
                }
            }
        });
    });
    $('[id*="select-options"]').each(function() {
        const id = $(this).attr("id").replace(/_*select-options_*/, '');
        $(this).click(function() {
            let element = $('select[id="' + id + '"]');
            element.find('option').prop('selected', true);
            if (element.hasClass('selectpicker')) {
                element.selectpicker('refresh');
            }
        });
    });
    $('[id*="copy-options"]').each(function() {
        $(this).click(function(e) {
            e.preventDefault();
            var currentFocus = document.activeElement;
            let src_id = $(this).attr("id").replace(/_*copy-options_*/, '');
            let element = $('select[id="' + src_id + '"]');
            let target = $("<textarea style='opacity:0;'/>").val(element.val().join('\n')) ;
            element.after(target);
            target.select().focus();
            document.execCommand("copy");
            target.remove();
            if (currentFocus && typeof currentFocus.focus === "function") {
                currentFocus.focus();
            }
        });
    });
    $('[id*="paste-options"]').each(function() {
        $(this).click(function(e) {
            e.preventDefault();
            let id = $(this).attr("id").replace(/_*paste-options_*/, '');
            let target = $('select[id="' + id + '"]');
            var cpb = navigator.clipboard.readText();
            $.when(cpb).then(function(cbtext) {
                let values = $.trim(cbtext).replace(/\n|\r/g, ",").split(",");
                $.each(values, function( index, value ) {
                     target.tokenize2().trigger('tokenize:tokens:add', [value, value, true]);
                });
                target.change(); // signal subscribers about changed data
            });
        });
    });
    /* Tokenizer <-> text for quick edits */
    $('[id*="to-text"]').each(function() {
        $(this).click(function(e) {
            e.preventDefault();
            let id = $(this).attr("id").replace(/_*to-text_*/, '');
            let source = $('div[id="select_' + id + '"]').hide().find('select');
            let destination = $('div[id="textarea_' + id + '"]').show().find('textarea');
            if (!source.hasClass('text_area_hooked')) {
                /* Switch to normal tokenizer view on change() */
                source.addClass('text_area_hooked');
                source.change(function(){
                    $('a[id="to-select_' + id + '"]').click();
                });
            }
            destination.val(source.val().join('\n'));
            destination.unbind('change').change(function(){
                source.tokenize2().trigger('tokenize:clear');
                $.each($(this).val().split("\n"), function( index, value ) {
                    source.tokenize2().trigger('tokenize:tokens:add', [value, value, true]);
                });
            });
        });
    });
    $('[id*="to-select"]').each(function() {
        $(this).click(function(e) {
            e.preventDefault();
            let id = $(this).attr("id").replace(/_*to-select_*/, '');
            $('div[id="select_' + id + '"]').show();
            $('div[id="textarea_' + id + '"]').hide();
        });
    });
}


/**
 * setup form help buttons
 */
function initFormHelpUI() {
    // handle help messages show/hide
    $("a.showhelp").click(function (event) {
        $("*[data-for='" + $(this).attr('id') + "']").toggleClass("hidden show");
        event.preventDefault();
    });

    // handle all help messages show/hide
    let elements = $('[id*="show_all_help"]');
    elements.click(function(event) {
        $(this).toggleClass("fa-toggle-on fa-toggle-off");
        $(this).toggleClass("text-success text-danger");
        if ($(this).hasClass("fa-toggle-on")) {
            if (window.sessionStorage) {
                sessionStorage.setItem('all_help_preset', 1);
            }
            $('[data-for*="help_for"]').addClass("show").removeClass("hidden");
        } else {
            $('[data-for*="help_for"]').addClass("hidden").removeClass("show");
            if (window.sessionStorage) {
                sessionStorage.setItem('all_help_preset', 0);
            }
        }
        event.preventDefault();
    });

    if (window.sessionStorage && sessionStorage.getItem('all_help_preset') === "1") {
        // show all help messages when preset was stored
        elements.toggleClass("fa-toggle-on fa-toggle-off").toggleClass("text-success text-danger");
        $('[data-for*="help_for"]').addClass("show").removeClass("hidden");
    }
}

/**
 * handle advanced show/hide
 */
function initFormAdvancedUI() {

    // handle striped tables hidden rows
    let targetNodes = $('table.table-striped tbody');
    let config = { attributes: true, subtree: true, attributeFilter: ["style"] };
    let callback = function(mutationsList, observer) {
        mutationsList.forEach(function(mutation) {
            if (mutation.target.tagName == "TR") {
                let currentValue = mutation.target.style.display;
                if (currentValue == "") {
                    // row is visible
                    $(mutation.target).next('.dummy_row').remove();
                } else if (currentValue == "none" && !$(mutation.target).next().hasClass("dummy_row")) {
                    // row is hidden and no dummy rows after it. insert one to keep stripes order
                    $(mutation.target).after("<tr data-advanced='hidden_row' class='dummy_row'></tr>");
                }
            }
        });
    }

    observer = new MutationObserver(callback);
    // observe all striped tables on page for style changes
    targetNodes.each(function(index, node) {
        observer.observe(node, config);
    });

    //  handle "advanced mode" toggle
    let elements = $('[id*="show_advanced"]');
    if (window.sessionStorage && sessionStorage.getItem('show_advanced_preset') === 1) {
        // show advanced options when preset was stored
        elements.toggleClass("fa-toggle-on fa-toggle-off");
        elements.toggleClass("text-success text-danger");
    } else {
        $('[data-advanced*="true"]').hide();
    }

    elements.click(function() {
        elements.toggleClass("fa-toggle-on fa-toggle-off");
        elements.toggleClass("text-success text-danger");
        if (elements.hasClass("fa-toggle-on")) {
            $('[data-advanced*="true"]').show();
            if (window.sessionStorage) {
                sessionStorage.setItem('show_advanced_preset', 1);
            }
        } else {
            $('[data-advanced*="true"]').hide()
            if (window.sessionStorage) {
                sessionStorage.setItem('show_advanced_preset', 0);
            }
        }
    });
}

/**
 * standard dialog when information is required, wrapper around BootstrapDialog
 */
function stdDialogInform(title, message, close, callback, type, cssClass) {
    const types = {
        "danger": BootstrapDialog.TYPE_DANGER,
        "default": BootstrapDialog.TYPE_DEFAULT,
        "info": BootstrapDialog.TYPE_INFO,
        "primary": BootstrapDialog.TYPE_PRIMARY,
        "success": BootstrapDialog.TYPE_SUCCESS,
        "warning": BootstrapDialog.TYPE_WARNING
    };
    if (!(type in types)) {
        type = 'info';
    }
    if (cssClass === undefined) {
        cssClass = '';
    }
    BootstrapDialog.show({
        title: title,
        message: message,
        cssClass: cssClass,
        type: types[type],
        buttons: [{
            label: close,
            action: function (dialogRef) {
                if (typeof callback !== 'undefined') {
                    callback();
                }
                dialogRef.close();
            }
        }]
    });
}

/**
 * standard dialog when confirmation is required, wrapper around BootstrapDialog
 */
function stdDialogConfirm(title, message, accept, decline, callback, type) {
    const types = {
        "danger": BootstrapDialog.TYPE_DANGER,
        "default": BootstrapDialog.TYPE_DEFAULT,
        "info": BootstrapDialog.TYPE_INFO,
        "primary": BootstrapDialog.TYPE_PRIMARY,
        "success": BootstrapDialog.TYPE_SUCCESS,
        "warning": BootstrapDialog.TYPE_WARNING
    };
    if (!(type in types)) {
        type = 'warning';
    }
    BootstrapDialog.confirm({
        title: title,
        message: message,
        type: types[type],
        btnCancelLabel: decline,
        btnOKLabel: accept,
        btnOKClass: 'btn-' + type,
        callback: function(result) {
            if (result) {
                callback();
            }
        }
    });
}

/**
 * wrapper for backwards compatibility (do not use)
 */
function stdDialogRemoveItem(message, callback) {
    stdDialogConfirm(
        stdDialogRemoveItem.defaults.title,  message, stdDialogRemoveItem.defaults.accept,
        stdDialogRemoveItem.defaults.decline, callback
    );
}

stdDialogRemoveItem.defaults = {
    'title': 'Remove',
    'accept': 'Yes',
    'decline': 'Cancel'
};


/**
 *  Action button, expects the following data attributes on the widget
 *      data-endpoint='/path/to/my/endpoint'
 *      data-label="Apply text"
 *      data-service-widget="service" (optional service widget to signal)
 *      data-error-title="My error message"
 */
$.fn.SimpleActionButton = function (params) {
    let this_button = this;
    this.construct = function () {
        let label_content = '<b>' + this_button.data('label') + '</b> <i class="reload_progress">';
        this_button.html(label_content);
        this_button.on('click', function () {
            this_button.find('.reload_progress').addClass("fa fa-spinner fa-pulse");
            let pre_action = function () {
                return (new $.Deferred()).resolve();
            }
            if (params && params.onPreAction) {
                pre_action = params.onPreAction;
            }
            pre_action().done(function () {
                ajaxCall(this_button.data('endpoint'), {}, function (data, status) {
                    let data_status = typeof data == 'object' && 'status' in data ? data['status'] : '';
                    if (params && params.onAction) {
                        params.onAction(data, status);
                    }
                    if ((status != "success" || (data_status.toLowerCase().trim() != 'ok')) && data_status !== '') {
                          BootstrapDialog.show({
                              type: BootstrapDialog.TYPE_WARNING,
                              title: this_button.data('error-title'),
                              message: data['status_msg'] ? data['status_msg'] : data['status'],
                              draggable: true
                          });
                    }
                    this_button.find('.reload_progress').removeClass("fa fa-spinner fa-pulse");
                    if (this_button.data('service-widget')) {
                        updateServiceControlUI(this_button.data('service-widget'));
                    }
                    if (this_button.data('grid-reload')) {
                        std_bootgrid_reload(this_button.data('grid-reload'));
                    }
                });
            }).fail(function () {
                this_button.find('.reload_progress').removeClass("fa fa-spinner fa-pulse");
            });
        });
    }

    return this.each(function () {
        const button = this_button.construct();
        return button;
    });
}


/**
 *  File upload dialog, constructs a modal, asks for a file to upload and sets {'payload': ..,, 'filename': ...}
 *  to specified endpoint. Endppoint response should contain validation errors including messages and row sequences,
 *  specified as :
 *  {
 *     validations: [
 *          {
 *            'sequence': X << sequence number,  first data record starts at 0
 *            'message': '' << validation message
 *          }
 *     ]
 *  }
 *
 *      data-endpoint='/path/to/my/endpoint'
 *      data-title="Apply text"
 */
$.fn.SimpleFileUploadDlg = function (params) {
    let this_button = this;

    this.construct = function () {
        this_button.click(function(){
            let content = $("<div/>");
            let fileinp = $("<input type='file'/>");
            let error_output = $("<textarea style='display:none; max-width:100%; height:200px;'/>");
            let doinp = $('<button style="display:none" type="button" class="btn btn-xs"/>');
            doinp.append($('<span class="fa fa-fw fa-check"></span>'));

            content.append(
                $("<table/>").append(
                    $("<tr/>").append(
                        $("<td style='width:200px;'/>").append(fileinp),
                        $("<td/>").append(doinp)
                    ),
                    $("<tr/>").append($("<td colspan='2' style='height:10px;'/>"))
                )
            );
            content.append(error_output);
            fileinp.change(function(evt) {
                if (evt.target.files[0]) {
                    var reader = new FileReader();
                    reader.onload = function(readerEvt) {
                        doinp.data('payload', readerEvt.target.result);
                        doinp.data('filename', fileinp.val().split('\\').pop());
                        doinp.show();
                    };
                    reader.readAsText(evt.target.files[0]);
                }
            });
            let dialog = BootstrapDialog.show({
                title: this_button.data('title'),
                type: BootstrapDialog.TYPE_DEFAULT,
                message: content
            });
            doinp.click(function(){
                let eparams =  {
                    'payload': $(this).data('payload'),
                    'filename': $(this).data('filename')
                };
                ajaxCall(this_button.data('endpoint'), eparams, function (data, status) {
                    if (params && params.onAction) {
                        params.onAction(data, status);
                    }
                    if (data.validations && data.validations.length > 0) {
                        // When validation errors are returned, write to textarea including original data lines.
                        let output = [];
                        let records = eparams.payload.split('\n');
                        records.shift();
                        for (r=0; r < records.length; ++r) {
                            let found = false;
                            for (i=0; i < data.validations.length ; ++i) {
                                if (r == data.validations[i].sequence) {
                                    if (!found) {
                                        output.push(records[data.validations[i].sequence]);
                                        found = true;
                                    }
                                    output.push('!! ' + data.validations[i].message);
                                }
                            }
                        }
                        error_output.val(output.join('\n')).show();
                    } else {
                        dialog.close();
                    }
                });
            });
        });
    }

    return this.each(function () {
        const button = this_button.construct();
        return button;
    });
}

/**
 * Changes an input to a selector with manual input option.
 * Expects the following structure:
 *      {
 *          group_name: {
 *              label: 'this items label',
 *              items: {                    << omit to mark the manual (empty) input
 *                  key: 'value',
 *              }
 *          }
 *      }
 * @param {*} params data structure to use for the select picker
 */
$.fn.replaceInputWithSelector = function (data, multiple=false) {
    let that = this;
    this.new_item = function() {
        let $div = $("<div/>");
        let $table = $('<table style="max-width: 348px"/>');
        let $select = $('<select data-live-search="true" data-size="5" data-width="348px"/>');
        if (multiple) {
            $select.attr('multiple', 'multiple');
        }
        $table.append(
            $("<tr/>").append(
                $("<td/>").append($select)
            )
        );
        $table.append(
            $("<tr/>").append(
                $("<td/>").append($('<input style="display:none;" type="text"/>'))
            )
        );
        $div.append($table);
        return $div;
    }

    this.construct = function () {
        let options = [];
        Object.keys(data).forEach((key, idx) => {
            if (data[key].items !== undefined) {
                let optgrp = $("<optgroup/>").attr('label', data[key].label);
                Object.keys(data[key].items).forEach((key2, idx2) => {
                    let this_item = data[key].items[key2];
                    optgrp.append($("<option/>").val(key2).text(this_item));
                });
                options.push(optgrp);
            } else {
                options.push($("<option/>").val('').text(data[key].label));
            }
        });
        let $target = that.new_item();
        $(this).replaceWith($target);
        let $this_input = $target.find('input');
        let $this_select = $target.find('select');
        for (i=0; i < options.length; ++i) {
            $this_select.append(options[i].clone());
        }
        $this_select.attr('for', $(this).attr('id')).selectpicker();
        $this_select.change(function(){
            let $value = $(this).val();
            if (Array.isArray($value)) {
                $value = $value.filter(value => value !== '').join(',');
            }
            if ($value !== '') {
                $this_input.val($value);
                $this_input.hide();
            } else {
                $this_input.show();
            }
        });
        $this_input.attr('id', $(this).attr('id'));
        $this_input.change(function(){
            if (multiple) {
                $this_select.val($(this).val().split(','));
            } else {
                $this_select.val($(this).val());
            }
            if ($(this).val() === '' || $this_select.val() === null || $this_select.val() == '') {
                $this_select.val('');
                $this_input.show();
            } else {
                $this_input.hide();
            }
            $this_select.selectpicker('refresh');
        });
        $this_input.show();
    }

    return this.each(function () {
        return $.proxy(that.construct, $(this))();
    });
}

/**
 * Parse URL hash to activate a tab and/or fetch search or edit phrase for use in a grid.
 * - Supports hashes with direct actions: "#edit=UUID" or "#search=UUID" without a tab.
 * - If the hash includes a tab name, & must be used (e.g., "#peers&edit=UUID").
 */
function getUrlHash(key=null) {
    const hash = window.location.hash.slice(1);
    if (!hash) return;

    const splitIndex = hash.indexOf('&');
    const tabName = splitIndex !== -1 ? hash.substring(0, splitIndex) : null;
    const action = splitIndex !== -1 ? hash.substring(splitIndex + 1) : hash;

    if (tabName) {
        const tabElement = $(`a[href="#${tabName}"]`);
        if (tabElement.length) {
            tabElement.tab('show');
        }
    }

    if (action) {
        const [prefix, rawPhrase] = action.includes('=') ? action.split('=') : [null, null];
        const decodedPhrase = rawPhrase ? decodeURIComponent(rawPhrase.trim()) : '';

        if ((prefix === key) || (key === null)) return decodedPhrase;
    }

    return '';
}

Zerion Mini Shell 1.0