From: Fabian Ebner <f.ebner@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [RFC v2 proxmox-backup] create prune simulator
Date: Wed, 28 Oct 2020 14:12:06 +0100 [thread overview]
Message-ID: <20201028131206.23593-1-f.ebner@proxmox.com> (raw)
A stand-alone ExtJS app that allows experimenting with
different backup schedules and prune parameters.
For performance reasons, the week table does not use
subcomponents, but raw HTML.
Signed-off-by: Fabian Ebner <f.ebner@proxmox.com>
---
Changes from v1:
* add list view of backups as we have in PBS
* make it possible to toggle the calendar and hide it by default
* make it possible to toggle the colors and turn them off by default
* fix backup sorting and uniqueness when there is more than one start time
* add a button for applying a new schedule instead of doing so continuosly
* make it more obvious that one can input a custom schedule
* error out on invalid schedule inputs
* make the number of weeks configurable
* show which rule keeps a specific backup
* keep all if all options are zero
* minor style improvements
docs/prune-simulator/extjs | 1 +
docs/prune-simulator/index.html | 13 +
docs/prune-simulator/prune-simulator.js | 699 ++++++++++++++++++++++++
3 files changed, 713 insertions(+)
create mode 120000 docs/prune-simulator/extjs
create mode 100644 docs/prune-simulator/index.html
create mode 100644 docs/prune-simulator/prune-simulator.js
diff --git a/docs/prune-simulator/extjs b/docs/prune-simulator/extjs
new file mode 120000
index 00000000..b71ec6ef
--- /dev/null
+++ b/docs/prune-simulator/extjs
@@ -0,0 +1 @@
+/usr/share/javascript/extjs
\ No newline at end of file
diff --git a/docs/prune-simulator/index.html b/docs/prune-simulator/index.html
new file mode 100644
index 00000000..deb26764
--- /dev/null
+++ b/docs/prune-simulator/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
+ <title>Prune Backups Simulator</title>
+
+ <link rel="stylesheet" type="text/css" href="extjs/theme-crisp/resources/theme-crisp-all.css">
+ <script type="text/javascript" src="extjs/ext-all.js"></script>
+ <script type="text/javascript" src="prune-simulator.js"></script>
+</head>
+<body></body>
+</html>
diff --git a/docs/prune-simulator/prune-simulator.js b/docs/prune-simulator/prune-simulator.js
new file mode 100644
index 00000000..2ba89e74
--- /dev/null
+++ b/docs/prune-simulator/prune-simulator.js
@@ -0,0 +1,699 @@
+// avoid errors when running without development tools
+if (!Ext.isDefined(Ext.global.console)) {
+ var console = {
+ dir: function() {},
+ log: function() {},
+ };
+}
+
+Ext.onReady(function() {
+ const NOW = new Date();
+ const COLORS = {
+ 'keep-last': 'orange',
+ 'keep-hourly': 'purple',
+ 'keep-daily': 'yellow',
+ 'keep-weekly': 'green',
+ 'keep-monthly': 'blue',
+ 'keep-yearly': 'red',
+ 'all zero': 'white',
+ };
+ const TEXT_COLORS = {
+ 'keep-last': 'black',
+ 'keep-hourly': 'white',
+ 'keep-daily': 'black',
+ 'keep-weekly': 'white',
+ 'keep-monthly': 'white',
+ 'keep-yearly': 'white',
+ 'all zero': 'black',
+ };
+
+ Ext.define('PBS.prunesimulator.CalendarEvent', {
+ extend: 'Ext.form.field.ComboBox',
+ alias: 'widget.prunesimulatorCalendarEvent',
+
+ editable: true,
+
+ displayField: 'text',
+ valueField: 'value',
+ queryMode: 'local',
+
+ store: {
+ field: ['value', 'text'],
+ data: [
+ { value: '0/2:00', text: "Every two hours" },
+ { value: '0/6:00', text: "Every six hours" },
+ { value: '2,22:30', text: "At 02:30 and 22:30" },
+ { value: 'hour[:minute][/interval]', text: "Custom schedule" },
+ ],
+ },
+
+ tpl: [
+ '<ul class="x-list-plain"><tpl for=".">',
+ '<li role="option" class="x-boundlist-item">{text}</li>',
+ '</tpl></ul>',
+ ],
+
+ displayTpl: [
+ '<tpl for=".">',
+ '{value}',
+ '</tpl>',
+ ],
+ });
+
+ Ext.define('PBS.prunesimulator.DayOfWeekSelector', {
+ extend: 'Ext.form.field.ComboBox',
+ alias: 'widget.prunesimulatorDayOfWeekSelector',
+
+ displayField: 'text',
+ valueField: 'value',
+ queryMode: 'local',
+
+ store: {
+ field: ['value', 'text'],
+ data: [
+ { value: 'mon', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[1]) },
+ { value: 'tue', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[2]) },
+ { value: 'wed', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[3]) },
+ { value: 'thu', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[4]) },
+ { value: 'fri', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[5]) },
+ { value: 'sat', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[6]) },
+ { value: 'sun', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[0]) },
+ ],
+ },
+ });
+
+ Ext.define('pbs-prune-list', {
+ extend: 'Ext.data.Model',
+ fields: [
+ {
+ name: 'backuptime',
+ type: 'date',
+ dateFormat: 'timestamp',
+ },
+ {
+ name: 'mark',
+ type: 'string',
+ },
+ {
+ name: 'keepName',
+ type: 'string',
+ },
+ ],
+ });
+
+ Ext.define('PBS.prunesimulator.PruneList', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.prunesimulatorPruneList',
+
+ initComponent: function() {
+ var me = this;
+
+ if (!me.store) {
+ throw "no store specified";
+ }
+
+ me.items = [
+ {
+ xtype: 'grid',
+ height: 200,
+ store: me.store,
+ width: 400,
+ columns: [
+ {
+ header: 'Backup Time',
+ dataIndex: 'backuptime',
+ renderer: function(value, metaData, record) {
+ let text = Ext.Date.format(value, 'Y-m-d H:i:s');
+ if (record.data.mark === 'keep') {
+ if (me.useColors) {
+ let bgColor = COLORS[record.data.keepName];
+ let textColor = TEXT_COLORS[record.data.keepName];
+ return '<div style="background-color: ' + bgColor + '; ' +
+ 'color: ' + textColor + ';">' + text + '</div>';
+ } else {
+ return text;
+ }
+ } else {
+ return '<div style="text-decoration: line-through;">' + text + '</div>';
+ }
+ },
+ flex: 1,
+ },
+ {
+ header: 'Keep (reason)',
+ dataIndex: 'mark',
+ renderer: function(value, metaData, record) {
+ if (record.data.mark === 'keep') {
+ return 'keep (' + record.data.keepName + ')';
+ } else {
+ return value;
+ }
+ },
+ width: 200,
+ },
+ ],
+ },
+ ];
+
+ me.callParent();
+ },
+ });
+
+ Ext.define('PBS.prunesimulator.WeekTable', {
+ extend: 'Ext.panel.Panel',
+ alias: 'widget.prunesimulatorWeekTable',
+
+ autoScroll: true,
+ height: 800,
+
+ reload: function() {
+ let me = this;
+ let backups = me.store.data.items;
+
+ let html = '<table>';
+
+ let now = new Date(NOW.getTime());
+ let skip = 7 - parseInt(Ext.Date.format(now, 'N'), 10);
+ let tableStartDate = Ext.Date.add(now, Ext.Date.DAY, skip);
+
+ let bIndex = 0;
+
+ for (let i = 0; bIndex < backups.length; i++) {
+ html += '<tr>';
+
+ for (let j = 0; j < 7; j++) {
+ html += '<td style="vertical-align: top;' +
+ 'width: 150px;' +
+ 'border: black 1px solid;' +
+ '">';
+
+ let date = Ext.Date.subtract(tableStartDate, Ext.Date.DAY, j + 7 * i);
+ let currentDay = Ext.Date.format(date, 'd/m/Y');
+
+ let isBackupOnDay = function(backup, day) {
+ return backup && Ext.Date.format(backup.data.backuptime, 'd/m/Y') === day;
+ };
+
+ let backup = backups[bIndex];
+
+ html += '<table><tr><th style="border-bottom: black 1px solid;">' +
+ Ext.Date.format(date, 'D, d M Y') + '</th>';
+
+ while (isBackupOnDay(backup, currentDay)) {
+ html += '<tr><td>';
+
+ let text = Ext.Date.format(backup.data.backuptime, 'H:i');
+ if (backup.data.mark === 'remove') {
+ html += '<div style="text-decoration: line-through;">' + text + '</div>';
+ } else {
+ text += ' (' + backup.data.keepName + ')';
+ if (me.useColors) {
+ let bgColor = COLORS[backup.data.keepName];
+ let textColor = TEXT_COLORS[backup.data.keepName];
+ html += '<div style="background-color: ' + bgColor + '; ' +
+ 'color: ' + textColor + ';">' + text + '</div>';
+ } else {
+ html += '<div>' + text + '</div>';
+ }
+ }
+ html += '</td></tr>';
+ backup = backups[++bIndex];
+ }
+ html += '</table>';
+ html += '</div>';
+ html += '</td>';
+ }
+
+ html += '</tr>';
+ }
+
+ me.setHtml(html);
+ },
+
+ initComponent: function() {
+ let me = this;
+
+ if (!me.store) {
+ throw "no store specified";
+ }
+
+ let reload = function() {
+ me.reload();
+ };
+
+ me.store.on("datachanged", reload);
+
+ me.callParent();
+
+ me.reload();
+ },
+ });
+
+ Ext.define('PBS.PruneSimulatorPanel', {
+ extend: 'Ext.panel.Panel',
+
+ viewModel: {
+ formulas: {
+ calendarHidden: function(get) {
+ return !get('showCalendar.checked');
+ },
+ },
+ },
+
+ getValues: function() {
+ let me = this;
+
+ let values = {};
+
+ Ext.Array.each(me.query('[isFormField]'), function(field) {
+ let data = field.getSubmitData();
+ Ext.Object.each(data, function(name, val) {
+ values[name] = val;
+ });
+ });
+
+ return values;
+ },
+
+ layout: {
+ type: 'table',
+ columns: 3,
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+
+ init: function(view) {
+ this.reloadFull(); // initial load
+ },
+
+ control: {
+ 'field[fieldGroup=keep]': { change: 'reloadPrune' },
+ },
+
+ reloadFull: function() {
+ let me = this;
+ let view = me.getView();
+
+ let params = view.getValues();
+
+ let [startTimesStr, interval] = params['schedule-time'].split('/');
+
+ if (interval) {
+ let [intervalBig, intervalSmall] = interval.split(':');
+ if (intervalSmall) {
+ interval = Number(intervalBig) * 60 + Number(intervalSmall);
+ } else {
+ interval = Number(intervalBig); // no ':' means only minutes
+ }
+
+ if (isNaN(interval) || interval < 0 || interval >= 60 * 24) {
+ Ext.Msg.alert('Error', 'Invalid interval');
+ return;
+ }
+ }
+
+ let [startTimesHours, startTimesMinute] = startTimesStr.split(':');
+ let startTimesHoursArray = startTimesHours.split(',');
+
+ startTimesMinute = startTimesMinute ? Number(startTimesMinute) : 0;
+ if (isNaN(startTimesMinute) || startTimesMinute < 0 || startTimesMinute >= 60) {
+ Ext.Msg.alert('Error', 'Invalid start time');
+ return;
+ }
+
+ let startTimes = [];
+ startTimesHoursArray.forEach(function(hour) {
+ hour = Number(hour);
+ if (isNaN(hour) || hour < 0 || hour >= 24) {
+ Ext.Msg.alert('Error', 'Invalid start time');
+ return;
+ }
+ startTimes.push([hour, startTimesMinute]);
+ });
+
+ let backups = me.populateFromSchedule(
+ params['schedule-weekdays'],
+ startTimes,
+ interval,
+ params.numberOfWeeks,
+ );
+
+ me.pruneSelect(backups, params);
+
+ view.pruneStore.setData(backups);
+ },
+
+ reloadPrune: function() {
+ let me = this;
+ let view = me.getView();
+
+ let params = view.getValues();
+
+ let backups = [];
+ view.pruneStore.getData().items.forEach(function(item) {
+ backups.push({
+ backuptime: item.data.backuptime,
+ });
+ });
+
+ me.pruneSelect(backups, params);
+
+ view.pruneStore.setData(backups);
+ },
+
+ // backups are sorted descending by date
+ populateFromSchedule: function(weekdays, startTimes, interval, weekCount) {
+ interval = interval || 60 * 24;
+
+ let weekdayFlags = [
+ weekdays.includes('sun'),
+ weekdays.includes('mon'),
+ weekdays.includes('tue'),
+ weekdays.includes('wed'),
+ weekdays.includes('thu'),
+ weekdays.includes('fri'),
+ weekdays.includes('sat'),
+ ];
+
+ let todaysDate = new Date(NOW.getTime());
+
+ let timesOnSingleDaySet = new Set();
+
+ startTimes.forEach(function(startTime) {
+ todaysDate.setHours(startTime[0]);
+ todaysDate.setMinutes(startTime[1]);
+ let date = new Date(todaysDate);
+ let weekday = parseInt(Ext.Date.format(date, 'w'), 10);
+ while (parseInt(Ext.Date.format(date, 'w'), 10) === weekday) {
+ timesOnSingleDaySet.add(date.getTime());
+ date = Ext.Date.add(date, Ext.Date.MINUTE, interval);
+ }
+ });
+
+ let timesOnSingleDayArray = [];
+
+ for (let time of timesOnSingleDaySet.keys()) {
+ timesOnSingleDayArray.push(time);
+ }
+ // ordering here and iterating backwards through days
+ // ensures that everything is ordered
+ timesOnSingleDayArray.sort(function(a, b) {
+ return a < b;
+ });
+
+ let backups = [];
+
+ for (let i = 0; i < 7 * weekCount; i++) {
+ let daysDate = Ext.Date.subtract(todaysDate, Ext.Date.DAY, i);
+ let weekday = parseInt(Ext.Date.format(daysDate, 'w'), 10);
+ if (weekdayFlags[weekday]) {
+ timesOnSingleDayArray.forEach(function(time) {
+ backups.push({
+ backuptime: Ext.Date.subtract(new Date(time), Ext.Date.DAY, i),
+ });
+ });
+ }
+ }
+
+ return backups;
+ },
+
+ pruneMark: function(backups, keepCount, keepName, idFunc) {
+ if (!keepCount) {
+ return;
+ }
+
+ let alreadyIncluded = {};
+ let newlyIncluded = {};
+ let newlyIncludedCount = 0;
+
+ let finished = false;
+
+ backups.forEach(function(backup) {
+ let mark = backup.mark;
+ let id = idFunc(backup);
+
+ if (finished || alreadyIncluded[id]) {
+ return;
+ }
+
+ if (mark) {
+ if (mark === 'keep') {
+ alreadyIncluded[id] = true;
+ }
+ return;
+ }
+
+ if (!newlyIncluded[id]) {
+ if (newlyIncludedCount >= keepCount) {
+ finished = true;
+ return;
+ }
+ newlyIncluded[id] = true;
+ newlyIncludedCount++;
+ backup.mark = 'keep';
+ backup.keepName = keepName;
+ } else {
+ backup.mark = 'remove';
+ }
+ });
+ },
+
+ // backups need to be sorted descending by date
+ pruneSelect: function(backups, keepParams) {
+ let me = this;
+
+ if (Number(keepParams['keep-last']) +
+ Number(keepParams['keep-hourly']) +
+ Number(keepParams['keep-daily']) +
+ Number(keepParams['keep-weekly']) +
+ Number(keepParams['keep-monthly']) +
+ Number(keepParams['keep-yearly']) === 0) {
+
+ backups.forEach(function(backup) {
+ backup.mark = 'keep';
+ backup.keepName = 'all zero';
+ });
+
+ return;
+ }
+
+ me.pruneMark(backups, keepParams['keep-last'], 'keep-last', function(backup) {
+ return backup.backuptime;
+ });
+ me.pruneMark(backups, keepParams['keep-hourly'], 'keep-hourly', function(backup) {
+ return Ext.Date.format(backup.backuptime, 'H/d/m/Y');
+ });
+ me.pruneMark(backups, keepParams['keep-daily'], 'keep-daily', function(backup) {
+ return Ext.Date.format(backup.backuptime, 'd/m/Y');
+ });
+ me.pruneMark(backups, keepParams['keep-weekly'], 'keep-weekly', function(backup) {
+ // ISO-8601 week and week-based year
+ return Ext.Date.format(backup.backuptime, 'W/o');
+ });
+ me.pruneMark(backups, keepParams['keep-monthly'], 'keep-monthly', function(backup) {
+ return Ext.Date.format(backup.backuptime, 'm/Y');
+ });
+ me.pruneMark(backups, keepParams['keep-yearly'], 'keep-yearly', function(backup) {
+ return Ext.Date.format(backup.backuptime, 'Y');
+ });
+
+ backups.forEach(function(backup) {
+ backup.mark = backup.mark || 'remove';
+ });
+ },
+ },
+
+ keepItems: [
+ {
+ xtype: 'numberfield',
+ name: 'keep-last',
+ allowBlank: true,
+ fieldLabel: 'keep-last',
+ minValue: 0,
+ value: 5,
+ fieldGroup: 'keep',
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'keep-hourly',
+ allowBlank: true,
+ fieldLabel: 'keep-hourly',
+ minValue: 0,
+ value: 4,
+ fieldGroup: 'keep',
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'keep-daily',
+ allowBlank: true,
+ fieldLabel: 'keep-daily',
+ minValue: 0,
+ value: 3,
+ fieldGroup: 'keep',
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'keep-weekly',
+ allowBlank: true,
+ fieldLabel: 'keep-weekly',
+ minValue: 0,
+ value: 2,
+ fieldGroup: 'keep',
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'keep-monthly',
+ allowBlank: true,
+ fieldLabel: 'keep-monthly',
+ minValue: 0,
+ value: 1,
+ fieldGroup: 'keep',
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'keep-yearly',
+ allowBlank: true,
+ fieldLabel: 'keep-yearly',
+ minValue: 0,
+ value: 0,
+ fieldGroup: 'keep',
+ padding: '0 0 0 10',
+ },
+ ],
+
+ initComponent: function() {
+ var me = this;
+
+ me.pruneStore = Ext.create('Ext.data.Store', {
+ model: 'pbs-prune-list',
+ sorters: { property: 'backuptime', direction: 'DESC' },
+ });
+
+ let scheduleItems = [
+ {
+ xtype: 'prunesimulatorDayOfWeekSelector',
+ name: 'schedule-weekdays',
+ fieldLabel: 'Day of week',
+ value: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
+ allowBlank: false,
+ multiSelect: true,
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'prunesimulatorCalendarEvent',
+ name: 'schedule-time',
+ allowBlank: false,
+ value: '0/6:00',
+ fieldLabel: 'Backup schedule',
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'numberOfWeeks',
+ allowBlank: false,
+ fieldLabel: 'Number of weeks',
+ minValue: 1,
+ value: 15,
+ maxValue: 200,
+ padding: '0 0 0 10',
+ },
+ {
+ xtype: 'button',
+ name: 'schedule-button',
+ text: 'Update Schedule',
+ handler: function() {
+ me.controller.reloadFull();
+ },
+ },
+ ];
+
+ me.items = [
+ {
+ padding: '10 5 0 10',
+ layout: 'anchor',
+ items: scheduleItems,
+ },
+ {
+ padding: '10 5 0 5',
+ layout: 'anchor',
+ items: me.keepItems,
+ },
+ {
+ padding: '10 10 0 5',
+ layout: 'anchor',
+ xtype: 'prunesimulatorPruneList',
+ store: me.pruneStore,
+ reference: 'pruneList',
+ },
+ {
+ padding: '0 0 0 10',
+ xtype: 'checkbox',
+ name: 'showCalendar',
+ reference: 'showCalendar',
+ fieldLabel: 'Show Calendar:',
+ checked: false,
+ colspan: 3,
+ },
+ {
+ padding: '0 0 0 10',
+ xtype: 'checkbox',
+ name: 'showColors',
+ reference: 'showColors',
+ fieldLabel: 'Show Colors:',
+ checked: false,
+ handler: function(checkbox, checked) {
+ Ext.Array.each(me.query('[isFormField]'), function(field) {
+ if (field.fieldGroup !== 'keep') {
+ return;
+ }
+
+ if (checked) {
+ field.setFieldStyle('background-color: ' + COLORS[field.name] + '; ' +
+ 'color: ' + TEXT_COLORS[field.name] + ';');
+ } else {
+ field.setFieldStyle('background-color: white; color: black;');
+ }
+ });
+
+ me.lookupReference('weekTable').useColors = checked;
+ me.lookupReference('pruneList').useColors = checked;
+
+ me.controller.reloadPrune();
+ },
+ colspan: 3,
+ },
+ {
+ padding: '10 10 10 10',
+ layout: 'anchor',
+ xtype: 'prunesimulatorWeekTable',
+ reference: 'weekTable',
+ store: me.pruneStore,
+ bind: {
+ hidden: '{calendarHidden}',
+ },
+ colspan: 3,
+ },
+ ];
+
+ me.callParent();
+ },
+ });
+
+ let simulator = Ext.create('PBS.PruneSimulatorPanel', {});
+
+ Ext.create('Ext.container.Viewport', {
+ layout: 'border',
+ renderTo: Ext.getBody(),
+ items: [
+ simulator,
+ ],
+ });
+});
+
--
2.20.1
next reply other threads:[~2020-10-28 13:12 UTC|newest]
Thread overview: 2+ messages / expand[flat|nested] mbox.gz Atom feed top
2020-10-28 13:12 Fabian Ebner [this message]
2020-10-29 16:11 ` 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=20201028131206.23593-1-f.ebner@proxmox.com \
--to=f.ebner@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