]> git.proxmox.com Git - proxmox-backup.git/blob - www/DataStoreContent.js
ui: datastore: show more granular verify state
[proxmox-backup.git] / www / DataStoreContent.js
1 Ext.define('pbs-data-store-snapshots', {
2 extend: 'Ext.data.Model',
3 fields: [
4 'backup-type',
5 'backup-id',
6 {
7 name: 'backup-time',
8 type: 'date',
9 dateFormat: 'timestamp'
10 },
11 'files',
12 'owner',
13 'verification',
14 { name: 'size', type: 'int', allowNull: true, },
15 {
16 name: 'crypt-mode',
17 type: 'boolean',
18 calculate: function(data) {
19 let encrypted = 0;
20 let crypt = {
21 none: 0,
22 mixed: 0,
23 'sign-only': 0,
24 encrypt: 0,
25 count: 0,
26 };
27 let signed = 0;
28 data.files.forEach(file => {
29 if (file.filename === 'index.json.blob') return; // is never encrypted
30 let mode = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
31 if (mode !== -1) {
32 crypt[file['crypt-mode']]++;
33 crypt.count++;
34 }
35 });
36
37 return PBS.Utils.calculateCryptMode(crypt);
38 }
39 },
40 {
41 name: 'matchesFilter',
42 type: 'boolean',
43 defaultValue: true,
44 },
45 ]
46 });
47
48 Ext.define('PBS.DataStoreContent', {
49 extend: 'Ext.tree.Panel',
50 alias: 'widget.pbsDataStoreContent',
51
52 rootVisible: false,
53
54 title: gettext('Content'),
55
56 controller: {
57 xclass: 'Ext.app.ViewController',
58
59 init: function(view) {
60 if (!view.datastore) {
61 throw "no datastore specified";
62 }
63
64 this.store = Ext.create('Ext.data.Store', {
65 model: 'pbs-data-store-snapshots',
66 groupField: 'backup-group',
67 });
68 this.store.on('load', this.onLoad, this);
69
70 view.getStore().setSorters([
71 'backup-group',
72 'text',
73 'backup-time'
74 ]);
75 Proxmox.Utils.monStoreErrors(view, this.store);
76 this.reload(); // initial load
77 },
78
79 reload: function() {
80 let view = this.getView();
81
82 if (!view.store || !this.store) {
83 console.warn('cannot reload, no store(s)');
84 return;
85 }
86
87 let url = `/api2/json/admin/datastore/${view.datastore}/snapshots`;
88 this.store.setProxy({
89 type: 'proxmox',
90 timeout: 300*1000, // 5 minutes, we should make that api call faster
91 url: url
92 });
93
94 this.store.load();
95 },
96
97 getRecordGroups: function(records) {
98 let groups = {};
99
100 for (const item of records) {
101 var btype = item.data["backup-type"];
102 let group = btype + "/" + item.data["backup-id"];
103
104 if (groups[group] !== undefined) {
105 continue;
106 }
107
108 var cls = '';
109 if (btype === 'vm') {
110 cls = 'fa-desktop';
111 } else if (btype === 'ct') {
112 cls = 'fa-cube';
113 } else if (btype === 'host') {
114 cls = 'fa-building';
115 } else {
116 console.warn(`got unknown backup-type '${btype}'`);
117 continue; // FIXME: auto render? what do?
118 }
119
120 groups[group] = {
121 text: group,
122 leaf: false,
123 iconCls: "fa " + cls,
124 expanded: false,
125 backup_type: item.data["backup-type"],
126 backup_id: item.data["backup-id"],
127 children: []
128 };
129 }
130
131 return groups;
132 },
133
134 onLoad: function(store, records, success, operation) {
135 let me = this;
136 let view = this.getView();
137
138 if (!success) {
139 Proxmox.Utils.setErrorMask(view, Proxmox.Utils.getResponseErrorMessage(operation.getError()));
140 return;
141 }
142
143 let groups = this.getRecordGroups(records);
144
145 let selected;
146 let expanded = {};
147
148 view.getSelection().some(function(item) {
149 let id = item.data.text;
150 if (item.data.leaf) {
151 id = item.parentNode.data.text + id;
152 }
153 selected = id;
154 return true;
155 });
156
157 view.getRootNode().cascadeBy({
158 before: item => {
159 if (item.isExpanded() && !item.data.leaf) {
160 let id = item.data.text;
161 expanded[id] = true;
162 return true;
163 }
164 return false;
165 },
166 after: () => {},
167 });
168
169 for (const item of records) {
170 let group = item.data["backup-type"] + "/" + item.data["backup-id"];
171 let children = groups[group].children;
172
173 let data = item.data;
174
175 data.text = group + '/' + PBS.Utils.render_datetime_utc(data["backup-time"]);
176 data.leaf = false;
177 data.cls = 'no-leaf-icons';
178 data.matchesFilter = true;
179
180 data.expanded = !!expanded[data.text];
181
182 data.children = [];
183 for (const file of data.files) {
184 file.text = file.filename;
185 file['crypt-mode'] = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
186 file.leaf = true;
187 file.matchesFilter = true;
188
189 data.children.push(file);
190 }
191
192 children.push(data);
193 }
194
195 let nowSeconds = Date.now() / 1000;
196 let children = [];
197 for (const [name, group] of Object.entries(groups)) {
198 let last_backup = 0;
199 let crypt = {
200 none: 0,
201 mixed: 0,
202 'sign-only': 0,
203 encrypt: 0,
204 };
205 let verify = {
206 outdated: 0,
207 none: 0,
208 failed: 0,
209 ok: 0,
210 };
211 for (let item of group.children) {
212 crypt[PBS.Utils.cryptmap[item['crypt-mode']]]++;
213 if (item["backup-time"] > last_backup && item.size !== null) {
214 last_backup = item["backup-time"];
215 group["backup-time"] = last_backup;
216 group.files = item.files;
217 group.size = item.size;
218 group.owner = item.owner;
219 verify.lastFailed = item.verification && item.verification.state !== 'ok';
220 }
221 if (!item.verification) {
222 verify.none++;
223 } else {
224 if (item.verification.state === 'ok') {
225 verify.ok++;
226 } else {
227 verify.failed++;
228 }
229 let task = Proxmox.Utils.parse_task_upid(item.verification.upid);
230 item.verification.lastTime = task.starttime;
231 if (nowSeconds - task.starttime > 30 * 24 * 60 * 60) {
232 verify.outdated++;
233 }
234 }
235 }
236 group.verification = verify;
237 group.count = group.children.length;
238 group.matchesFilter = true;
239 crypt.count = group.count;
240 group['crypt-mode'] = PBS.Utils.calculateCryptMode(crypt);
241 group.expanded = !!expanded[name];
242 children.push(group);
243 }
244
245 view.setRootNode({
246 expanded: true,
247 children: children
248 });
249
250 if (selected !== undefined) {
251 let selection = view.getRootNode().findChildBy(function(item) {
252 let id = item.data.text;
253 if (item.data.leaf) {
254 id = item.parentNode.data.text + id;
255 }
256 return selected === id;
257 }, undefined, true);
258 if (selection) {
259 view.setSelection(selection);
260 view.getView().focusRow(selection);
261 }
262 }
263
264 Proxmox.Utils.setErrorMask(view, false);
265 if (view.getStore().getFilters().length > 0) {
266 let searchBox = me.lookup("searchbox");
267 let searchvalue = searchBox.getValue();;
268 me.search(searchBox, searchvalue);
269 }
270 },
271
272 onPrune: function(view, rI, cI, item, e, rec) {
273 var view = this.getView();
274
275 if (!(rec && rec.data)) return;
276 let data = rec.data;
277 if (rec.parentNode.id !== 'root') return;
278
279 if (!view.datastore) return;
280
281 let win = Ext.create('PBS.DataStorePrune', {
282 datastore: view.datastore,
283 backup_type: data.backup_type,
284 backup_id: data.backup_id,
285 });
286 win.on('destroy', this.reload, this);
287 win.show();
288 },
289
290 onVerify: function(view, rI, cI, item, e, rec) {
291 let me = this;
292 view = me.getView();
293
294 if (!view.datastore) return;
295
296 if (!(rec && rec.data)) return;
297 let data = rec.data;
298
299 let params;
300
301 if (rec.parentNode.id !== 'root') {
302 params = {
303 "backup-type": data["backup-type"],
304 "backup-id": data["backup-id"],
305 "backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
306 };
307 } else {
308 params = {
309 "backup-type": data.backup_type,
310 "backup-id": data.backup_id,
311 };
312 }
313
314 Proxmox.Utils.API2Request({
315 params: params,
316 url: `/admin/datastore/${view.datastore}/verify`,
317 method: 'POST',
318 failure: function(response) {
319 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
320 },
321 success: function(response, options) {
322 Ext.create('Proxmox.window.TaskViewer', {
323 upid: response.result.data,
324 taskDone: () => me.reload(),
325 }).show();
326 },
327 });
328 },
329
330 onForget: function(view, rI, cI, item, e, rec) {
331 let me = this;
332 var view = this.getView();
333
334 if (!(rec && rec.data)) return;
335 let data = rec.data;
336 if (!view.datastore) return;
337
338 Ext.Msg.show({
339 title: gettext('Confirm'),
340 icon: Ext.Msg.WARNING,
341 message: Ext.String.format(gettext('Are you sure you want to remove snapshot {0}'), `'${data.text}'`),
342 buttons: Ext.Msg.YESNO,
343 defaultFocus: 'no',
344 callback: function(btn) {
345 if (btn !== 'yes') {
346 return;
347 }
348
349 Proxmox.Utils.API2Request({
350 params: {
351 "backup-type": data["backup-type"],
352 "backup-id": data["backup-id"],
353 "backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
354 },
355 url: `/admin/datastore/${view.datastore}/snapshots`,
356 method: 'DELETE',
357 waitMsgTarget: view,
358 failure: function(response, opts) {
359 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
360 },
361 callback: me.reload.bind(me),
362 });
363 },
364 });
365 },
366
367 downloadFile: function(tV, rI, cI, item, e, rec) {
368 let me = this;
369 let view = me.getView();
370
371 if (!(rec && rec.data)) return;
372 let data = rec.parentNode.data;
373
374 let file = rec.data.filename;
375 let params = {
376 'backup-id': data['backup-id'],
377 'backup-type': data['backup-type'],
378 'backup-time': (data['backup-time'].getTime()/1000).toFixed(0),
379 'file-name': file,
380 };
381
382 let idx = file.lastIndexOf('.');
383 let filename = file.slice(0, idx);
384 let atag = document.createElement('a');
385 params['file-name'] = file;
386 atag.download = filename;
387 let url = new URL(`/api2/json/admin/datastore/${view.datastore}/download-decoded`, window.location.origin);
388 for (const [key, value] of Object.entries(params)) {
389 url.searchParams.append(key, value);
390 }
391 atag.href = url.href;
392 atag.click();
393 },
394
395 openPxarBrowser: function(tv, rI, Ci, item, e, rec) {
396 let me = this;
397 let view = me.getView();
398
399 if (!(rec && rec.data)) return;
400 let data = rec.parentNode.data;
401
402 let id = data['backup-id'];
403 let time = data['backup-time'];
404 let type = data['backup-type'];
405 let timetext = PBS.Utils.render_datetime_utc(data["backup-time"]);
406
407 Ext.create('PBS.window.FileBrowser', {
408 title: `${type}/${id}/${timetext}`,
409 datastore: view.datastore,
410 'backup-id': id,
411 'backup-time': (time.getTime()/1000).toFixed(0),
412 'backup-type': type,
413 archive: rec.data.filename,
414 }).show();
415 },
416
417 filter: function(item, value) {
418 if (item.data.text.indexOf(value) !== -1) {
419 return true;
420 }
421
422 if (item.data.owner && item.data.owner.indexOf(value) !== -1) {
423 return true;
424 }
425
426 return false;
427 },
428
429 search: function(tf, value) {
430 let me = this;
431 let view = me.getView();
432 let store = view.getStore();
433 if (!value && value !== 0) {
434 store.clearFilter();
435 store.getRoot().collapseChildren(true);
436 tf.triggers.clear.setVisible(false);
437 return;
438 }
439 tf.triggers.clear.setVisible(true);
440 if (value.length < 2) return;
441 Proxmox.Utils.setErrorMask(view, true);
442 // we do it a little bit later for the error mask to work
443 setTimeout(function() {
444 store.clearFilter();
445 store.getRoot().collapseChildren(true);
446
447 store.beginUpdate();
448 store.getRoot().cascadeBy({
449 before: function(item) {
450 if(me.filter(item, value)) {
451 item.set('matchesFilter', true);
452 if (item.parentNode && item.parentNode.id !== 'root') {
453 item.parentNode.childmatches = true;
454 }
455 return false;
456 }
457 return true;
458 },
459 after: function(item) {
460 if (me.filter(item, value) || item.id === 'root' || item.childmatches) {
461 item.set('matchesFilter', true);
462 if (item.parentNode && item.parentNode.id !== 'root') {
463 item.parentNode.childmatches = true;
464 }
465 if (item.childmatches) {
466 item.expand();
467 }
468 } else {
469 item.set('matchesFilter', false);
470 }
471 delete item.childmatches;
472 },
473 });
474 store.endUpdate();
475
476 store.filter((item) => !!item.get('matchesFilter'));
477 Proxmox.Utils.setErrorMask(view, false);
478 }, 10);
479 },
480 },
481
482 viewConfig: {
483 getRowClass: function(record, index) {
484 let verify = record.get('verification');
485 if (verify && verify.lastFailed) {
486 return 'proxmox-invalid-row';
487 }
488 },
489 },
490
491 columns: [
492 {
493 xtype: 'treecolumn',
494 header: gettext("Backup Group"),
495 dataIndex: 'text',
496 flex: 1
497 },
498 {
499 header: gettext('Actions'),
500 xtype: 'actioncolumn',
501 dataIndex: 'text',
502 items: [
503 {
504 handler: 'onVerify',
505 tooltip: gettext('Verify'),
506 getClass: (v, m, rec) => rec.data.leaf ? 'pmx-hidden' : 'fa fa-search',
507 isDisabled: (v, r, c, i, rec) => !!rec.data.leaf,
508 },
509 {
510 handler: 'onPrune',
511 tooltip: gettext('Prune'),
512 getClass: (v, m, rec) => rec.parentNode.id ==='root' ? 'fa fa-scissors' : 'pmx-hidden',
513 isDisabled: (v, r, c, i, rec) => rec.parentNode.id !=='root',
514 },
515 {
516 handler: 'onForget',
517 tooltip: gettext('Forget Snapshot'),
518 getClass: (v, m, rec) => !rec.data.leaf && rec.parentNode.id !== 'root' ? 'fa critical fa-trash-o' : 'pmx-hidden',
519 isDisabled: (v, r, c, i, rec) => rec.data.leaf || rec.parentNode.id === 'root',
520 },
521 {
522 handler: 'downloadFile',
523 tooltip: gettext('Download'),
524 getClass: (v, m, rec) => rec.data.leaf && rec.data.filename ? 'fa fa-download' : 'pmx-hidden',
525 isDisabled: (v, r, c, i, rec) => !rec.data.leaf || !rec.data.filename || rec.data['crypt-mode'] > 2,
526 },
527 {
528 handler: 'openPxarBrowser',
529 tooltip: gettext('Browse'),
530 getClass: (v, m, rec) => {
531 let data = rec.data;
532 if (data.leaf && data.filename && data.filename.endsWith('pxar.didx')) {
533 return 'fa fa-folder-open-o';
534 }
535 return 'pmx-hidden';
536 },
537 isDisabled: (v, r, c, i, rec) => {
538 let data = rec.data;
539 return !(data.leaf &&
540 data.filename &&
541 data.filename.endsWith('pxar.didx') &&
542 data['crypt-mode'] < 3);
543 }
544 },
545 ]
546 },
547 {
548 xtype: 'datecolumn',
549 header: gettext('Backup Time'),
550 sortable: true,
551 dataIndex: 'backup-time',
552 format: 'Y-m-d H:i:s',
553 width: 150
554 },
555 {
556 header: gettext("Size"),
557 sortable: true,
558 dataIndex: 'size',
559 renderer: (v, meta, record) => {
560 if (record.data.text === 'client.log.blob' && v === undefined) {
561 return '';
562 }
563 if (v === undefined || v === null) {
564 meta.tdCls = "x-grid-row-loading";
565 return '';
566 }
567 return Proxmox.Utils.format_size(v);
568 },
569 },
570 {
571 xtype: 'numbercolumn',
572 format: '0',
573 header: gettext("Count"),
574 sortable: true,
575 width: 75,
576 align: 'right',
577 dataIndex: 'count',
578 },
579 {
580 header: gettext("Owner"),
581 sortable: true,
582 dataIndex: 'owner',
583 },
584 {
585 header: gettext('Encrypted'),
586 dataIndex: 'crypt-mode',
587 renderer: (v, meta, record) => {
588 if (record.data.size === undefined || record.data.size === null) {
589 return '';
590 }
591 if (v === -1) {
592 return '';
593 }
594 let iconCls = PBS.Utils.cryptIconCls[v] || '';
595 let iconTxt = "";
596 if (iconCls) {
597 iconTxt = `<i class="fa fa-fw fa-${iconCls}"></i> `;
598 }
599 return (iconTxt + PBS.Utils.cryptText[v]) || Proxmox.Utils.unknownText
600 }
601 },
602 {
603 header: gettext('Verify State'),
604 sortable: true,
605 dataIndex: 'verification',
606 width: 120,
607 renderer: (v, meta, record) => {
608 let i = (cls, txt) => `<i class="fa fa-fw fa-${cls}"></i> ${txt}`;
609 if (v === undefined || v === null) {
610 return record.data.leaf ? '' : i('question-circle-o warning', gettext('None'));
611 }
612 let tip, iconCls, txt;
613 if (record.parentNode.id === 'root') {
614 if (v.failed === 0) {
615 if (v.none === 0) {
616 if (v.outdated > 0) {
617 tip = 'All OK, but some snapshots were not verified in last 30 days';
618 iconCls = 'check warning';
619 txt = gettext('All OK (old)');
620 } else {
621 tip = 'All snapshots verified at least once in last 30 days';
622 iconCls = 'check good';
623 txt = gettext('All OK');
624 }
625 } else if (v.ok === 0) {
626 tip = `${v.none} not verified yet`;
627 iconCls = 'question-circle-o warning';
628 txt = gettext('None');
629 } else {
630 tip = `${v.ok} OK, ${v.none} not verified yet`;
631 iconCls = 'check faded';
632 txt = `${v.ok} OK`;
633 }
634 } else {
635 tip = `${v.ok} OK, ${v.failed} failed, ${v.none} not verified yet`;
636 iconCls = 'times critical';
637 txt = v.ok === 0 && v.none === 0
638 ? gettext('All failed')
639 : `${v.failed} failed`;
640 }
641 } else if (!v.state) {
642 return record.data.leaf ? '' : gettext('None');
643 } else {
644 let verify_time = Proxmox.Utils.render_timestamp(v.lastTime);
645 tip = `Last verify task started on ${verify_time}`;
646 txt = v.state;
647 iconCls = 'times critical';
648 if (v.state === 'ok') {
649 iconCls = 'check good';
650 let now = Date.now() / 1000;
651 if (now - v.lastTime > 30 * 24 * 60 * 60) {
652 tip = `Last verify task over 30 days ago: ${verify_time}`;
653 iconCls = 'check warning';
654 }
655 }
656 }
657 return `<span data-qtip="${tip}">
658 <i class="fa fa-fw fa-${iconCls}"></i> ${txt}
659 </span>`;
660 },
661 listeners: {
662 dblclick: function(view, el, row, col, ev, rec) {
663 let data = rec.data || {};
664 let verify = data.verification;
665 if (verify && verify.upid && rec.parentNode.id !== 'root') {
666 let win = Ext.create('Proxmox.window.TaskViewer', {
667 upid: verify.upid,
668 });
669 win.show();
670 }
671 },
672 },
673 },
674 ],
675
676 tbar: [
677 {
678 text: gettext('Reload'),
679 iconCls: 'fa fa-refresh',
680 handler: 'reload',
681 },
682 '->',
683 {
684 xtype: 'tbtext',
685 html: gettext('Search'),
686 },
687 {
688 xtype: 'textfield',
689 reference: 'searchbox',
690 emptyText: gettext('group, date or owner'),
691 triggers: {
692 clear: {
693 cls: 'pmx-clear-trigger',
694 weight: -1,
695 hidden: true,
696 handler: function() {
697 this.triggers.clear.setVisible(false);
698 this.setValue('');
699 },
700 }
701 },
702 listeners: {
703 change: {
704 fn: 'search',
705 buffer: 500,
706 },
707 },
708 }
709 ],
710 });