[pbs-devel] [RFC v2 proxmox-backup] create prune simulator
Fabian Ebner
f.ebner at proxmox.com
Wed Oct 28 14:12:06 CET 2020
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 at 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
More information about the pbs-devel
mailing list