all lists on 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal