public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox-backup 2/2] ui: add Traffic Control UI
Date: Fri, 19 Nov 2021 15:42:27 +0100	[thread overview]
Message-ID: <20211119144227.1337999-4-d.csapak@proxmox.com> (raw)
In-Reply-To: <20211119144227.1337999-1-d.csapak@proxmox.com>

adds a list of traffic control rules (with their current usage)
and let the user add/edit/remove them

the edit window currently has a grid for timeframes to add/remove
with input fields for start/endtime and checkboxes for the days

there are still some improvements possible, like having a seperate
grid for networks (the input field is maybe too small), or
optimizing consecutive days to a range (e.g. mon..wed instead of mon,tue,wed)

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
 www/Makefile                     |   2 +
 www/NavigationTree.js            |   6 +
 www/config/TrafficControlView.js | 197 +++++++++++++
 www/window/TrafficControlEdit.js | 464 +++++++++++++++++++++++++++++++
 4 files changed, 669 insertions(+)
 create mode 100644 www/config/TrafficControlView.js
 create mode 100644 www/window/TrafficControlEdit.js

diff --git a/www/Makefile b/www/Makefile
index 32a6d7d5..616c3e12 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -47,6 +47,7 @@ JSSRC=							\
 	config/UserView.js				\
 	config/TokenView.js				\
 	config/RemoteView.js				\
+	config/TrafficControlView.js			\
 	config/ACLView.js				\
 	config/SyncView.js				\
 	config/VerifyView.js				\
@@ -60,6 +61,7 @@ JSSRC=							\
 	window/DataStoreEdit.js				\
 	window/NotesEdit.js				\
 	window/RemoteEdit.js				\
+	window/TrafficControlEdit.js			\
 	window/NotifyOptions.js				\
 	window/SyncJobEdit.js				\
 	window/UserEdit.js				\
diff --git a/www/NavigationTree.js b/www/NavigationTree.js
index 6035526c..3b4e54ce 100644
--- a/www/NavigationTree.js
+++ b/www/NavigationTree.js
@@ -50,6 +50,12 @@ Ext.define('PBS.store.NavigationStore', {
 			path: 'pbsRemoteView',
 			leaf: true,
 		    },
+		    {
+			text: gettext('Traffic Control'),
+			iconCls: 'fa fa-exchange fa-rotate-90',
+			path: 'pbsTrafficControlView',
+			leaf: true,
+		    },
 		    {
 			text: gettext('Certificates'),
 			iconCls: 'fa fa-certificate',
diff --git a/www/config/TrafficControlView.js b/www/config/TrafficControlView.js
new file mode 100644
index 00000000..70532d6c
--- /dev/null
+++ b/www/config/TrafficControlView.js
@@ -0,0 +1,197 @@
+Ext.define('pmx-traffic-control', {
+    extend: 'Ext.data.Model',
+    fields: [
+	'name', 'rate-in', 'rate-out', 'burst-in', 'burst-out', 'network',
+	'timeframe', 'comment', 'cur-rate-in', 'cur-rate-out',
+	{
+	    name: 'rateInUsed',
+	    calculate: function(data) {
+		return (data['cur-rate-in'] || 0) / (data['rate-in'] || Infinity);
+	    },
+	},
+	{
+	    name: 'rateOutUsed',
+	    calculate: function(data) {
+		return (data['cur-rate-out'] || 0) / (data['rate-out'] || Infinity);
+	    },
+	},
+    ],
+    idProperty: 'name',
+    proxy: {
+	type: 'proxmox',
+	url: '/api2/json/admin/traffic-control',
+    },
+});
+
+Ext.define('PBS.config.TrafficControlView', {
+    extend: 'Ext.grid.GridPanel',
+    alias: 'widget.pbsTrafficControlView',
+
+    stateful: true,
+    stateId: 'grid-traffic-control',
+
+    title: gettext('Traffic Control'),
+
+//    tools: [PBS.Utils.get_help_tool("backup-remote")],
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	addRemote: function() {
+	    let me = this;
+            Ext.create('PBS.window.TrafficControlEdit', {
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+            }).show();
+	},
+
+	editRemote: function() {
+	    let me = this;
+	    let view = me.getView();
+	    let selection = view.getSelection();
+	    if (selection.length < 1) return;
+
+            Ext.create('PBS.window.TrafficControlEdit', {
+                name: selection[0].data.name,
+		listeners: {
+		    destroy: function() {
+			me.reload();
+		    },
+		},
+            }).show();
+	},
+
+	render_bandwidth: (value) => value ? Proxmox.Utils.format_size(value) + '/s' : '',
+
+	reload: function() { this.getView().getStore().rstore.load(); },
+
+	init: function(view) {
+	    Proxmox.Utils.monStoreErrors(view, view.getStore().rstore);
+	},
+    },
+
+    listeners: {
+	activate: 'reload',
+	itemdblclick: 'editRemote',
+    },
+
+    store: {
+	type: 'diff',
+	autoDestroy: true,
+	autoDestroyRstore: true,
+	sorters: 'name',
+	rstore: {
+	    type: 'update',
+	    storeid: 'pmx-traffic-control',
+	    model: 'pmx-traffic-control',
+	    autoStart: true,
+	    interval: 5000,
+	},
+    },
+
+    tbar: [
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Add'),
+	    handler: 'addRemote',
+	    selModel: false,
+	},
+	{
+	    xtype: 'proxmoxButton',
+	    text: gettext('Edit'),
+	    handler: 'editRemote',
+	    disabled: true,
+	},
+	{
+	    xtype: 'proxmoxStdRemoveButton',
+	    baseurl: '/config/traffic-control',
+	    callback: 'reload',
+	},
+    ],
+
+    viewConfig: {
+	trackOver: false,
+    },
+
+    columns: [
+	{
+	    header: gettext('Rule'),
+	    width: 200,
+	    sortable: true,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'name',
+	},
+	{
+	    header: gettext('Rate In'),
+	    width: 200,
+	    sortable: true,
+	    renderer: 'render_bandwidth',
+	    dataIndex: 'rate-in',
+	},
+	{
+	    header: gettext('Rate In Used'),
+	    xtype: 'widgetcolumn',
+	    dataIndex: 'rateInUsed',
+	    widget: {
+		xtype: 'progressbarwidget',
+		textTpl: '{percent:number("0")}%',
+		animate: true,
+	    },
+	},
+	{
+	    header: gettext('Rate Out'),
+	    width: 200,
+	    sortable: true,
+	    renderer: 'render_bandwidth',
+	    dataIndex: 'rate-out',
+	},
+	{
+	    header: gettext('Rate Out Used'),
+	    xtype: 'widgetcolumn',
+	    dataIndex: 'rateOutUsed',
+	    widget: {
+		xtype: 'progressbarwidget',
+		textTpl: '{percent:number("0")}%',
+		animate: true,
+	    },
+	},
+	{
+	    header: gettext('Burst In'),
+	    width: 200,
+	    sortable: true,
+	    renderer: 'render_bandwidth',
+	    dataIndex: 'burst-in',
+	},
+	{
+	    header: gettext('Burst Out'),
+	    width: 200,
+	    sortable: true,
+	    renderer: 'render_bandwidth',
+	    dataIndex: 'burst-out',
+	},
+	{
+	    header: gettext('Networks'),
+	    width: 200,
+	    sortable: true,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'network',
+	},
+	{
+	    header: gettext('Timeframes'),
+	    sortable: false,
+	    renderer: (timeframes) => Ext.String.htmlEncode(timeframes.join('; ')),
+	    dataIndex: 'timeframe',
+	    width: 200,
+	},
+	{
+	    header: gettext('Comment'),
+	    sortable: false,
+	    renderer: Ext.String.htmlEncode,
+	    dataIndex: 'comment',
+	    flex: 1,
+	},
+    ],
+});
diff --git a/www/window/TrafficControlEdit.js b/www/window/TrafficControlEdit.js
new file mode 100644
index 00000000..24e6b63f
--- /dev/null
+++ b/www/window/TrafficControlEdit.js
@@ -0,0 +1,464 @@
+Ext.define('PBS.window.TrafficControlEdit', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pbsTrafficControlEdit',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'sysadmin_traffic_control',
+    width: 800,
+
+    isAdd: true,
+
+    subject: gettext('Traffic Control Rule'),
+
+    fieldDefaults: { labelWidth: 120 },
+
+    cbindData: function(initialConfig) {
+	let me = this;
+
+	let baseurl = '/api2/extjs/config/traffic-control';
+	let name = initialConfig.name;
+
+	me.isCreate = !name;
+	me.url = name ? `${baseurl}/${name}` : baseurl;
+	me.method = name ? 'PUT' : 'POST';
+	return { };
+    },
+
+    controller: {
+	xclass: 'Ext.app.ViewController',
+
+	weekdays: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+
+	dowChanged: function(field, value) {
+	    let me = this;
+	    let record = field.getWidgetRecord();
+	    if (record === undefined) {
+		// this is sometimes called before a record/column is initialized
+		return;
+	    }
+	    let col = field.getWidgetColumn();
+	    record.set(col.dataIndex, value);
+	    record.commit();
+
+	    me.updateTimeframeField();
+	},
+
+	timeChanged: function(field, value) {
+	    let me = this;
+	    if (value === null) {
+		return;
+	    }
+	    let record = field.getWidgetRecord();
+	    if (record === undefined) {
+		// this is sometimes called before a record/column is initialized
+		return;
+	    }
+	    let col = field.getWidgetColumn();
+	    let hours = value.getHours().toString().padStart(2, '0');
+	    let minutes = value.getMinutes().toString().padStart(2, '0');
+	    record.set(col.dataIndex, `${hours}:${minutes}`);
+	    record.commit();
+
+	    me.updateTimeframeField();
+	},
+
+	addTimeframe: function() {
+	    let me = this;
+	    me.lookup('timeframes').getStore().add({
+		start: "00:00",
+		end: "23:59",
+		mon: true,
+		tue: true,
+		wed: true,
+		thu: true,
+		fri: true,
+		sat: true,
+		sun: true,
+	    });
+
+	    me.updateTimeframeField();
+	},
+
+	updateTimeframeField: function() {
+	    let me = this;
+
+	    let timeframes = [];
+	    me.lookup('timeframes').getStore().each((rec) => {
+		let timeframe = '';
+		let days = me.weekdays.filter(day => rec.data[day]);
+		if (days.length < 7 && days.length > 0) {
+		    timeframe += days.join(',') + ' ';
+		}
+		let { start, end } = rec.data;
+
+		timeframe += `${start}-${end}`;
+		timeframes.push(timeframe);
+	    });
+
+	    let field = me.lookup('timeframe');
+	    field.suspendEvent('change');
+	    field.setValue(timeframes.join(';'));
+	    field.resumeEvent('change');
+	},
+
+	removeTimeFrame: function(field) {
+	    let me = this;
+	    let record = field.getWidgetRecord();
+	    if (record === undefined) {
+		// this is sometimes called before a record/column is initialized
+		return;
+	    }
+
+	    me.lookup('timeframes').getStore().remove(record);
+	    me.updateTimeframeField();
+	},
+
+	parseTimeframe: function(timeframe) {
+	    let me = this;
+	    let [, days, start, end] = /^(?:(\S*)\s+)?([0-9:]+)-([0-9:]+)$/.exec(timeframe) || [];
+
+	    if (start === '0') {
+		start = "00:00";
+	    }
+
+	    let record = {
+		start,
+		end,
+	    };
+
+	    if (!days) {
+		days = 'mon..sun';
+	    }
+
+	    days = days.split(',');
+	    days.forEach((day) => {
+		if (record[day]) {
+		    return;
+		}
+
+		if (me.weekdays.indexOf(day) !== -1) {
+		    record[day] = true;
+		} else {
+		    // we have a range 'xxx..yyy'
+		    let [startDay, endDay] = day.split('..');
+		    let startIdx = me.weekdays.indexOf(startDay);
+		    let endIdx = me.weekdays.indexOf(endDay);
+
+		    if (endIdx < startIdx) {
+			endIdx += me.weekdays.length;
+		    }
+
+		    for (let dayIdx = startIdx; dayIdx <= endIdx; dayIdx++) {
+			let curDay = me.weekdays[dayIdx%me.weekdays.length];
+			if (!record[curDay]) {
+			    record[curDay] = true;
+			}
+		    }
+		}
+	    });
+
+	    return record;
+	},
+
+	setGridData: function(field, value) {
+	    let me = this;
+	    if (!value) {
+		return;
+	    }
+
+	    value = value.split(';');
+	    let records = value.map((timeframe) => me.parseTimeframe(timeframe));
+	    me.lookup('timeframes').getStore().setData(records);
+	},
+
+	control: {
+	    'grid checkbox': {
+		change: 'dowChanged',
+	    },
+	    'grid timefield': {
+		change: 'timeChanged',
+	    },
+	    'grid button': {
+		click: 'removeTimeFrame',
+	    },
+	    'field[name=timeframe]': {
+		change: 'setGridData',
+	    },
+	},
+    },
+
+    items: {
+	xtype: 'inputpanel',
+	onGetValues: function(values) {
+	    let me = this;
+	    let isCreate = me.up('window').isCreate;
+
+	    if (values['network-select'] === 'all') {
+		values.network = '0.0.0.0/0';
+	    } else if (values.network) {
+		values.network = values.network.split(/\s*,\s*/);
+	    }
+
+	    if (!Ext.isArray(values.timeframe)) {
+		values.timeframe = values.timeframe.split(';');
+	    }
+
+	    delete values['network-select'];
+
+	    if (!isCreate) {
+		PBS.Utils.delete_if_default(values, 'rate-in');
+		PBS.Utils.delete_if_default(values, 'rate-out');
+		PBS.Utils.delete_if_default(values, 'burst-in');
+		PBS.Utils.delete_if_default(values, 'burst-out');
+		if (typeof values.delete === 'string') {
+		    values.delete = values.delete.split(',');
+		}
+	    }
+
+	    return values;
+	},
+	column1: [
+	    {
+		xtype: 'pmxDisplayEditField',
+		name: 'name',
+		fieldLabel: gettext('Name'),
+		renderer: Ext.htmlEncode,
+		allowBlank: false,
+		minLength: 4,
+		cbind: {
+		    editable: '{isCreate}',
+		},
+	    },
+	    {
+		xtype: 'pmxBandwidthField',
+		fieldLabel: gettext('Rate In'),
+		name: 'rate-in',
+	    },
+	    {
+		xtype: 'pmxBandwidthField',
+		fieldLabel: gettext('Rate Out'),
+		name: 'rate-out',
+	    },
+	],
+
+	column2: [
+	    {
+		xtype: 'proxmoxtextfield',
+		name: 'comment',
+		cbind: {
+		    deleteEmpty: '{!isCreate}',
+		},
+		fieldLabel: gettext('Comment'),
+	    },
+	    {
+		xtype: 'pmxBandwidthField',
+		fieldLabel: gettext('Burst In'),
+		name: 'burst-in',
+	    },
+	    {
+		xtype: 'pmxBandwidthField',
+		fieldLabel: gettext('Burst Out'),
+		name: 'burst-out',
+	    },
+	],
+
+	columnB: [
+	    {
+		xtype: 'fieldcontainer',
+		fieldLabel: gettext('Network'),
+		layout: {
+		    type: 'hbox',
+		    align: 'stretch',
+		},
+		items: [
+		    {
+			flex: 1,
+			xtype: 'radiofield',
+			boxLabel: gettext('All Networks'),
+			name: 'network-select',
+			value: true,
+			inputValue: 'all',
+		    },
+		    {
+			xtype: 'radiofield',
+			boxLabel: gettext('Limit to'),
+			name: 'network-select',
+			inputValue: 'limit',
+			listeners: {
+			    change: function(field, value) {
+				this.up('window').lookup('network').setDisabled(!value);
+			    },
+			},
+		    },
+		    {
+			flex: 1,
+			margin: '0 0 0 10',
+			xtype: 'proxmoxtextfield',
+			name: 'network',
+			reference: 'network',
+			disabled: true,
+		    },
+		],
+	    },
+	    {
+		xtype: 'displayfield',
+		fieldLabel: gettext('Timeframes'),
+	    },
+	    {
+		xtype: 'fieldcontainer',
+		items: [
+		    {
+			xtype: 'grid',
+			height: 150,
+			scrollable: true,
+			reference: 'timeframes',
+			store: {
+			    fields: ['start', 'end', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+			    data: [],
+			},
+			columns: [
+			    {
+				text: gettext('Time Start'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'start',
+				widget: {
+				    xtype: 'timefield',
+				    isFormField: false,
+				    format: 'H:i',
+				    formatText: 'HH:MM',
+				},
+				flex: 1,
+			    },
+			    {
+				text: gettext('Time End'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'end',
+				widget: {
+				    xtype: 'timefield',
+				    isFormField: false,
+				    format: 'H:i',
+				    formatText: 'HH:MM',
+				    maxValue: '23:59',
+				},
+				flex: 1,
+			    },
+			    {
+				text: gettext('Mon'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'mon',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				text: gettext('Tue'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'tue',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				text: gettext('Wed'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'wed',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				text: gettext('Thu'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'thu',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				text: gettext('Fri'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'fri',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				text: gettext('Sat'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'sat',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				text: gettext('Sun'),
+				xtype: 'widgetcolumn',
+				dataIndex: 'sun',
+				width: 60,
+				widget: {
+				    xtype: 'checkbox',
+				    isFormField: false,
+				},
+			    },
+			    {
+				xtype: 'widgetcolumn',
+				width: 40,
+				widget: {
+				    xtype: 'button',
+				    iconCls: 'fa fa-trash-o',
+				},
+			    },
+			],
+		    },
+		],
+	    },
+	    {
+		xtype: 'button',
+		text: gettext('Add'),
+		iconCls: 'fa fa-plus-circle',
+		handler: 'addTimeframe',
+	    },
+	    {
+		xtype: 'hidden',
+		reference: 'timeframe',
+		name: 'timeframe',
+	    },
+	],
+    },
+
+    initComponent: function() {
+	let me = this;
+	me.callParent();
+	if (!me.isCreate) {
+	    me.load({
+		success: function(response) {
+		    let data = response.result.data;
+		    if (data.network?.length === 1 && data.network[0] === '0.0.0.0/0') {
+			data['network-select'] = 'all';
+			delete data.network;
+		    } else {
+			data['network-select'] = 'limit';
+		    }
+
+		    if (Ext.isArray(data.timeframe)) {
+			data.timeframe = data.timeframe.join(';');
+		    }
+
+		    me.setValues(data);
+		},
+	    });
+	}
+    },
+});
-- 
2.30.2





  parent reply	other threads:[~2021-11-19 14:43 UTC|newest]

Thread overview: 7+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2021-11-19 14:42 [pbs-devel] [PATCH widget-toolkit/proxmox-backup] implement traffic-control ui Dominik Csapak
2021-11-19 14:42 ` [pbs-devel] [PATCH widget-toolkit 1/1] form: copy BandwidthSelector/SizeField from PVE Dominik Csapak
2021-11-19 15:12   ` [pbs-devel] applied: " Thomas Lamprecht
2021-11-19 14:42 ` [pbs-devel] [PATCH proxmox-backup 1/2] api: traffic_control: add missing rename to 'kebab-case' Dominik Csapak
2021-11-20 21:47   ` [pbs-devel] applied: " Thomas Lamprecht
2021-11-19 14:42 ` Dominik Csapak [this message]
2021-11-20 21:57   ` [pbs-devel] applied: [PATCH proxmox-backup 2/2] ui: add Traffic Control UI Thomas Lamprecht

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20211119144227.1337999-4-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=pbs-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal