]> git.proxmox.com Git - proxmox-backup.git/blob - docs/prune-simulator/prune-simulator.js
505eb8527fbee97ce36ee2f0603e3d4dd1d34461
[proxmox-backup.git] / docs / prune-simulator / prune-simulator.js
1 // avoid errors when running without development tools
2 if (!Ext.isDefined(Ext.global.console)) {
3 var console = {
4 dir: function() {},
5 log: function() {},
6 };
7 }
8
9 Ext.onReady(function() {
10 const NOW = new Date();
11 const COLORS = {
12 'keep-last': 'orange',
13 'keep-hourly': 'purple',
14 'keep-daily': 'yellow',
15 'keep-weekly': 'green',
16 'keep-monthly': 'blue',
17 'keep-yearly': 'red',
18 'all zero': 'white',
19 };
20 const TEXT_COLORS = {
21 'keep-last': 'black',
22 'keep-hourly': 'white',
23 'keep-daily': 'black',
24 'keep-weekly': 'white',
25 'keep-monthly': 'white',
26 'keep-yearly': 'white',
27 'all zero': 'black',
28 };
29
30 Ext.define('PBS.prunesimulator.Documentation', {
31 extend: 'Ext.Panel',
32 alias: 'widget.prunesimulatorDocumentation',
33
34 html: '<iframe style="width:100%;height:100%" src="./documentation.html"/>',
35 });
36
37 Ext.define('PBS.prunesimulator.CalendarEvent', {
38 extend: 'Ext.form.field.ComboBox',
39 alias: 'widget.prunesimulatorCalendarEvent',
40
41 editable: true,
42
43 displayField: 'text',
44 valueField: 'value',
45 queryMode: 'local',
46
47 store: {
48 field: ['value', 'text'],
49 data: [
50 { value: '0/2:00', text: "Every two hours" },
51 { value: '0/6:00', text: "Every six hours" },
52 { value: '2,22:30', text: "At 02:30 and 22:30" },
53 { value: '08..17:00/30', text: "From 08:00 to 17:30 every 30 minutes" },
54 { value: 'HOUR:MINUTE', text: "Custom schedule" },
55 ],
56 },
57
58 tpl: [
59 '<ul class="x-list-plain"><tpl for=".">',
60 '<li role="option" class="x-boundlist-item">{text}</li>',
61 '</tpl></ul>',
62 ],
63
64 displayTpl: [
65 '<tpl for=".">',
66 '{value}',
67 '</tpl>',
68 ],
69 });
70
71 Ext.define('PBS.prunesimulator.DayOfWeekSelector', {
72 extend: 'Ext.form.field.ComboBox',
73 alias: 'widget.prunesimulatorDayOfWeekSelector',
74
75 editable: false,
76
77 displayField: 'text',
78 valueField: 'value',
79 queryMode: 'local',
80
81 store: {
82 field: ['value', 'text'],
83 data: [
84 { value: 'mon', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[1]) },
85 { value: 'tue', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[2]) },
86 { value: 'wed', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[3]) },
87 { value: 'thu', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[4]) },
88 { value: 'fri', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[5]) },
89 { value: 'sat', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[6]) },
90 { value: 'sun', text: Ext.util.Format.htmlDecode(Ext.Date.dayNames[0]) },
91 ],
92 },
93 });
94
95 Ext.define('pbs-prune-list', {
96 extend: 'Ext.data.Model',
97 fields: [
98 {
99 name: 'backuptime',
100 type: 'date',
101 dateFormat: 'timestamp',
102 },
103 {
104 name: 'mark',
105 type: 'string',
106 },
107 {
108 name: 'keepName',
109 type: 'string',
110 },
111 ],
112 });
113
114 Ext.define('PBS.prunesimulator.PruneList', {
115 extend: 'Ext.panel.Panel',
116 alias: 'widget.prunesimulatorPruneList',
117
118 initComponent: function() {
119 var me = this;
120
121 if (!me.store) {
122 throw "no store specified";
123 }
124
125 me.items = [
126 {
127 xtype: 'grid',
128 store: me.store,
129 columns: [
130 {
131 header: 'Backup Time',
132 dataIndex: 'backuptime',
133 renderer: function(value, metaData, record) {
134 let text = Ext.Date.format(value, 'Y-m-d H:i:s');
135 if (record.data.mark === 'keep') {
136 if (me.useColors) {
137 let bgColor = COLORS[record.data.keepName];
138 let textColor = TEXT_COLORS[record.data.keepName];
139 return '<div style="background-color: ' + bgColor + '; ' +
140 'color: ' + textColor + ';">' + text + '</div>';
141 } else {
142 return text;
143 }
144 } else {
145 return '<div style="text-decoration: line-through;">' + text + '</div>';
146 }
147 },
148 flex: 1,
149 sortable: false,
150 },
151 {
152 header: 'Keep (reason)',
153 dataIndex: 'mark',
154 renderer: function(value, metaData, record) {
155 if (record.data.mark === 'keep') {
156 return 'keep (' + record.data.keepName + ')';
157 } else {
158 return value;
159 }
160 },
161 width: 200,
162 sortable: false,
163 },
164 ],
165 },
166 ];
167
168 me.callParent();
169 },
170 });
171
172 Ext.define('PBS.prunesimulator.WeekTable', {
173 extend: 'Ext.panel.Panel',
174 alias: 'widget.prunesimulatorWeekTable',
175
176 reload: function() {
177 let me = this;
178 let backups = me.store.data.items;
179
180 let html = '<table>';
181
182 let now = new Date(NOW.getTime());
183 let skip = 7 - parseInt(Ext.Date.format(now, 'N'), 10);
184 let tableStartDate = Ext.Date.add(now, Ext.Date.DAY, skip);
185
186 let bIndex = 0;
187
188 for (let i = 0; bIndex < backups.length; i++) {
189 html += '<tr>';
190
191 for (let j = 0; j < 7; j++) {
192 html += '<td style="vertical-align: top;' +
193 'width: 150px;' +
194 'border: black 1px solid;' +
195 '">';
196
197 let date = Ext.Date.subtract(tableStartDate, Ext.Date.DAY, j + 7 * i);
198 let currentDay = Ext.Date.format(date, 'd/m/Y');
199
200 let isBackupOnDay = function(backup, day) {
201 return backup && Ext.Date.format(backup.data.backuptime, 'd/m/Y') === day;
202 };
203
204 let backup = backups[bIndex];
205
206 html += '<table><tr><th style="border-bottom: black 1px solid;">' +
207 Ext.Date.format(date, 'D, d M Y') + '</th>';
208
209 while (isBackupOnDay(backup, currentDay)) {
210 html += '<tr><td>';
211
212 let text = Ext.Date.format(backup.data.backuptime, 'H:i');
213 if (backup.data.mark === 'remove') {
214 html += '<div style="text-decoration: line-through;">' + text + '</div>';
215 } else {
216 text += ' (' + backup.data.keepName + ')';
217 if (me.useColors) {
218 let bgColor = COLORS[backup.data.keepName];
219 let textColor = TEXT_COLORS[backup.data.keepName];
220 html += '<div style="background-color: ' + bgColor + '; ' +
221 'color: ' + textColor + ';">' + text + '</div>';
222 } else {
223 html += '<div>' + text + '</div>';
224 }
225 }
226 html += '</td></tr>';
227 backup = backups[++bIndex];
228 }
229 html += '</table>';
230 html += '</div>';
231 html += '</td>';
232 }
233
234 html += '</tr>';
235 }
236
237 me.setHtml(html);
238 },
239
240 initComponent: function() {
241 let me = this;
242
243 if (!me.store) {
244 throw "no store specified";
245 }
246
247 let reload = function() {
248 me.reload();
249 };
250
251 me.store.on("datachanged", reload);
252
253 me.callParent();
254
255 me.reload();
256 },
257 });
258
259 Ext.define('PBS.PruneSimulatorPanel', {
260 extend: 'Ext.panel.Panel',
261 alias: 'widget.prunesimulatorPanel',
262
263 viewModel: {
264 formulas: {
265 calendarHidden: function(get) {
266 return !get('showCalendar.checked');
267 },
268 },
269 },
270
271 getValues: function() {
272 let me = this;
273
274 let values = {};
275
276 Ext.Array.each(me.query('[isFormField]'), function(field) {
277 let data = field.getSubmitData();
278 Ext.Object.each(data, function(name, val) {
279 values[name] = val;
280 });
281 });
282
283 return values;
284 },
285
286 controller: {
287 xclass: 'Ext.app.ViewController',
288
289 init: function(view) {
290 this.reloadFull(); // initial load
291 },
292
293 control: {
294 'field[fieldGroup=keep]': { change: 'reloadPrune' },
295 },
296
297 reloadFull: function() {
298 let me = this;
299 let view = me.getView();
300
301 let params = view.getValues();
302
303 let [hourSpec, minuteSpec] = params['schedule-time'].split(':');
304
305 if (!hourSpec || !minuteSpec) {
306 Ext.Msg.alert('Error', 'Invalid schedule');
307 return;
308 }
309
310 let matchTimeSpec = function(timeSpec, rangeMin, rangeMax) {
311 let specValues = timeSpec.split(',');
312 let matches = {};
313
314 let assertValid = function(value) {
315 let num = Number(value);
316 if (isNaN(num)) {
317 throw value + " is not an integer";
318 } else if (value < rangeMin || value > rangeMax) {
319 throw "number '" + value + "' is not in the range '" + rangeMin + ".." + rangeMax + "'";
320 }
321 return num;
322 };
323
324 specValues.forEach(function(value) {
325 if (value.includes('..')) {
326 let [start, end] = value.split('..');
327 start = assertValid(start);
328 end = assertValid(end);
329 if (start > end) {
330 throw "interval start is bigger then interval end '" + start + " > " + end + "'";
331 }
332 for (let i = start; i <= end; i++) {
333 matches[i] = 1;
334 }
335 } else if (value.includes('/')) {
336 let [start, step] = value.split('/');
337 start = assertValid(start);
338 step = assertValid(step);
339 for (let i = start; i <= rangeMax; i += step) {
340 matches[i] = 1;
341 }
342 } else if (value === '*') {
343 for (let i = rangeMin; i <= rangeMax; i++) {
344 matches[i] = 1;
345 }
346 } else {
347 value = assertValid(value);
348 matches[value] = 1;
349 }
350 });
351
352 return Object.keys(matches);
353 };
354
355 let hours, minutes;
356
357 try {
358 hours = matchTimeSpec(hourSpec, 0, 23);
359 minutes = matchTimeSpec(minuteSpec, 0, 59);
360 } catch (err) {
361 Ext.Msg.alert('Error', err);
362 }
363
364 let backups = me.populateFromSchedule(
365 params['schedule-weekdays'],
366 hours,
367 minutes,
368 params.numberOfWeeks,
369 );
370
371 me.pruneSelect(backups, params);
372
373 view.pruneStore.setData(backups);
374 },
375
376 reloadPrune: function() {
377 let me = this;
378 let view = me.getView();
379
380 let params = view.getValues();
381
382 let backups = [];
383 view.pruneStore.getData().items.forEach(function(item) {
384 backups.push({
385 backuptime: item.data.backuptime,
386 });
387 });
388
389 me.pruneSelect(backups, params);
390
391 view.pruneStore.setData(backups);
392 },
393
394 // backups are sorted descending by date
395 populateFromSchedule: function(weekdays, hours, minutes, weekCount) {
396 let weekdayFlags = [
397 weekdays.includes('sun'),
398 weekdays.includes('mon'),
399 weekdays.includes('tue'),
400 weekdays.includes('wed'),
401 weekdays.includes('thu'),
402 weekdays.includes('fri'),
403 weekdays.includes('sat'),
404 ];
405
406 let todaysDate = new Date(NOW.getTime());
407
408 let timesOnSingleDay = [];
409
410 hours.forEach(function(hour) {
411 minutes.forEach(function(minute) {
412 todaysDate.setHours(hour);
413 todaysDate.setMinutes(minute);
414 timesOnSingleDay.push(todaysDate.getTime());
415 });
416 });
417
418 // ordering here and iterating backwards through days
419 // ensures that everything is ordered
420 timesOnSingleDay.sort(function(a, b) {
421 return a < b;
422 });
423
424 let backups = [];
425
426 for (let i = 0; i < 7 * weekCount; i++) {
427 let daysDate = Ext.Date.subtract(todaysDate, Ext.Date.DAY, i);
428 let weekday = parseInt(Ext.Date.format(daysDate, 'w'), 10);
429 if (weekdayFlags[weekday]) {
430 timesOnSingleDay.forEach(function(time) {
431 backups.push({
432 backuptime: Ext.Date.subtract(new Date(time), Ext.Date.DAY, i),
433 });
434 });
435 }
436 }
437
438 return backups;
439 },
440
441 pruneMark: function(backups, keepCount, keepName, idFunc) {
442 if (!keepCount) {
443 return;
444 }
445
446 let alreadyIncluded = {};
447 let newlyIncluded = {};
448 let newlyIncludedCount = 0;
449
450 let finished = false;
451
452 backups.forEach(function(backup) {
453 let mark = backup.mark;
454 let id = idFunc(backup);
455
456 if (finished || alreadyIncluded[id]) {
457 return;
458 }
459
460 if (mark) {
461 if (mark === 'keep') {
462 alreadyIncluded[id] = true;
463 }
464 return;
465 }
466
467 if (!newlyIncluded[id]) {
468 if (newlyIncludedCount >= keepCount) {
469 finished = true;
470 return;
471 }
472 newlyIncluded[id] = true;
473 newlyIncludedCount++;
474 backup.mark = 'keep';
475 backup.keepName = keepName;
476 } else {
477 backup.mark = 'remove';
478 }
479 });
480 },
481
482 // backups need to be sorted descending by date
483 pruneSelect: function(backups, keepParams) {
484 let me = this;
485
486 if (Number(keepParams['keep-last']) +
487 Number(keepParams['keep-hourly']) +
488 Number(keepParams['keep-daily']) +
489 Number(keepParams['keep-weekly']) +
490 Number(keepParams['keep-monthly']) +
491 Number(keepParams['keep-yearly']) === 0) {
492 backups.forEach(function(backup) {
493 backup.mark = 'keep';
494 backup.keepName = 'all zero';
495 });
496
497 return;
498 }
499
500 me.pruneMark(backups, keepParams['keep-last'], 'keep-last', function(backup) {
501 return backup.backuptime;
502 });
503 me.pruneMark(backups, keepParams['keep-hourly'], 'keep-hourly', function(backup) {
504 return Ext.Date.format(backup.backuptime, 'H/d/m/Y');
505 });
506 me.pruneMark(backups, keepParams['keep-daily'], 'keep-daily', function(backup) {
507 return Ext.Date.format(backup.backuptime, 'd/m/Y');
508 });
509 me.pruneMark(backups, keepParams['keep-weekly'], 'keep-weekly', function(backup) {
510 // ISO-8601 week and week-based year
511 return Ext.Date.format(backup.backuptime, 'W/o');
512 });
513 me.pruneMark(backups, keepParams['keep-monthly'], 'keep-monthly', function(backup) {
514 return Ext.Date.format(backup.backuptime, 'm/Y');
515 });
516 me.pruneMark(backups, keepParams['keep-yearly'], 'keep-yearly', function(backup) {
517 return Ext.Date.format(backup.backuptime, 'Y');
518 });
519
520 backups.forEach(function(backup) {
521 backup.mark = backup.mark || 'remove';
522 });
523 },
524 },
525
526 keepItems: [
527 {
528 xtype: 'numberfield',
529 name: 'keep-last',
530 allowBlank: true,
531 fieldLabel: 'keep-last',
532 minValue: 0,
533 value: 4,
534 fieldGroup: 'keep',
535 padding: '0 0 0 10',
536 },
537 {
538 xtype: 'numberfield',
539 name: 'keep-hourly',
540 allowBlank: true,
541 fieldLabel: 'keep-hourly',
542 minValue: 0,
543 value: 0,
544 fieldGroup: 'keep',
545 padding: '0 0 0 10',
546 },
547 {
548 xtype: 'numberfield',
549 name: 'keep-daily',
550 allowBlank: true,
551 fieldLabel: 'keep-daily',
552 minValue: 0,
553 value: 5,
554 fieldGroup: 'keep',
555 padding: '0 0 0 10',
556 },
557 {
558 xtype: 'numberfield',
559 name: 'keep-weekly',
560 allowBlank: true,
561 fieldLabel: 'keep-weekly',
562 minValue: 0,
563 value: 2,
564 fieldGroup: 'keep',
565 padding: '0 0 0 10',
566 },
567 {
568 xtype: 'numberfield',
569 name: 'keep-monthly',
570 allowBlank: true,
571 fieldLabel: 'keep-monthly',
572 minValue: 0,
573 value: 0,
574 fieldGroup: 'keep',
575 padding: '0 0 0 10',
576 },
577 {
578 xtype: 'numberfield',
579 name: 'keep-yearly',
580 allowBlank: true,
581 fieldLabel: 'keep-yearly',
582 minValue: 0,
583 value: 0,
584 fieldGroup: 'keep',
585 padding: '0 0 0 10',
586 },
587 ],
588
589 initComponent: function() {
590 var me = this;
591
592 me.pruneStore = Ext.create('Ext.data.Store', {
593 model: 'pbs-prune-list',
594 sorters: { property: 'backuptime', direction: 'DESC' },
595 });
596
597 let scheduleItems = [
598 {
599 xtype: 'prunesimulatorDayOfWeekSelector',
600 name: 'schedule-weekdays',
601 fieldLabel: 'Day of week',
602 value: ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'],
603 allowBlank: false,
604 multiSelect: true,
605 padding: '0 0 0 10',
606 },
607 {
608 xtype: 'prunesimulatorCalendarEvent',
609 name: 'schedule-time',
610 allowBlank: false,
611 value: '0/6:00',
612 fieldLabel: 'Backup schedule',
613 padding: '0 0 0 10',
614 },
615 {
616 xtype: 'numberfield',
617 name: 'numberOfWeeks',
618 allowBlank: false,
619 fieldLabel: 'Number of weeks',
620 minValue: 1,
621 value: 15,
622 maxValue: 200,
623 padding: '0 0 0 10',
624 },
625 {
626 xtype: 'button',
627 name: 'schedule-button',
628 text: 'Update Schedule',
629 handler: function() {
630 me.controller.reloadFull();
631 },
632 },
633 ];
634
635 me.items = [
636 {
637 xtype: 'panel',
638 layout: 'hbox',
639 height: 180,
640 items: [
641 {
642 title: 'View',
643 layout: 'anchor',
644 flex: 1,
645 items: [
646 {
647 padding: '0 0 0 10',
648 xtype: 'checkbox',
649 name: 'showCalendar',
650 reference: 'showCalendar',
651 fieldLabel: 'Show Calendar:',
652 checked: false,
653 },
654 {
655 padding: '0 0 0 10',
656 xtype: 'checkbox',
657 name: 'showColors',
658 reference: 'showColors',
659 fieldLabel: 'Show Colors:',
660 checked: false,
661 handler: function(checkbox, checked) {
662 Ext.Array.each(me.query('[isFormField]'), function(field) {
663 if (field.fieldGroup !== 'keep') {
664 return;
665 }
666
667 if (checked) {
668 field.setFieldStyle('background-color: ' + COLORS[field.name] + '; ' +
669 'color: ' + TEXT_COLORS[field.name] + ';');
670 } else {
671 field.setFieldStyle('background-color: white; color: black;');
672 }
673 });
674
675 me.lookupReference('weekTable').useColors = checked;
676 me.lookupReference('pruneList').useColors = checked;
677
678 me.controller.reloadPrune();
679 },
680 },
681 ],
682 },
683 {
684 layout: 'anchor',
685 flex: 1,
686 title: 'Backup Schedule',
687 items: scheduleItems,
688 },
689 ],
690 },
691 {
692 xtype: 'panel',
693 layout: 'hbox',
694 flex: 1,
695 items: [
696 {
697 layout: 'anchor',
698 title: 'Prune Options',
699 items: me.keepItems,
700 flex: 1,
701 },
702 {
703 layout: 'fit',
704 title: 'Backups',
705 xtype: 'prunesimulatorPruneList',
706 store: me.pruneStore,
707 reference: 'pruneList',
708 height: '100%',
709 flex: 1,
710 },
711 ],
712 },
713 {
714 layout: 'anchor',
715 title: 'Calendar',
716 autoScroll: true,
717 flex: 2,
718 xtype: 'prunesimulatorWeekTable',
719 reference: 'weekTable',
720 store: me.pruneStore,
721 bind: {
722 hidden: '{calendarHidden}',
723 },
724 },
725 ];
726
727 me.callParent();
728 },
729 });
730
731 Ext.create('Ext.container.Viewport', {
732 layout: 'border',
733 renderTo: Ext.getBody(),
734 items: [
735 {
736 xtype: 'prunesimulatorPanel',
737 title: 'PBS Prune Simulator',
738 region: 'west',
739 layout: {
740 type: 'vbox',
741 align: 'stretch',
742 pack: 'start',
743 },
744 width: 1080,
745 },
746 {
747 xtype: 'prunesimulatorDocumentation',
748 title: 'Usage',
749 margins: '5 0 0 0',
750 region: 'center',
751 },
752 ],
753 });
754 });
755