]> git.proxmox.com Git - proxmox-backup.git/blob - www/DataStoreContent.js
28dfd74379f682ca319616f3ab0cfe2a3b85671b
[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 crypt = {
20 none: 0,
21 mixed: 0,
22 'sign-only': 0,
23 encrypt: 0,
24 count: 0,
25 };
26 data.files.forEach(file => {
27 if (file.filename === 'index.json.blob') return; // is never encrypted
28 let mode = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
29 if (mode !== -1) {
30 crypt[file['crypt-mode']]++;
31 crypt.count++;
32 }
33 });
34
35 return PBS.Utils.calculateCryptMode(crypt);
36 },
37 },
38 {
39 name: 'matchesFilter',
40 type: 'boolean',
41 defaultValue: true,
42 },
43 ],
44 });
45
46 Ext.define('PBS.DataStoreContent', {
47 extend: 'Ext.tree.Panel',
48 alias: 'widget.pbsDataStoreContent',
49
50 rootVisible: false,
51
52 title: gettext('Content'),
53
54 controller: {
55 xclass: 'Ext.app.ViewController',
56
57 init: function(view) {
58 if (!view.datastore) {
59 throw "no datastore specified";
60 }
61
62 this.store = Ext.create('Ext.data.Store', {
63 model: 'pbs-data-store-snapshots',
64 groupField: 'backup-group',
65 });
66 this.store.on('load', this.onLoad, this);
67
68 view.getStore().setSorters([
69 'backup-group',
70 'text',
71 'backup-time',
72 ]);
73 Proxmox.Utils.monStoreErrors(view, this.store);
74 this.reload(); // initial load
75 },
76
77 reload: function() {
78 let view = this.getView();
79
80 if (!view.store || !this.store) {
81 console.warn('cannot reload, no store(s)');
82 return;
83 }
84
85 let url = `/api2/json/admin/datastore/${view.datastore}/snapshots`;
86 this.store.setProxy({
87 type: 'proxmox',
88 timeout: 300*1000, // 5 minutes, we should make that api call faster
89 url: url,
90 });
91
92 this.store.load();
93 },
94
95 getRecordGroups: function(records) {
96 let groups = {};
97
98 for (const item of records) {
99 var btype = item.data["backup-type"];
100 let group = btype + "/" + item.data["backup-id"];
101
102 if (groups[group] !== undefined) {
103 continue;
104 }
105
106 var cls = '';
107 if (btype === 'vm') {
108 cls = 'fa-desktop';
109 } else if (btype === 'ct') {
110 cls = 'fa-cube';
111 } else if (btype === 'host') {
112 cls = 'fa-building';
113 } else {
114 console.warn(`got unknown backup-type '${btype}'`);
115 continue; // FIXME: auto render? what do?
116 }
117
118 groups[group] = {
119 text: group,
120 leaf: false,
121 iconCls: "fa " + cls,
122 expanded: false,
123 backup_type: item.data["backup-type"],
124 backup_id: item.data["backup-id"],
125 children: [],
126 };
127 }
128
129 return groups;
130 },
131
132 onLoad: function(store, records, success, operation) {
133 let me = this;
134 let view = this.getView();
135
136 if (!success) {
137 Proxmox.Utils.setErrorMask(view, Proxmox.Utils.getResponseErrorMessage(operation.getError()));
138 return;
139 }
140
141 let groups = this.getRecordGroups(records);
142
143 let selected;
144 let expanded = {};
145
146 view.getSelection().some(function(item) {
147 let id = item.data.text;
148 if (item.data.leaf) {
149 id = item.parentNode.data.text + id;
150 }
151 selected = id;
152 return true;
153 });
154
155 view.getRootNode().cascadeBy({
156 before: item => {
157 if (item.isExpanded() && !item.data.leaf) {
158 let id = item.data.text;
159 expanded[id] = true;
160 return true;
161 }
162 return false;
163 },
164 after: Ext.emptyFn,
165 });
166
167 for (const item of records) {
168 let group = item.data["backup-type"] + "/" + item.data["backup-id"];
169 let children = groups[group].children;
170
171 let data = item.data;
172
173 data.text = group + '/' + PBS.Utils.render_datetime_utc(data["backup-time"]);
174 data.leaf = false;
175 data.cls = 'no-leaf-icons';
176 data.matchesFilter = true;
177
178 data.expanded = !!expanded[data.text];
179
180 data.children = [];
181 for (const file of data.files) {
182 file.text = file.filename;
183 file['crypt-mode'] = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
184 file.leaf = true;
185 file.matchesFilter = true;
186
187 data.children.push(file);
188 }
189
190 children.push(data);
191 }
192
193 let nowSeconds = Date.now() / 1000;
194 let children = [];
195 for (const [name, group] of Object.entries(groups)) {
196 let last_backup = 0;
197 let crypt = {
198 none: 0,
199 mixed: 0,
200 'sign-only': 0,
201 encrypt: 0,
202 };
203 let verify = {
204 outdated: 0,
205 none: 0,
206 failed: 0,
207 ok: 0,
208 };
209 for (let item of group.children) {
210 crypt[PBS.Utils.cryptmap[item['crypt-mode']]]++;
211 if (item["backup-time"] > last_backup && item.size !== null) {
212 last_backup = item["backup-time"];
213 group["backup-time"] = last_backup;
214 group.files = item.files;
215 group.size = item.size;
216 group.owner = item.owner;
217 verify.lastFailed = item.verification && item.verification.state !== 'ok';
218 }
219 if (!item.verification) {
220 verify.none++;
221 } else {
222 if (item.verification.state === 'ok') {
223 verify.ok++;
224 } else {
225 verify.failed++;
226 }
227 let task = Proxmox.Utils.parse_task_upid(item.verification.upid);
228 item.verification.lastTime = task.starttime;
229 if (nowSeconds - task.starttime > 30 * 24 * 60 * 60) {
230 verify.outdated++;
231 }
232 }
233 }
234 group.verification = verify;
235 group.count = group.children.length;
236 group.matchesFilter = true;
237 crypt.count = group.count;
238 group['crypt-mode'] = PBS.Utils.calculateCryptMode(crypt);
239 group.expanded = !!expanded[name];
240 children.push(group);
241 }
242
243 view.setRootNode({
244 expanded: true,
245 children: children,
246 });
247
248 if (selected !== undefined) {
249 let selection = view.getRootNode().findChildBy(function(item) {
250 let id = item.data.text;
251 if (item.data.leaf) {
252 id = item.parentNode.data.text + id;
253 }
254 return selected === id;
255 }, undefined, true);
256 if (selection) {
257 view.setSelection(selection);
258 view.getView().focusRow(selection);
259 }
260 }
261
262 Proxmox.Utils.setErrorMask(view, false);
263 if (view.getStore().getFilters().length > 0) {
264 let searchBox = me.lookup("searchbox");
265 let searchvalue = searchBox.getValue();
266 me.search(searchBox, searchvalue);
267 }
268 },
269
270 onPrune: function(view, rI, cI, item, e, rec) {
271 view = this.getView();
272
273 if (!(rec && rec.data)) return;
274 let data = rec.data;
275 if (rec.parentNode.id !== 'root') return;
276
277 if (!view.datastore) return;
278
279 let win = Ext.create('PBS.DataStorePrune', {
280 datastore: view.datastore,
281 backup_type: data.backup_type,
282 backup_id: data.backup_id,
283 });
284 win.on('destroy', this.reload, this);
285 win.show();
286 },
287
288 onVerify: function(view, rI, cI, item, e, rec) {
289 let me = this;
290 view = me.getView();
291
292 if (!view.datastore) return;
293
294 if (!(rec && rec.data)) return;
295 let data = rec.data;
296
297 let params;
298
299 if (rec.parentNode.id !== 'root') {
300 params = {
301 "backup-type": data["backup-type"],
302 "backup-id": data["backup-id"],
303 "backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
304 };
305 } else {
306 params = {
307 "backup-type": data.backup_type,
308 "backup-id": data.backup_id,
309 };
310 }
311
312 Proxmox.Utils.API2Request({
313 params: params,
314 url: `/admin/datastore/${view.datastore}/verify`,
315 method: 'POST',
316 failure: function(response) {
317 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
318 },
319 success: function(response, options) {
320 Ext.create('Proxmox.window.TaskViewer', {
321 upid: response.result.data,
322 taskDone: () => me.reload(),
323 }).show();
324 },
325 });
326 },
327
328 onForget: function(view, rI, cI, item, e, rec) {
329 let me = this;
330 view = this.getView();
331
332 if (!(rec && rec.data)) return;
333 let data = rec.data;
334 if (!view.datastore) return;
335
336 Ext.Msg.show({
337 title: gettext('Confirm'),
338 icon: Ext.Msg.WARNING,
339 message: Ext.String.format(gettext('Are you sure you want to remove snapshot {0}'), `'${data.text}'`),
340 buttons: Ext.Msg.YESNO,
341 defaultFocus: 'no',
342 callback: function(btn) {
343 if (btn !== 'yes') {
344 return;
345 }
346
347 Proxmox.Utils.API2Request({
348 params: {
349 "backup-type": data["backup-type"],
350 "backup-id": data["backup-id"],
351 "backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
352 },
353 url: `/admin/datastore/${view.datastore}/snapshots`,
354 method: 'DELETE',
355 waitMsgTarget: view,
356 failure: function(response, opts) {
357 Ext.Msg.alert(gettext('Error'), response.htmlStatus);
358 },
359 callback: me.reload.bind(me),
360 });
361 },
362 });
363 },
364
365 downloadFile: function(tV, rI, cI, item, e, rec) {
366 let me = this;
367 let view = me.getView();
368
369 if (!(rec && rec.data)) return;
370 let data = rec.parentNode.data;
371
372 let file = rec.data.filename;
373 let params = {
374 'backup-id': data['backup-id'],
375 'backup-type': data['backup-type'],
376 'backup-time': (data['backup-time'].getTime()/1000).toFixed(0),
377 'file-name': file,
378 };
379
380 let idx = file.lastIndexOf('.');
381 let filename = file.slice(0, idx);
382 let atag = document.createElement('a');
383 params['file-name'] = file;
384 atag.download = filename;
385 let url = new URL(`/api2/json/admin/datastore/${view.datastore}/download-decoded`,
386 window.location.origin);
387 for (const [key, value] of Object.entries(params)) {
388 url.searchParams.append(key, value);
389 }
390 atag.href = url.href;
391 atag.click();
392 },
393
394 openPxarBrowser: function(tv, rI, Ci, item, e, rec) {
395 let me = this;
396 let view = me.getView();
397
398 if (!(rec && rec.data)) return;
399 let data = rec.parentNode.data;
400
401 let id = data['backup-id'];
402 let time = data['backup-time'];
403 let type = data['backup-type'];
404 let timetext = PBS.Utils.render_datetime_utc(data["backup-time"]);
405
406 Ext.create('PBS.window.FileBrowser', {
407 title: `${type}/${id}/${timetext}`,
408 datastore: view.datastore,
409 'backup-id': id,
410 'backup-time': (time.getTime()/1000).toFixed(0),
411 'backup-type': type,
412 archive: rec.data.filename,
413 }).show();
414 },
415
416 filter: function(item, value) {
417 if (item.data.text.indexOf(value) !== -1) {
418 return true;
419 }
420
421 if (item.data.owner && item.data.owner.indexOf(value) !== -1) {
422 return true;
423 }
424
425 return false;
426 },
427
428 search: function(tf, value) {
429 let me = this;
430 let view = me.getView();
431 let store = view.getStore();
432 if (!value && value !== 0) {
433 store.clearFilter();
434 store.getRoot().collapseChildren(true);
435 tf.triggers.clear.setVisible(false);
436 return;
437 }
438 tf.triggers.clear.setVisible(true);
439 if (value.length < 2) return;
440 Proxmox.Utils.setErrorMask(view, true);
441 // we do it a little bit later for the error mask to work
442 setTimeout(function() {
443 store.clearFilter();
444 store.getRoot().collapseChildren(true);
445
446 store.beginUpdate();
447 store.getRoot().cascadeBy({
448 before: function(item) {
449 if (me.filter(item, value)) {
450 item.set('matchesFilter', true);
451 if (item.parentNode && item.parentNode.id !== 'root') {
452 item.parentNode.childmatches = true;
453 }
454 return false;
455 }
456 return true;
457 },
458 after: function(item) {
459 if (me.filter(item, value) || item.id === 'root' || item.childmatches) {
460 item.set('matchesFilter', true);
461 if (item.parentNode && item.parentNode.id !== 'root') {
462 item.parentNode.childmatches = true;
463 }
464 if (item.childmatches) {
465 item.expand();
466 }
467 } else {
468 item.set('matchesFilter', false);
469 }
470 delete item.childmatches;
471 },
472 });
473 store.endUpdate();
474
475 store.filter((item) => !!item.get('matchesFilter'));
476 Proxmox.Utils.setErrorMask(view, false);
477 }, 10);
478 },
479 },
480
481 viewConfig: {
482 getRowClass: function(record, index) {
483 let verify = record.get('verification');
484 if (verify && verify.lastFailed) {
485 return 'proxmox-invalid-row';
486 }
487 return null;
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 });