]> git.proxmox.com Git - proxmox-backup.git/blame - www/DataStoreContent.js
ui: code cleanup
[proxmox-backup.git] / www / DataStoreContent.js
CommitLineData
33839735 1Ext.define('pbs-data-store-snapshots', {
ca23a97f 2 extend: 'Ext.data.Model',
d9c38ddc 3 fields: [
d9c38ddc 4 'backup-type',
507c39c5
DM
5 'backup-id',
6 {
e8f0ad19 7 name: 'backup-time',
507c39c5 8 type: 'date',
c6e07769 9 dateFormat: 'timestamp',
507c39c5 10 },
d9c38ddc 11 'files',
04b0ca8b 12 'owner',
7b212c1f 13 'verification',
c6e07769 14 { name: 'size', type: 'int', allowNull: true },
e005f953 15 {
2774566b 16 name: 'crypt-mode',
676b0fde 17 type: 'boolean',
e005f953 18 calculate: function(data) {
2774566b
DC
19 let crypt = {
20 none: 0,
21 mixed: 0,
22 'sign-only': 0,
23 encrypt: 0,
106603c5 24 count: 0,
2774566b 25 };
e005f953
DC
26 data.files.forEach(file => {
27 if (file.filename === 'index.json.blob') return; // is never encrypted
2774566b
DC
28 let mode = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
29 if (mode !== -1) {
30 crypt[file['crypt-mode']]++;
1bfdae79 31 crypt.count++;
e005f953 32 }
e005f953
DC
33 });
34
106603c5 35 return PBS.Utils.calculateCryptMode(crypt);
c6e07769 36 },
6d55603d
DC
37 },
38 {
39 name: 'matchesFilter',
40 type: 'boolean',
41 defaultValue: true,
42 },
c6e07769 43 ],
ca23a97f
DM
44});
45
46Ext.define('PBS.DataStoreContent', {
e8f0ad19 47 extend: 'Ext.tree.Panel',
ca23a97f
DM
48 alias: 'widget.pbsDataStoreContent',
49
e8f0ad19 50 rootVisible: false,
507c39c5 51
c0ac2074
DC
52 title: gettext('Content'),
53
f1baa7f4
TL
54 controller: {
55 xclass: 'Ext.app.ViewController',
56
57 init: function(view) {
58 if (!view.datastore) {
59 throw "no datastore specified";
60 }
61
3f98b347 62 this.store = Ext.create('Ext.data.Store', {
33839735 63 model: 'pbs-data-store-snapshots',
e8f0ad19
DM
64 groupField: 'backup-group',
65 });
3f98b347 66 this.store.on('load', this.onLoad, this);
e8f0ad19 67
7b1e2669
DC
68 view.getStore().setSorters([
69 'backup-group',
70 'text',
c6e07769 71 'backup-time',
7b1e2669 72 ]);
90779237 73 Proxmox.Utils.monStoreErrors(view, this.store);
f1baa7f4
TL
74 this.reload(); // initial load
75 },
76
77 reload: function() {
3f98b347
TL
78 let view = this.getView();
79
80 if (!view.store || !this.store) {
81 console.warn('cannot reload, no store(s)');
82 return;
83 }
f1baa7f4 84
e8f0ad19 85 let url = `/api2/json/admin/datastore/${view.datastore}/snapshots`;
3f98b347 86 this.store.setProxy({
f1baa7f4 87 type: 'proxmox',
26f499b1 88 timeout: 300*1000, // 5 minutes, we should make that api call faster
c6e07769 89 url: url,
f1baa7f4 90 });
e8f0ad19 91
3f98b347
TL
92 this.store.load();
93 },
e8f0ad19 94
3f98b347
TL
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 {
add5861e 114 console.warn(`got unknown backup-type '${btype}'`);
3f98b347
TL
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"],
c6e07769 125 children: [],
aeee4329 126 };
3f98b347 127 }
aeee4329 128
3f98b347
TL
129 return groups;
130 },
e8f0ad19 131
90779237 132 onLoad: function(store, records, success, operation) {
6d55603d 133 let me = this;
3f98b347
TL
134 let view = this.getView();
135
136 if (!success) {
90779237 137 Proxmox.Utils.setErrorMask(view, Proxmox.Utils.getResponseErrorMessage(operation.getError()));
3f98b347
TL
138 return;
139 }
140
141 let groups = this.getRecordGroups(records);
e8f0ad19 142
69d970a6
DC
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 },
c6e07769 164 after: Ext.emptyFn,
69d970a6
DC
165 });
166
3f98b347
TL
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
f68ae22c 173 data.text = group + '/' + PBS.Utils.render_datetime_utc(data["backup-time"]);
3e395378 174 data.leaf = false;
3f98b347 175 data.cls = 'no-leaf-icons';
6d55603d 176 data.matchesFilter = true;
3f98b347 177
69d970a6
DC
178 data.expanded = !!expanded[data.text];
179
3e395378
DC
180 data.children = [];
181 for (const file of data.files) {
b1149ebb 182 file.text = file.filename;
3e395378
DC
183 file['crypt-mode'] = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
184 file.leaf = true;
6d55603d 185 file.matchesFilter = true;
3e395378
DC
186
187 data.children.push(file);
188 }
189
3f98b347
TL
190 children.push(data);
191 }
192
423df9b1 193 let nowSeconds = Date.now() / 1000;
3f98b347 194 let children = [];
69d970a6 195 for (const [name, group] of Object.entries(groups)) {
3f98b347 196 let last_backup = 0;
2774566b
DC
197 let crypt = {
198 none: 0,
199 mixed: 0,
200 'sign-only': 0,
106603c5 201 encrypt: 0,
2774566b 202 };
423df9b1
TL
203 let verify = {
204 outdated: 0,
205 none: 0,
206 failed: 0,
207 ok: 0,
208 };
209 for (let item of group.children) {
2774566b 210 crypt[PBS.Utils.cryptmap[item['crypt-mode']]]++;
48e22a89 211 if (item["backup-time"] > last_backup && item.size !== null) {
3f98b347
TL
212 last_backup = item["backup-time"];
213 group["backup-time"] = last_backup;
214 group.files = item.files;
215 group.size = item.size;
04b0ca8b 216 group.owner = item.owner;
c879e5af 217 verify.lastFailed = item.verification && item.verification.state !== 'ok';
3f98b347 218 }
423df9b1
TL
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 }
7b212c1f 232 }
3f98b347 233 }
423df9b1 234 group.verification = verify;
3f98b347 235 group.count = group.children.length;
6d55603d 236 group.matchesFilter = true;
106603c5
DC
237 crypt.count = group.count;
238 group['crypt-mode'] = PBS.Utils.calculateCryptMode(crypt);
69d970a6 239 group.expanded = !!expanded[name];
3f98b347
TL
240 children.push(group);
241 }
242
243 view.setRootNode({
244 expanded: true,
c6e07769 245 children: children,
3f98b347 246 });
69d970a6
DC
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);
80db161e
SR
256 if (selection) {
257 view.setSelection(selection);
258 view.getView().focusRow(selection);
259 }
69d970a6
DC
260 }
261
90779237 262 Proxmox.Utils.setErrorMask(view, false);
6d55603d
DC
263 if (view.getStore().getFilters().length > 0) {
264 let searchBox = me.lookup("searchbox");
c6e07769 265 let searchvalue = searchBox.getValue();
6d55603d
DC
266 me.search(searchBox, searchvalue);
267 }
f1baa7f4 268 },
5f448992 269
3e395378 270 onPrune: function(view, rI, cI, item, e, rec) {
c6e07769 271 view = this.getView();
5f448992 272
5f448992
DM
273 if (!(rec && rec.data)) return;
274 let data = rec.data;
3e395378 275 if (rec.parentNode.id !== 'root') return;
5f448992
DM
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();
98425309
DC
286 },
287
3e395378 288 onVerify: function(view, rI, cI, item, e, rec) {
484d439a
TL
289 let me = this;
290 view = me.getView();
8f6088c1
DM
291
292 if (!view.datastore) return;
293
8f6088c1
DM
294 if (!(rec && rec.data)) return;
295 let data = rec.data;
296
297 let params;
298
3e395378 299 if (rec.parentNode.id !== 'root') {
8f6088c1
DM
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,
484d439a 322 taskDone: () => me.reload(),
8f6088c1
DM
323 }).show();
324 },
325 });
326 },
327
3e395378
DC
328 onForget: function(view, rI, cI, item, e, rec) {
329 let me = this;
c6e07769 330 view = this.getView();
4ff2c9b8 331
4ff2c9b8
DM
332 if (!(rec && rec.data)) return;
333 let data = rec.data;
4ff2c9b8
DM
334 if (!view.datastore) return;
335
3e395378
DC
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 }
4ff2c9b8 346
3e395378
DC
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 });
4ff2c9b8 361 },
4ff2c9b8
DM
362 });
363 },
364
3e395378 365 downloadFile: function(tV, rI, cI, item, e, rec) {
98425309
DC
366 let me = this;
367 let view = me.getView();
368
98425309 369 if (!(rec && rec.data)) return;
3e395378
DC
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;
c6e07769
TL
385 let url = new URL(`/api2/json/admin/datastore/${view.datastore}/download-decoded`,
386 window.location.origin);
3e395378
DC
387 for (const [key, value] of Object.entries(params)) {
388 url.searchParams.append(key, value);
389 }
390 atag.href = url.href;
391 atag.click();
8567c0d2
DC
392 },
393
3e395378 394 openPxarBrowser: function(tv, rI, Ci, item, e, rec) {
8567c0d2
DC
395 let me = this;
396 let view = me.getView();
397
8567c0d2 398 if (!(rec && rec.data)) return;
3e395378 399 let data = rec.parentNode.data;
8567c0d2
DC
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,
3e395378 412 archive: rec.data.filename,
8567c0d2 413 }).show();
6d55603d
DC
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) {
c6e07769 449 if (me.filter(item, value)) {
6d55603d
DC
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 },
f1baa7f4
TL
479 },
480
c879e5af
TL
481 viewConfig: {
482 getRowClass: function(record, index) {
483 let verify = record.get('verification');
484 if (verify && verify.lastFailed) {
485 return 'proxmox-invalid-row';
486 }
c6e07769 487 return null;
c879e5af
TL
488 },
489 },
490
04b0ca8b
DC
491 columns: [
492 {
493 xtype: 'treecolumn',
494 header: gettext("Backup Group"),
495 dataIndex: 'text',
c6e07769 496 flex: 1,
04b0ca8b 497 },
3e395378
DC
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') &&
c96b0de4 542 data['crypt-mode'] < 3);
c6e07769 543 },
3e395378 544 },
c6e07769 545 ],
3e395378 546 },
04b0ca8b
DC
547 {
548 xtype: 'datecolumn',
549 header: gettext('Backup Time'),
550 sortable: true,
551 dataIndex: 'backup-time',
552 format: 'Y-m-d H:i:s',
c6e07769 553 width: 150,
04b0ca8b
DC
554 },
555 {
556 header: gettext("Size"),
557 sortable: true,
558 dataIndex: 'size',
a7a5f56d 559 renderer: (v, meta, record) => {
3e395378
DC
560 if (record.data.text === 'client.log.blob' && v === undefined) {
561 return '';
562 }
a7a5f56d
TL
563 if (v === undefined || v === null) {
564 meta.tdCls = "x-grid-row-loading";
565 return '';
566 }
567 return Proxmox.Utils.format_size(v);
568 },
04b0ca8b
DC
569 },
570 {
571 xtype: 'numbercolumn',
572 format: '0',
573 header: gettext("Count"),
574 sortable: true,
46388e6a
TL
575 width: 75,
576 align: 'right',
04b0ca8b
DC
577 dataIndex: 'count',
578 },
579 {
580 header: gettext("Owner"),
581 sortable: true,
582 dataIndex: 'owner',
583 },
e005f953
DC
584 {
585 header: gettext('Encrypted'),
2774566b 586 dataIndex: 'crypt-mode',
3e395378 587 renderer: (v, meta, record) => {
71282dd9
SR
588 if (record.data.size === undefined || record.data.size === null) {
589 return '';
590 }
3e395378
DC
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 }
c6e07769
TL
599 return (iconTxt + PBS.Utils.cryptText[v]) || Proxmox.Utils.unknownText;
600 },
04b0ca8b 601 },
7b212c1f
TL
602 {
603 header: gettext('Verify State'),
604 sortable: true,
605 dataIndex: 'verification',
423df9b1 606 width: 120,
7b212c1f 607 renderer: (v, meta, record) => {
423df9b1
TL
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'));
7b212c1f 611 }
423df9b1 612 let tip, iconCls, txt;
7b212c1f 613 if (record.parentNode.id === 'root') {
423df9b1
TL
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 }
7b212c1f
TL
656 }
657 return `<span data-qtip="${tip}">
423df9b1 658 <i class="fa fa-fw fa-${iconCls}"></i> ${txt}
7b212c1f
TL
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 },
04b0ca8b 674 ],
b1127fd0 675
04b0ca8b
DC
676 tbar: [
677 {
678 text: gettext('Reload'),
679 iconCls: 'fa fa-refresh',
680 handler: 'reload',
681 },
6d55603d
DC
682 '->',
683 {
684 xtype: 'tbtext',
685 html: gettext('Search'),
686 },
687 {
688 xtype: 'textfield',
689 reference: 'searchbox',
c3b1da9e 690 emptyText: gettext('group, date or owner'),
6d55603d
DC
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 },
c6e07769 700 },
6d55603d
DC
701 },
702 listeners: {
703 change: {
704 fn: 'search',
705 buffer: 500,
706 },
707 },
c6e07769 708 },
04b0ca8b 709 ],
ca23a97f 710});