From 104fcf941269965d47bc82eea1b396414109b404 Mon Sep 17 00:00:00 2001 From: Stephan de Wit Date: Wed, 17 Jul 2024 14:47:24 +0200 Subject: [PATCH] dashboard: add widget configuration options --- .../app/views/OPNsense/Core/dashboard.volt | 2 + .../www/js/opnsense_widget_manager.js | 203 ++++++++++++------ src/opnsense/www/js/widgets/BaseWidget.js | 52 +++-- src/opnsense/www/js/widgets/Metadata/Core.xml | 1 + src/opnsense/www/js/widgets/Traffic.js | 53 ++++- 5 files changed, 228 insertions(+), 83 deletions(-) diff --git a/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt b/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt index 502ba72ba..9beafa172 100644 --- a/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt +++ b/src/opnsense/mvc/app/views/OPNsense/Core/dashboard.volt @@ -64,11 +64,13 @@ $( document ).ready(function() { } }, { 'save': "{{ lang._('Save') }}", + 'ok': "{{ lang._('OK') }}", 'restore': "{{ lang._('Restore default layout') }}", 'addwidget': "{{ lang._('Add Widget') }}", 'add': "{{ lang._('Add') }}", 'cancel': "{{ lang._('Cancel') }}", 'failed': "{{ lang._('Failed to load widget') }}", + 'options': "{{ lang._('Options') }}", }); widgetManager.initialize(); }); diff --git a/src/opnsense/www/js/opnsense_widget_manager.js b/src/opnsense/www/js/opnsense_widget_manager.js index e9417c12b..60a4afeca 100644 --- a/src/opnsense/www/js/opnsense_widget_manager.js +++ b/src/opnsense/www/js/opnsense_widget_manager.js @@ -257,61 +257,8 @@ class WidgetManager { $('#save-grid').hide(); // Click event for save button - $('#save-grid').click(() => { - // 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.forEach((item) => { - // Store widget-specific configuration - let widgetConfig = 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 - 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; - - delete item['callbacks']; - }); - - $.ajax({ - type: "POST", - url: "/api/core/dashboard/saveWidgets", - dataType: "text", - contentType: 'text/plain', - data: JSON.stringify(items), - 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); - }, 500) - } - }, 300); // Artificial delay to give more feedback on button click - } - }); + $('#save-grid').click(async () => { + await this._saveDashboard(); }); $('#add_widget').click(() => { @@ -339,11 +286,12 @@ class WidgetManager { BootstrapDialog.show({ title: this.gettext.addwidget, draggable: true, + animate: false, message: $content, buttons: [{ label: this.gettext.add, hotkey: 13, - action: async (dialog) => { + action: (dialog) => { let ids = $('select', dialog.$modalContent).val(); let changed = false; for (const id of ids) { @@ -445,8 +393,7 @@ class WidgetManager { $(`.spinner-${widget.id}`).remove(); // retrieve widget-specific options - const options = widget.getWidgetOptions(); - if (!$.isEmptyObject(options)) { + if (widget.isConfigurable()) { let $editHandle = $(`
@@ -454,8 +401,8 @@ class WidgetManager { `); $(`#close-handle-${widget.id}`).before($editHandle); - $editHandle.click((event) => { - // TODO: implement + $editHandle.on('click', async (event) => { + await this._renderOptionsForm(widget); }); } @@ -553,6 +500,142 @@ class WidgetManager { return $panel; } + 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 + 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; + + delete item['callbacks']; + return item; + })); + + $.ajax({ + type: "POST", + url: "/api/core/dashboard/saveWidgets", + dataType: "text", + contentType: 'text/plain', + data: JSON.stringify(items), + 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); + }, 500) + } + }, 300); // Artificial delay to give more feedback on button click + } + }); + } + + async _renderOptionsForm(widget) { + let $content = $(`
`); + + // parse widget options + const options = await widget.getWidgetOptions(); + for (const [key, value] of Object.entries(options)) { + let $option = $(`
`); + switch (value.type) { + case 'select_multiple': + let $select = $(``); + + for (const option of value.options) { + $select.append($(``)); + } + + if (value.options.every(obj => !obj.selected)) { + // No selection, apply the default. + $select.val(value.default); + } + + $option.append($(`
${value.title}
`)); + $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: (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); + widget.onWidgetOptionsChanged(values); + $('#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]); this.widgetClasses[id].onWidgetClose(); diff --git a/src/opnsense/www/js/widgets/BaseWidget.js b/src/opnsense/www/js/widgets/BaseWidget.js index 22635918c..1d40a5871 100644 --- a/src/opnsense/www/js/widgets/BaseWidget.js +++ b/src/opnsense/www/js/widgets/BaseWidget.js @@ -40,6 +40,8 @@ export default class BaseWidget { this.timeoutPeriod = 1000; this.retryLimit = 3; this.eventSourceRetryCount = 0; // retrycount for $.ajax is managed in its own scope + + this.configurable = false; } /* Public functions */ @@ -48,12 +50,30 @@ export default class BaseWidget { return this.resizeHandles; } - getWidgetConfig() { - if (this.config !== undefined && 'widget' in this.config) { - return this.config['widget']; - } + setWidgetConfig(config) { + this.config['widget'] = config; + } - return false; + async getWidgetConfig() { + let widget_config = {}; + if (this.config !== undefined && 'widget' in this.config) { + widget_config = this.config['widget']; + } + const options = await this.getWidgetOptions(); + return Object.entries(options).reduce((acc, [key, value]) => { + if (key in widget_config && + widget_config[key] !== null && + widget_config[key] !== undefined && + (typeof(widget_config[key] === 'array') && widget_config[key].length !== 0) && + (typeof(widget_config[key] === 'object') && Object.keys(widget_config[key]).length !== 0) && + (typeof(widget_config[key] === 'string') && widget_config[key].length !== 0) + ) { + acc[key] = widget_config[key]; + } else { + acc[key] = value.default; + } + return acc; + }, {}); } setId(id) { @@ -64,17 +84,17 @@ export default class BaseWidget { this.translations = translations; } - /* Public virtual functions */ + isConfigurable() { + return this.configurable; + } + + /* Public virtual/override functions */ getGridOptions() { // per-widget gridstack options override return {}; } - getWidgetOptions() { - return {}; - } - getMarkup() { return $(""); } @@ -105,6 +125,14 @@ export default class BaseWidget { } } + async getWidgetOptions() { + return {}; + } + + onWidgetOptionsChanged(options) { + return null; + } + /* Utility/protected functions */ ajaxCall(url, data={}, method='GET') { @@ -155,10 +183,6 @@ export default class BaseWidget { return false; } - setWidgetConfig(config) { - this.config['widget'] = config; - } - openEventSource(url, onMessage) { this.closeEventSource(); diff --git a/src/opnsense/www/js/widgets/Metadata/Core.xml b/src/opnsense/www/js/widgets/Metadata/Core.xml index 376ea0728..7bb232566 100644 --- a/src/opnsense/www/js/widgets/Metadata/Core.xml +++ b/src/opnsense/www/js/widgets/Metadata/Core.xml @@ -71,6 +71,7 @@ Traffic Graph Traffic In Traffic Out + Interfaces diff --git a/src/opnsense/www/js/widgets/Traffic.js b/src/opnsense/www/js/widgets/Traffic.js index c5d59cac5..010636c41 100644 --- a/src/opnsense/www/js/widgets/Traffic.js +++ b/src/opnsense/www/js/widgets/Traffic.js @@ -27,8 +27,8 @@ import BaseWidget from "./BaseWidget.js"; export default class Traffic extends BaseWidget { - constructor() { - super(); + constructor(config) { + super(config); this.charts = { trafficIn: null, @@ -36,6 +36,8 @@ export default class Traffic extends BaseWidget { }; this.initialized = false; this.datasets = {inbytes: [], outbytes: []}; + this.configurable = true; + this.configChanged = false; } _set_alpha(color, opacity) { @@ -54,7 +56,7 @@ export default class Traffic extends BaseWidget { maintainAspectRatio: false, scaleShowLabels: false, tooltipEvents: [], - pointDot: false, + pointDot: true, scaleShowGridLines: true, responsive: true, normalized: true, @@ -121,7 +123,9 @@ export default class Traffic extends BaseWidget { }; } - _initialize(data) { + async _initialize(data) { + const config = await this.getWidgetConfig(); + this.datasets = {inbytes: [], outbytes: []}; for (const dir of ['inbytes', 'outbytes']) { let colors = Chart.colorschemes.tableau.Classic10; let i = 0; @@ -130,7 +134,7 @@ export default class Traffic extends BaseWidget { i++; this.datasets[dir].push({ label: data.interfaces[intf].name, - hidden: false, // XXX + hidden: !config.interfaces.includes(data.interfaces[intf].name), borderColor: colors[idx], backgroundColor: this._set_alpha(colors[idx], 0.5), pointHoverBackgroundColor: colors[idx], @@ -147,10 +151,9 @@ export default class Traffic extends BaseWidget { this.charts.trafficIn = new Chart($('#traffic-in')[0].getContext('2d'), this._chartConfig(this.datasets.inbytes)); this.charts.trafficOut = new Chart($('#traffic-out')[0].getContext('2d'), this._chartConfig(this.datasets.outbytes)); - this.initialized = true; } - _onMessage(event) { + async _onMessage(event) { if (!event) { super.closeEventSource(); } @@ -158,15 +161,20 @@ export default class Traffic extends BaseWidget { const data = JSON.parse(event.data); if (!this.initialized) { - this._initialize(data); + await this._initialize(data); this.initialized = true; } for (let chart of Object.values(this.charts)) { Object.keys(data.interfaces).forEach((intf) => { - chart.config.data.datasets.forEach((dataset) => { + chart.config.data.datasets.forEach(async (dataset) => { if (dataset.intf === intf) { let elapsed_time = data.time - dataset.last_time; + if (this.configChanged) { + // check hidden status of dataset + const config = await this.getWidgetConfig(); + dataset.hidden = !config.interfaces.includes(data.interfaces[intf].name); + } dataset.data.push({ x: new Date(data.time * 1000.0), y: Math.round(((data.interfaces[intf][dataset.src_field]) / elapsed_time) * 8, 0) @@ -178,6 +186,10 @@ export default class Traffic extends BaseWidget { }); chart.update('quiet'); } + + if (this.configChanged) { + this.configChanged = false; + } } getMarkup() { @@ -199,6 +211,29 @@ export default class Traffic extends BaseWidget { super.openEventSource('/api/diagnostics/traffic/stream/1', this._onMessage.bind(this)); } + async getWidgetOptions() { + const interfaces = await this.ajaxCall('/api/diagnostics/traffic/interface'); + return { + interfaces: { + title: this.translations.interfaces, + type: 'select_multiple', + options: Object.entries(interfaces.interfaces).map(([key,intf]) => { + return { + value: intf.name, + selected: this.config.widget?.interfaces?.includes(intf.name) ?? false + }; + }), + default: Object.entries(interfaces.interfaces) + .filter(([key, intf]) => key === 'lan' || key === 'wan') + .map(([key, intf]) => (intf.name)) + } + }; + } + + onWidgetOptionsChanged(options) { + this.configChanged = true; + } + onWidgetClose() { super.onWidgetClose(); this.charts.trafficIn.destroy();