%PDF- %PDF-
Direktori : /backups/router/usr/local/opnsense/www/js/ |
Current File : //backups/router/usr/local/opnsense/www/js/opnsense_widget_manager.js |
/* * Copyright (C) 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. */ class ResizeObserverWrapper { _lastWidths = {}; _lastHeights = {}; _observer = null; _debounce(f, delay = 50, ensure = true) { // debounce to prevent a flood of calls in a short time let lastCall = Number.NEGATIVE_INFINITY; let wait; let handle; return (...args) => { wait = lastCall + delay - Date.now(); clearTimeout(handle); if (wait <= 0 || ensure) { handle = setTimeout(() => { f(...args); lastCall = Date.now(); }, wait); } }; } observe(elements, onSizeChanged, onInitialize) { this._observer = new ResizeObserver(this._debounce((entries) => { if (entries != undefined && entries.length > 0) { for (const entry of entries) { const width = entry.contentRect.width; const height = entry.contentRect.height; let id = entry.target.id; if (id.length === 0) { // element has just rendered onInitialize(entry.target, width, height); // we're observing multiple elements of the same class, assign a unique id entry.target.id = Math.random().toString(36).substring(7); this._lastWidths[id] = width; this._lastHeights[id] = height; } else { if (width !== this._lastWidths[id] || height !== this._lastHeights[id]) { this._lastWidths[id] = width; this._lastHeights[id] = height; onSizeChanged(entry.target, width, height); } } } } })); elements.forEach((element) => { this._observer.observe(element); }); } disconnect() { this._observer.disconnect(); } } class WidgetManager { constructor(gridStackOptions = {}, gettext = {}) { this.gridStackOptions = gridStackOptions; this.runtimeOptions = {}; // non-persisted runtime options this.persistedOptions = {}; // persisted options this.gettext = gettext; this.loadedModules = {}; // id -> widget module this.breakoutLinks = {}; // id -> breakout links this.widgetTranslations = {}; // id -> translations this.widgetConfigurations = {}; // id -> per-widget configuration this.widgetClasses = {}; // id -> instantiated widget module this.widgetHTMLElements = {}; // id -> Element types this.widgetTickRoutines = {}; // id -> tick routines this.errorStates = {} // id -> error state this.grid = null; // gridstack instance this.moduleDiff = []; // list of module ids that are allowed, but not currently rendered this.resizeObserver = new ResizeObserverWrapper(); } async initialize() { // render header buttons, make sure this always runs so a user can clear the grid if necessary this._renderHeader(); try { // import allowed modules and current persisted configuration await this._loadWidgets(); // prepare widget markup this._initializeWidgets(); // render grid and append widget markup this._initializeGridStack(); // load all dynamic content and start tick routines await this._loadDynamicContent(); } catch (error) { console.error('Failed initializing Widgets', error); } } async _loadWidgets() { const response = await $.ajax('/api/core/dashboard/getDashboard', { type: 'GET', dataType: 'json', contentType: 'application/json' }).then(async (data) => { try { let configuration = data.dashboard; configuration.widgets.forEach(item => { this.widgetConfigurations[item.id] = item; }); this.persistedOptions = configuration.options; } catch (error) { // persisted config likely out of date, reset to defaults this.__restoreDefaults(); } const promises = data.modules.map(async (item) => { try { const mod = await import('/ui/js/widgets/' + item.module + '?t='+Date.now()); this.loadedModules[item.id] = mod.default; } catch (error) { console.error('Could not import module', item.module, error); } finally { this.breakoutLinks[item.id] = item.link; this.widgetTranslations[item.id] = item.translations; } }); // Load all modules simultaneously - this shouldn't take long await Promise.all(promises); }); } _initializeWidgets() { if ($.isEmptyObject(this.loadedModules)) { throw new Error('No widgets loaded'); } for (const [id, configuration] of Object.entries(this.widgetConfigurations)) { try { this._createGridStackWidget(id, this.loadedModules[id], configuration); } catch (error) { console.error(error); let $panel = this._makeWidget(id, ` <div class="widget-error"> <i class="fa fa-exclamation-circle text-danger"></i> <br/> ${this.gettext.failed} </div> `); this.widgetConfigurations[id] = { id: id, content: $panel.prop('outerHTML'), ...configuration }; } } this.moduleDiff = Object.keys(this.loadedModules).filter(x => !Object.keys(this.widgetConfigurations).includes(x)); } _createGridStackWidget(id, widgetClass, persistedConfig = {}) { if (!(id in this.loadedModules)) { throw new Error('Widget not loaded'); } // merge persisted config with defaults let config = { callbacks: { // pre-bind the updateGrid function to the widget instance updateGrid: () => { this._updateGrid.call(this, this.widgetHTMLElements[id]) } }, ...persistedConfig, } // instantiate widget const widget = new widgetClass(config); // make id accessible to the widget, useful for traceability (e.g. data-widget-id attribute in the DOM) widget.setId(id); this.widgetClasses[id] = widget; document.addEventListener('visibilitychange', (e) => { this.widgetClasses[id].onVisibilityChanged(!document.hidden); }); if (!id in this.widgetTranslations) { console.error('Missing translations for widget', id); } widget.setTranslations(this.widgetTranslations[id]); // setup generic panels let content = widget.getMarkup(); let $panel = this._makeWidget(id, content); let options = widget.getGridOptions(); if ('sizeToContent' in options && 'h' in persistedConfig) { // override the sizeToContent option with the persisted height to allow for manual resizing with scrollbar options.sizeToContent = persistedConfig.h; } const gridElement = { content: $panel.prop('outerHTML'), id: id, minW: 2, // force a minimum width of 2 unless specified otherwise ...config, ...options }; this.widgetConfigurations[id] = gridElement; } // runs only once _initializeGridStack() { let runtimeConfig = {} // XXX runtimeConfig can be populated with additional options based on the persistedOptions // structure to accomodate for persisted (gridstack) configuration options in the future. this.grid = GridStack.init({...this.gridStackOptions, ...runtimeConfig}); // before we render the grid, register the added event so we can store the Element type objects this.grid.on('added', (event, items) => { // store Elements for later use, such as update() and resizeToContent() items.forEach((item) => { this.widgetHTMLElements[item.id] = item.el; }); }); for (const event of ['disable', 'dragstop', 'dropped', 'removed', 'resizestop']) { this.grid.on(event, (event, items) => { $('#save-grid').show(); }); } // render to the DOM this.grid.load(Object.values(this.widgetConfigurations)); // force the cell height of each widget to the lowest value. The grid will adjust the height // according to the content of the widget. this.grid.cellHeight(1); // click handlers for widget removal. for (const id of Object.keys(this.widgetConfigurations)) { $(`#close-handle-${id}`).click((event) => { this._onWidgetClose(id); }); } } _renderHeader() { // Serialization options let $btn_group_container = $('.btn-group-container'); let $btn_group = $('<div\>').addClass('btn-group'); // Append Save button and directly next to it, a hidden spinner $btn_group_container.append($(` <button class="btn btn-primary" id="save-grid"> <span id="save-btn-text" class="show">${this.gettext.save}</span> <span id="icon-container"> <i class="fa fa-spinner fa-spin hide" id="save-spinner" style="font-size: 14px;"></i> <i class="fa fa-check checkmark hide" id="save-check" style="font-size: 14px;"></i> </span> </button> `)); $btn_group.append($(` <button class="btn btn-default" id="add_widget" style="display: none;" data-toggle="tooltip" title="${this.gettext.addwidget}"> <i class="fa fa-plus-circle fa-fw"></i> </button> `)); $btn_group.append($(` <button class="btn btn-secondary" style="display:none;" id="restore-defaults" data-toggle="tooltip" title=" ${this.gettext.restore}"> <i class="fa fa-window-restore fa-fw"></i> </button>`)); $btn_group.append($(` <button class="btn btn-secondary" id="edit-grid" data-toggle="tooltip" title="${this.gettext.edit}"> <i class="fa fa-pencil fa-fw"></i> </button> `)); // Append the button group to the container $btn_group.appendTo($btn_group_container); $('#add_widget').tooltip({placement: 'bottom', container: 'body'}); $('#restore-defaults').tooltip({placement: 'bottom', container: 'body'}); $('#edit-grid').tooltip({placement: 'bottom', container: 'body'}); // Initially hide the save button $('#save-grid').hide(); // Click event for save button $('#save-grid').click(async () => { await this._saveDashboard(); }); $('#add_widget').click(() => { let $content = $('<div></div>'); let $select = $('<select id="widget-selection" data-container="body" class="selectpicker" multiple="multiple"></select>'); // Sort options let options = []; for (const [id, widget] of Object.entries(this.loadedModules)) { if (this.moduleDiff.includes(id)) { options.push({ value: id, text: this.widgetTranslations[id].title ?? id }); } } options.sort((a, b) => a.text.localeCompare(b.text)); options.forEach(option => { $select.append($(`<option value="${option.value}">${option.text}</option>`)); }); $content.append($select); BootstrapDialog.show({ title: this.gettext.addwidget, draggable: true, animate: false, message: $content, buttons: [{ label: this.gettext.add, hotkey: 13, action: (dialog) => { let ids = $('select', dialog.$modalContent).val(); let changed = false; for (const id of ids) { if (id in this.loadedModules) { this.moduleDiff = this.moduleDiff.filter(x => x !== id); // XXX make sure to account for the defaults here in time this._createGridStackWidget(id, this.loadedModules[id]); this.grid.addWidget(this.widgetConfigurations[id]); this._onMarkupRendered(this.widgetClasses[id]); this._updateGrid(this.widgetHTMLElements[id]); if (this.runtimeOptions.editMode) { $('.widget-content').css('cursor', 'grab'); $('.link-handle').hide(); $('.close-handle').show(); $('.edit-handle').show(); } changed = true; } } if (changed) { $('#save-grid').show(); } dialog.close(); }, }, { label: this.gettext.cancel, action: (dialog) => { dialog.close(); } }], onshown: function (dialog) { $('#widget-selection').selectpicker(); }, onhide: function (dialog) { $('#widget-selection').selectpicker('destroy'); } }); }); $('#restore-defaults').click(() => { this._restoreDefaults(); }); $('#edit-grid').on('click', () => { $('#edit-grid').toggleClass('active'); if ($('#edit-grid').hasClass('active')) { this.runtimeOptions.editMode = true; this.grid.enableMove(true); this.grid.enableResize(true); $('.widget-content').css('cursor', 'grab'); $('.link-handle').hide(); $('.close-handle').show(); $('.edit-handle').show(); $('#add_widget').show(); $('#restore-defaults').show(); } else { this.runtimeOptions.editMode = false; this.grid.enableMove(false); this.grid.enableResize(false); $('.widget-content').css('cursor', 'default'); $('.link-handle').show(); $('.close-handle').hide(); $('.edit-handle').hide(); $('#add_widget').hide(); $('#restore-defaults').hide(); } }); $('#edit-grid').mouseup(function() { $(this).blur(); }); } /* Executes all widget post-render callbacks asynchronously and in "parallel". * No widget should wait on other widgets, and therefore the * individual widget tick() callbacks are not bound to a master timer, * this has the benefit of making it configurable per widget. */ async _loadDynamicContent() { // map to an array of context-bound _onMarkupRendered functions and their associated widget ids let tasks = Object.entries(this.widgetClasses).map(([id, widget]) => { return { id, func: this._onMarkupRendered.bind(this, widget) }; }); let functions = tasks.map(({ id, func }) => { return () => new Promise((resolve) => { resolve(func().then(result => ({ result, id })).catch(error => this._displayError(id, error))); }); }); // Fire away await Promise.all(functions.map(f => f())); } // Executed for each widget; starts the widget-specific tick routine. async _onMarkupRendered(widget) { // click handler for widget removal $(`#close-handle-${widget.id}`).click((event) => { this._onWidgetClose(widget.id); }); // load the widget dynamic content, make sure to bind the widget context to the callback let onMarkupRendered = widget.onMarkupRendered.bind(widget); // show a spinner while the widget is loading let $selector = $(`.widget-${widget.id} > .widget-content > .panel-divider`); $selector.after($(`<div class="widget-spinner spinner-${widget.id}"><i class="fa fa-spinner fa-spin"></i></div>`)); await onMarkupRendered(); $(`.spinner-${widget.id}`).remove(); // retrieve widget-specific options if (widget.isConfigurable()) { let $editHandle = $(` <div id="edit-handle-${widget.id}" class="edit-handle" style="display: none;"> <i class="fa fa-pencil"></i> </div> `); $(`#close-handle-${widget.id}`).before($editHandle); if (this.runtimeOptions.editMode) { $editHandle.show(); } $editHandle.on('click', async (event) => { await this._renderOptionsForm(widget); }); } // XXX this code enforces per-widget resize handle definitions, which isn't natively // supported by GridStack. $(this.widgetHTMLElements[widget.id]).attr('gs-resize-handles', widget.getResizeHandles()); this.widgetHTMLElements[widget.id].gridstackNode._initDD = false; this.grid.resizable(this.widgetHTMLElements[widget.id], true); // trigger initial widget resize and start observing resize events this.resizeObserver.observe( [document.querySelector(`.widget-${widget.id}`)], (elem, width, height) => { for (const subclass of elem.className.split(" ")) { let id = subclass.split('-')[1]; if (id in this.widgetClasses) { if (this.widgetClasses[id].onWidgetResize(elem, width, height)) { this._updateGrid(elem.parentElement.parentElement); } } } }, (elem, width, height) => { widget.onWidgetResize(this.widgetHTMLElements[widget.id], width, height); } ); // start the widget-specific tick routine let onWidgetTick = widget.onWidgetTick.bind(widget); const tick = async () => { try { await onWidgetTick(); this._clearError(widget.id); this._updateGrid(this.widgetHTMLElements[widget.id]); } catch (error) { this._displayError(widget.id, error); } } await tick(); const interval = setInterval(async () => { await tick(); }, widget.tickTimeout * 1000); // store the reference to the tick routine so we can clear it later on widget removal this.widgetTickRoutines[widget.id] = interval; } _clearError(widgetId) { if (widgetId in this.errorStates && this.errorStates[widgetId]) { $(`.widget-${widgetId} > .widget-content > .widget-error`).remove(); const widget = $(`.widget-${widgetId} > .widget-content > .panel-divider`); widget.nextAll().show(); this.errorStates[widgetId] = false; } } _displayError(widgetId, error) { if (widgetId in this.errorStates && this.errorStates[widgetId]) { return; } this.errorStates[widgetId] = true; console.error(`Failed to load content for widget: ${widgetId}, Error:`, error); const widget = $(`.widget-${widgetId} > .widget-content > .panel-divider`); widget.nextAll().hide(); widget.after(` <div class="widget-error"> <i class="fa fa-exclamation-circle text-danger"></i> <br/> ${this.gettext.failed} </div> `); this._updateGrid(this.widgetHTMLElements[widgetId]); } // Recalculate widget/grid dimensions _updateGrid(elem = null) { if (elem !== null) { this.grid.resizeToContent(elem); } else { for (const item of this.grid.getGridItems()) { this.grid.resizeToContent(item); } } } // Generic widget panels _makeWidget(identifier, content) { const title = this.widgetTranslations[identifier].title; const link = this.breakoutLinks[identifier] !== "" ? ` <div id="link-handle-${identifier}" class="link-handle"> <a href="${this.breakoutLinks[identifier]}" target="_blank"> <i class="fa fa-external-link fa-xs"></i> </a> </div> ` : ''; let $panel = $(`<div class="widget widget-${identifier}"></div>`); let $content = $(`<div class="widget-content"></div>`); let $header = $(` <div class="widget-header"> <div class="widget-header-left"></div> <div id="${identifier}-title" class="widget-title"><b>${title}</b></div> <div class="widget-command-container"> ${link} <div id="close-handle-${identifier}" class="close-handle" style="display: none;"> <i class="fa fa-times fa-xs"></i> </div> </div> </div> `); $content.append($header); let $divider = $(`<div class="panel-divider"><div class="line"></div></div></div>`); $content.append($divider); $content.append(content); $panel.append($content); return $panel; } __restoreDefaults(dialog) { $.ajax({type: "POST", url: "/api/core/dashboard/restoreDefaults"}).done((response) => { if (response['result'] == 'failed') { console.error('Failed to restore default widgets'); if (dialog !== undefined) { dialog.close(); } } else { window.location.reload(); } }) } _restoreDefaults() { BootstrapDialog.show({ title: this.gettext.restore, draggable: true, animate: false, message: this.gettext.restoreconfirm, buttons: [{ label: this.gettext.ok, hotkey: 13, action: (dialog) => { this.__restoreDefaults(dialog); } }, { label: this.gettext.cancel, action: (dialog) => { dialog.close(); } }] }); } async _saveDashboard() { // Show the spinner when the save operation starts $('#save-btn-text').toggleClass("show hide"); $('#save-spinner').addClass('show'); $('#save-grid').prop('disabled', true); let items = this.grid.save(false); items = await Promise.all(items.map(async (item) => { let widgetConfig = await this.widgetClasses[item.id].getWidgetConfig(); if (widgetConfig) { item['widget'] = widgetConfig; } // XXX the gridstack save() behavior is inconsistent with the responsive columnWidth option, // as the calculation will return impossible values for the x, y, w and h attributes. // For now, the gs-{x,y,w,h} attributes are a better representation of the grid for layout persistence if (this.grid.getColumn() >= 12) { let elem = $(this.widgetHTMLElements[item.id]); item.x = parseInt(elem.attr('gs-x')) ?? 1; item.y = parseInt(elem.attr('gs-y')) ?? 1; item.w = parseInt(elem.attr('gs-w')) ?? 1; item.h = parseInt(elem.attr('gs-h')) ?? 1; } else { // prevent restricting the grid to a few columns when saving on a smaller screen item.x = this.widgetConfigurations[item.id].x; item.y = this.widgetConfigurations[item.id].y; } delete item['callbacks']; return item; })); const payload = { options: {...this.persistedOptions}, widgets: items }; $.ajax({ type: "POST", url: "/api/core/dashboard/saveWidgets", dataType: "json", contentType: "application/json; charset=utf-8", data: JSON.stringify(payload), complete: (data, status) => { setTimeout(() => { let response = JSON.parse(data.responseText); if (response['result'] == 'failed') { console.error('Failed to save widgets', data); $('#save-grid').prop('disabled', false); $('#save-spinner').removeClass('show').addClass('hide'); $('#save-btn-text').removeClass('hide').addClass('show'); } else { $('#save-spinner').removeClass('show').addClass('hide'); $('#save-check').toggleClass("hide show"); setTimeout(() => { // Hide the save button upon successful save $('#save-grid').hide(); $('#save-check').toggleClass("show hide"); $('#save-btn-text').toggleClass("hide show"); $('#save-grid').prop('disabled', false); if ($('#edit-grid').hasClass('active')) { $('#edit-grid').click(); } }, 500) } }, 300); // Artificial delay to give more feedback on button click } }); } async _renderOptionsForm(widget) { let $content = $(`<div class="widget-options"></div>`); // parse widget options const options = await widget.getWidgetOptions(); const config = await widget.getWidgetConfig(); for (const [key, value] of Object.entries(options)) { let $option = $(`<div class="widget-option-container"></div>`); switch (value.type) { case 'select_multiple': let $select = $(`<select class="widget_optionsform_selectpicker" id="${value.id}" data-container="body" class="selectpicker" multiple="multiple"></select>`); for (const option of value.options) { let selected = config[key].includes(option.value); $select.append($(`<option value="${option.value}" ${selected ? 'selected' : ''}>${option.label}</option>`)); } $option.append($(`<div><b>${value.title}</b></div>`)); $option.append($select); break; default: console.error('Unknown option type', value.type); continue; } $content.append($option); } // present widget options BootstrapDialog.show({ title: this.gettext.options, draggable: true, animate: false, message: $content, buttons: [{ label: this.gettext.ok, hotkey: 13, action: async (dialog) => { let values = {}; for (const [key, value] of Object.entries(options)) { switch (value.type) { case 'select_multiple': values[key] = $(`#${value.id}`).val(); if (values[key].count === 0) { values[key] = value.default; } break; default: console.error('Unknown option type', value.type); } } widget.setWidgetConfig(values); await widget.onWidgetOptionsChanged(values); this._updateGrid(this.widgetHTMLElements[widget.id]); $('#save-grid').show(); dialog.close(); } }, { label: this.gettext.cancel, action: (dialog) => { dialog.close(); } }], onshown: function(dialog) { $('.widget_optionsform_selectpicker').selectpicker(); }, onhide: function(dialog) { $('.widget_optionsform_selectpicker').selectpicker('destroy'); } }); } _onWidgetClose(id) { clearInterval(this.widgetTickRoutines[id]); if (id in this.widgetClasses) this.widgetClasses[id].onWidgetClose(); this.grid.removeWidget(this.widgetHTMLElements[id]); if (id in this.loadedModules) this.moduleDiff.push(id); } }