dashboard: add widget configuration options

This commit is contained in:
Stephan de Wit 2024-07-17 14:47:24 +02:00
parent 7c6e958897
commit 104fcf9412
5 changed files with 228 additions and 83 deletions

View File

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

View File

@ -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 = $(`
<div id="edit-handle-${widget.id}" class="edit-handle">
<i class="fa fa-pencil"></i>
@ -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 = $(`<div class="widget-options"></div>`);
// parse widget options
const options = await widget.getWidgetOptions();
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) {
$select.append($(`<option value="${option.value}" ${option.selected ? 'selected' : ''}>${option.value}</option>`));
}
if (value.options.every(obj => !obj.selected)) {
// No selection, apply the default.
$select.val(value.default);
}
$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: (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();

View File

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

View File

@ -71,6 +71,7 @@
<title>Traffic Graph</title>
<trafficin>Traffic In</trafficin>
<trafficout>Traffic Out</trafficout>
<interfaces>Interfaces</interfaces>
</translations>
</traffic>
<memory>

View File

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