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();