2020-03-26 10:17:15 +00:00
|
|
|
Ext.define('pbs-data-store-snapshots', {
|
2019-12-20 11:46:09 +00:00
|
|
|
extend: 'Ext.data.Model',
|
2019-12-20 16:04:45 +00:00
|
|
|
fields: [
|
|
|
|
'backup-type',
|
2019-12-22 09:43:57 +00:00
|
|
|
'backup-id',
|
|
|
|
{
|
2020-03-25 14:17:28 +00:00
|
|
|
name: 'backup-time',
|
2019-12-22 09:43:57 +00:00
|
|
|
type: 'date',
|
|
|
|
dateFormat: 'timestamp'
|
|
|
|
},
|
2019-12-20 16:04:45 +00:00
|
|
|
'files',
|
2020-05-29 13:09:01 +00:00
|
|
|
'owner',
|
2020-07-08 10:08:02 +00:00
|
|
|
{ name: 'size', type: 'int', allowNull: true, },
|
2020-06-18 11:55:25 +00:00
|
|
|
{
|
2020-07-08 11:32:20 +00:00
|
|
|
name: 'crypt-mode',
|
2020-06-23 10:09:46 +00:00
|
|
|
type: 'boolean',
|
2020-06-18 11:55:25 +00:00
|
|
|
calculate: function(data) {
|
|
|
|
let encrypted = 0;
|
2020-07-08 11:32:20 +00:00
|
|
|
let crypt = {
|
|
|
|
none: 0,
|
|
|
|
mixed: 0,
|
|
|
|
'sign-only': 0,
|
|
|
|
encrypt: 0,
|
2020-07-09 14:50:24 +00:00
|
|
|
count: 0,
|
2020-07-08 11:32:20 +00:00
|
|
|
};
|
|
|
|
let signed = 0;
|
2020-06-18 11:55:25 +00:00
|
|
|
data.files.forEach(file => {
|
|
|
|
if (file.filename === 'index.json.blob') return; // is never encrypted
|
2020-07-08 11:32:20 +00:00
|
|
|
let mode = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
|
|
|
|
if (mode !== -1) {
|
|
|
|
crypt[file['crypt-mode']]++;
|
2020-06-18 11:55:25 +00:00
|
|
|
}
|
2020-07-09 14:50:24 +00:00
|
|
|
crypt.count++;
|
2020-06-18 11:55:25 +00:00
|
|
|
});
|
|
|
|
|
2020-07-09 14:50:24 +00:00
|
|
|
return PBS.Utils.calculateCryptMode(crypt);
|
2020-06-18 11:55:25 +00:00
|
|
|
}
|
|
|
|
}
|
2020-03-26 10:17:15 +00:00
|
|
|
]
|
2019-12-20 11:46:09 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
Ext.define('PBS.DataStoreContent', {
|
2020-03-25 14:17:28 +00:00
|
|
|
extend: 'Ext.tree.Panel',
|
2019-12-20 11:46:09 +00:00
|
|
|
alias: 'widget.pbsDataStoreContent',
|
|
|
|
|
2020-03-25 14:17:28 +00:00
|
|
|
rootVisible: false,
|
2019-12-22 09:43:57 +00:00
|
|
|
|
2020-05-20 10:15:38 +00:00
|
|
|
title: gettext('Content'),
|
|
|
|
|
2019-12-20 16:17:44 +00:00
|
|
|
controller: {
|
|
|
|
xclass: 'Ext.app.ViewController',
|
|
|
|
|
|
|
|
init: function(view) {
|
|
|
|
if (!view.datastore) {
|
|
|
|
throw "no datastore specified";
|
|
|
|
}
|
|
|
|
|
2020-05-26 10:46:45 +00:00
|
|
|
this.store = Ext.create('Ext.data.Store', {
|
2020-03-26 10:17:15 +00:00
|
|
|
model: 'pbs-data-store-snapshots',
|
2020-03-25 14:17:28 +00:00
|
|
|
groupField: 'backup-group',
|
|
|
|
});
|
2020-05-26 10:46:45 +00:00
|
|
|
this.store.on('load', this.onLoad, this);
|
2020-03-25 14:17:28 +00:00
|
|
|
|
2020-06-23 10:09:47 +00:00
|
|
|
view.getStore().setSorters([
|
|
|
|
'backup-group',
|
|
|
|
'text',
|
|
|
|
'backup-time'
|
|
|
|
]);
|
2020-06-25 08:45:47 +00:00
|
|
|
Proxmox.Utils.monStoreErrors(view, this.store);
|
2019-12-20 16:17:44 +00:00
|
|
|
this.reload(); // initial load
|
|
|
|
},
|
|
|
|
|
|
|
|
reload: function() {
|
2020-05-26 10:46:45 +00:00
|
|
|
let view = this.getView();
|
|
|
|
|
|
|
|
if (!view.store || !this.store) {
|
|
|
|
console.warn('cannot reload, no store(s)');
|
|
|
|
return;
|
|
|
|
}
|
2019-12-20 16:17:44 +00:00
|
|
|
|
2020-03-25 14:17:28 +00:00
|
|
|
let url = `/api2/json/admin/datastore/${view.datastore}/snapshots`;
|
2020-05-26 10:46:45 +00:00
|
|
|
this.store.setProxy({
|
2019-12-20 16:17:44 +00:00
|
|
|
type: 'proxmox',
|
2020-07-01 11:46:00 +00:00
|
|
|
timeout: 300*1000, // 5 minutes, we should make that api call faster
|
2019-12-20 16:17:44 +00:00
|
|
|
url: url
|
|
|
|
});
|
2020-03-25 14:17:28 +00:00
|
|
|
|
2020-05-26 10:46:45 +00:00
|
|
|
this.store.load();
|
|
|
|
},
|
2020-03-25 14:17:28 +00:00
|
|
|
|
2020-05-26 10:46:45 +00:00
|
|
|
getRecordGroups: function(records) {
|
|
|
|
let groups = {};
|
|
|
|
|
|
|
|
for (const item of records) {
|
|
|
|
var btype = item.data["backup-type"];
|
|
|
|
let group = btype + "/" + item.data["backup-id"];
|
|
|
|
|
|
|
|
if (groups[group] !== undefined) {
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
|
|
|
|
var cls = '';
|
|
|
|
if (btype === 'vm') {
|
|
|
|
cls = 'fa-desktop';
|
|
|
|
} else if (btype === 'ct') {
|
|
|
|
cls = 'fa-cube';
|
|
|
|
} else if (btype === 'host') {
|
|
|
|
cls = 'fa-building';
|
|
|
|
} else {
|
2020-05-30 14:37:33 +00:00
|
|
|
console.warn(`got unknown backup-type '${btype}'`);
|
2020-05-26 10:46:45 +00:00
|
|
|
continue; // FIXME: auto render? what do?
|
|
|
|
}
|
|
|
|
|
|
|
|
groups[group] = {
|
|
|
|
text: group,
|
|
|
|
leaf: false,
|
|
|
|
iconCls: "fa " + cls,
|
|
|
|
expanded: false,
|
|
|
|
backup_type: item.data["backup-type"],
|
|
|
|
backup_id: item.data["backup-id"],
|
|
|
|
children: []
|
2020-03-26 17:01:04 +00:00
|
|
|
};
|
2020-05-26 10:46:45 +00:00
|
|
|
}
|
2020-03-26 17:01:04 +00:00
|
|
|
|
2020-05-26 10:46:45 +00:00
|
|
|
return groups;
|
|
|
|
},
|
2020-03-25 14:17:28 +00:00
|
|
|
|
2020-06-25 08:45:47 +00:00
|
|
|
onLoad: function(store, records, success, operation) {
|
2020-05-26 10:46:45 +00:00
|
|
|
let view = this.getView();
|
|
|
|
|
|
|
|
if (!success) {
|
2020-06-25 08:45:47 +00:00
|
|
|
Proxmox.Utils.setErrorMask(view, Proxmox.Utils.getResponseErrorMessage(operation.getError()));
|
2020-05-26 10:46:45 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let groups = this.getRecordGroups(records);
|
2020-03-25 14:17:28 +00:00
|
|
|
|
2020-05-26 10:46:45 +00:00
|
|
|
for (const item of records) {
|
|
|
|
let group = item.data["backup-type"] + "/" + item.data["backup-id"];
|
|
|
|
let children = groups[group].children;
|
|
|
|
|
|
|
|
let data = item.data;
|
|
|
|
|
2020-05-26 16:16:38 +00:00
|
|
|
data.text = group + '/' + PBS.Utils.render_datetime_utc(data["backup-time"]);
|
2020-05-26 10:46:45 +00:00
|
|
|
data.leaf = true;
|
|
|
|
data.cls = 'no-leaf-icons';
|
|
|
|
|
|
|
|
children.push(data);
|
|
|
|
}
|
|
|
|
|
|
|
|
let children = [];
|
|
|
|
for (const [_key, group] of Object.entries(groups)) {
|
|
|
|
let last_backup = 0;
|
2020-07-08 11:32:20 +00:00
|
|
|
let crypt = {
|
|
|
|
none: 0,
|
|
|
|
mixed: 0,
|
|
|
|
'sign-only': 0,
|
2020-07-09 14:50:24 +00:00
|
|
|
encrypt: 0,
|
2020-07-08 11:32:20 +00:00
|
|
|
};
|
2020-05-26 10:46:45 +00:00
|
|
|
for (const item of group.children) {
|
2020-07-08 11:32:20 +00:00
|
|
|
crypt[PBS.Utils.cryptmap[item['crypt-mode']]]++;
|
2020-07-08 10:09:09 +00:00
|
|
|
if (item["backup-time"] > last_backup && item.size !== null) {
|
2020-05-26 10:46:45 +00:00
|
|
|
last_backup = item["backup-time"];
|
|
|
|
group["backup-time"] = last_backup;
|
|
|
|
group.files = item.files;
|
|
|
|
group.size = item.size;
|
2020-05-29 13:09:01 +00:00
|
|
|
group.owner = item.owner;
|
2020-05-26 10:46:45 +00:00
|
|
|
}
|
2020-06-18 11:55:25 +00:00
|
|
|
|
2020-05-26 10:46:45 +00:00
|
|
|
}
|
|
|
|
group.count = group.children.length;
|
2020-07-09 14:50:24 +00:00
|
|
|
crypt.count = group.count;
|
|
|
|
group['crypt-mode'] = PBS.Utils.calculateCryptMode(crypt);
|
2020-05-26 10:46:45 +00:00
|
|
|
children.push(group);
|
|
|
|
}
|
|
|
|
|
|
|
|
view.setRootNode({
|
|
|
|
expanded: true,
|
|
|
|
children: children
|
|
|
|
});
|
2020-06-25 08:45:47 +00:00
|
|
|
Proxmox.Utils.setErrorMask(view, false);
|
2019-12-20 16:17:44 +00:00
|
|
|
},
|
2020-03-26 16:23:51 +00:00
|
|
|
|
|
|
|
onPrune: function() {
|
|
|
|
var view = this.getView();
|
|
|
|
|
|
|
|
let rec = view.selModel.getSelection()[0];
|
|
|
|
if (!(rec && rec.data)) return;
|
|
|
|
let data = rec.data;
|
|
|
|
if (data.leaf) return;
|
|
|
|
|
|
|
|
if (!view.datastore) return;
|
|
|
|
|
|
|
|
let win = Ext.create('PBS.DataStorePrune', {
|
|
|
|
datastore: view.datastore,
|
|
|
|
backup_type: data.backup_type,
|
|
|
|
backup_id: data.backup_id,
|
|
|
|
});
|
|
|
|
win.on('destroy', this.reload, this);
|
|
|
|
win.show();
|
2020-06-23 10:09:48 +00:00
|
|
|
},
|
|
|
|
|
2020-06-30 11:22:02 +00:00
|
|
|
onVerify: function() {
|
|
|
|
var view = this.getView();
|
|
|
|
|
|
|
|
if (!view.datastore) return;
|
|
|
|
|
|
|
|
let rec = view.selModel.getSelection()[0];
|
|
|
|
if (!(rec && rec.data)) return;
|
|
|
|
let data = rec.data;
|
|
|
|
|
|
|
|
let params;
|
|
|
|
|
|
|
|
if (data.leaf) {
|
|
|
|
params = {
|
|
|
|
"backup-type": data["backup-type"],
|
|
|
|
"backup-id": data["backup-id"],
|
|
|
|
"backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
params = {
|
|
|
|
"backup-type": data.backup_type,
|
|
|
|
"backup-id": data.backup_id,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
Proxmox.Utils.API2Request({
|
|
|
|
params: params,
|
|
|
|
url: `/admin/datastore/${view.datastore}/verify`,
|
|
|
|
method: 'POST',
|
|
|
|
failure: function(response) {
|
|
|
|
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
|
|
|
|
},
|
|
|
|
success: function(response, options) {
|
|
|
|
Ext.create('Proxmox.window.TaskViewer', {
|
|
|
|
upid: response.result.data,
|
|
|
|
}).show();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
2020-06-26 13:58:06 +00:00
|
|
|
onForget: function() {
|
|
|
|
var view = this.getView();
|
|
|
|
|
|
|
|
let rec = view.selModel.getSelection()[0];
|
|
|
|
if (!(rec && rec.data)) return;
|
|
|
|
let data = rec.data;
|
|
|
|
if (!data.leaf) return;
|
|
|
|
|
|
|
|
if (!view.datastore) return;
|
|
|
|
|
|
|
|
console.log(data);
|
|
|
|
|
|
|
|
Proxmox.Utils.API2Request({
|
|
|
|
params: {
|
|
|
|
"backup-type": data["backup-type"],
|
|
|
|
"backup-id": data["backup-id"],
|
|
|
|
"backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
|
|
|
|
},
|
|
|
|
url: `/admin/datastore/${view.datastore}/snapshots`,
|
|
|
|
method: 'DELETE',
|
|
|
|
waitMsgTarget: view,
|
|
|
|
failure: function(response, opts) {
|
|
|
|
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
|
|
|
|
},
|
|
|
|
callback: this.reload.bind(this),
|
|
|
|
});
|
|
|
|
},
|
|
|
|
|
2020-06-23 10:09:48 +00:00
|
|
|
openBackupFileDownloader: function() {
|
|
|
|
let me = this;
|
|
|
|
let view = me.getView();
|
|
|
|
|
|
|
|
let rec = view.selModel.getSelection()[0];
|
|
|
|
if (!(rec && rec.data)) return;
|
|
|
|
let data = rec.data;
|
|
|
|
|
|
|
|
Ext.create('PBS.window.BackupFileDownloader', {
|
|
|
|
baseurl: `/api2/json/admin/datastore/${view.datastore}`,
|
|
|
|
params: {
|
|
|
|
'backup-id': data['backup-id'],
|
|
|
|
'backup-type': data['backup-type'],
|
|
|
|
'backup-time': (data['backup-time'].getTime()/1000).toFixed(0),
|
|
|
|
},
|
|
|
|
files: data.files,
|
|
|
|
}).show();
|
2020-06-23 10:09:54 +00:00
|
|
|
},
|
|
|
|
|
|
|
|
openPxarBrowser: function() {
|
|
|
|
let me = this;
|
|
|
|
let view = me.getView();
|
|
|
|
|
|
|
|
let rec = view.selModel.getSelection()[0];
|
|
|
|
if (!(rec && rec.data)) return;
|
|
|
|
let data = rec.data;
|
|
|
|
|
|
|
|
let encrypted = false;
|
|
|
|
data.files.forEach(file => {
|
2020-07-08 11:32:20 +00:00
|
|
|
if (file.filename === 'catalog.pcat1.didx' && file['crypt-mode'] === 'encrypt') {
|
2020-06-23 10:09:54 +00:00
|
|
|
encrypted = true;
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
if (encrypted) {
|
|
|
|
Ext.Msg.alert(
|
|
|
|
gettext('Cannot open Catalog'),
|
|
|
|
gettext('Only unencrypted Backups can be opened on the server. Please use the client with the decryption key instead.'),
|
|
|
|
);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
let id = data['backup-id'];
|
|
|
|
let time = data['backup-time'];
|
|
|
|
let type = data['backup-type'];
|
|
|
|
let timetext = PBS.Utils.render_datetime_utc(data["backup-time"]);
|
|
|
|
|
|
|
|
Ext.create('PBS.window.FileBrowser', {
|
|
|
|
title: `${type}/${id}/${timetext}`,
|
|
|
|
datastore: view.datastore,
|
|
|
|
'backup-id': id,
|
|
|
|
'backup-time': (time.getTime()/1000).toFixed(0),
|
|
|
|
'backup-type': type,
|
|
|
|
}).show();
|
2020-03-26 16:23:51 +00:00
|
|
|
}
|
2019-12-20 16:17:44 +00:00
|
|
|
},
|
|
|
|
|
2020-05-29 13:09:01 +00:00
|
|
|
columns: [
|
|
|
|
{
|
|
|
|
xtype: 'treecolumn',
|
|
|
|
header: gettext("Backup Group"),
|
|
|
|
dataIndex: 'text',
|
|
|
|
flex: 1
|
|
|
|
},
|
|
|
|
{
|
|
|
|
xtype: 'datecolumn',
|
|
|
|
header: gettext('Backup Time'),
|
|
|
|
sortable: true,
|
|
|
|
dataIndex: 'backup-time',
|
|
|
|
format: 'Y-m-d H:i:s',
|
|
|
|
width: 150
|
|
|
|
},
|
|
|
|
{
|
|
|
|
header: gettext("Size"),
|
|
|
|
sortable: true,
|
|
|
|
dataIndex: 'size',
|
2020-07-08 10:08:02 +00:00
|
|
|
renderer: (v, meta, record) => {
|
|
|
|
if (v === undefined || v === null) {
|
|
|
|
meta.tdCls = "x-grid-row-loading";
|
|
|
|
return '';
|
|
|
|
}
|
|
|
|
return Proxmox.Utils.format_size(v);
|
|
|
|
},
|
2020-05-29 13:09:01 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
xtype: 'numbercolumn',
|
|
|
|
format: '0',
|
|
|
|
header: gettext("Count"),
|
|
|
|
sortable: true,
|
|
|
|
dataIndex: 'count',
|
|
|
|
},
|
|
|
|
{
|
|
|
|
header: gettext("Owner"),
|
|
|
|
sortable: true,
|
|
|
|
dataIndex: 'owner',
|
|
|
|
},
|
2020-06-18 11:55:25 +00:00
|
|
|
{
|
|
|
|
header: gettext('Encrypted'),
|
2020-07-08 11:32:20 +00:00
|
|
|
dataIndex: 'crypt-mode',
|
|
|
|
renderer: value => PBS.Utils.cryptText[value] || Proxmox.Utils.unknownText,
|
2020-06-18 11:55:25 +00:00
|
|
|
},
|
2020-05-29 13:09:01 +00:00
|
|
|
{
|
|
|
|
header: gettext("Files"),
|
|
|
|
sortable: false,
|
|
|
|
dataIndex: 'files',
|
2020-06-18 11:55:24 +00:00
|
|
|
renderer: function(files) {
|
|
|
|
return files.map((file) => {
|
2020-06-18 11:55:25 +00:00
|
|
|
let icon = '';
|
|
|
|
let size = '';
|
2020-07-08 11:32:20 +00:00
|
|
|
let mode = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
|
|
|
|
let iconCls = PBS.Utils.cryptIconCls[mode] || '';
|
|
|
|
if (iconCls !== '') {
|
|
|
|
icon = `<i class="fa fa-${iconCls}"></i> `;
|
2020-06-18 11:55:25 +00:00
|
|
|
}
|
|
|
|
if (file.size) {
|
|
|
|
size = ` (${Proxmox.Utils.format_size(file.size)})`;
|
|
|
|
}
|
|
|
|
return `${icon}${file.filename}${size}`;
|
2020-06-18 11:55:24 +00:00
|
|
|
}).join(', ');
|
|
|
|
},
|
2020-05-29 13:09:01 +00:00
|
|
|
flex: 2
|
|
|
|
},
|
|
|
|
],
|
2020-03-26 12:23:28 +00:00
|
|
|
|
2020-05-29 13:09:01 +00:00
|
|
|
tbar: [
|
|
|
|
{
|
|
|
|
text: gettext('Reload'),
|
|
|
|
iconCls: 'fa fa-refresh',
|
|
|
|
handler: 'reload',
|
|
|
|
},
|
2020-07-08 10:21:56 +00:00
|
|
|
'-',
|
2020-06-30 11:22:02 +00:00
|
|
|
{
|
|
|
|
xtype: 'proxmoxButton',
|
|
|
|
text: gettext('Verify'),
|
|
|
|
disabled: true,
|
|
|
|
parentXType: 'pbsDataStoreContent',
|
2020-07-08 10:21:56 +00:00
|
|
|
enableFn: (rec) => !!rec.data && rec.data.size !== null,
|
2020-06-30 11:22:02 +00:00
|
|
|
handler: 'onVerify',
|
|
|
|
},
|
2020-05-29 13:09:01 +00:00
|
|
|
{
|
|
|
|
xtype: 'proxmoxButton',
|
2020-03-26 12:23:28 +00:00
|
|
|
text: gettext('Prune'),
|
|
|
|
disabled: true,
|
2020-06-04 10:38:45 +00:00
|
|
|
parentXType: 'pbsDataStoreContent',
|
2020-07-08 10:21:56 +00:00
|
|
|
enableFn: (rec) => !rec.data.leaf,
|
2020-03-26 16:23:51 +00:00
|
|
|
handler: 'onPrune',
|
2020-06-23 10:09:48 +00:00
|
|
|
},
|
2020-06-26 13:58:06 +00:00
|
|
|
{
|
|
|
|
xtype: 'proxmoxButton',
|
|
|
|
text: gettext('Forget'),
|
|
|
|
disabled: true,
|
|
|
|
parentXType: 'pbsDataStoreContent',
|
|
|
|
handler: 'onForget',
|
2020-07-08 10:21:56 +00:00
|
|
|
dangerous: true,
|
2020-06-26 13:58:06 +00:00
|
|
|
confirmMsg: function(record) {
|
2020-07-08 10:21:56 +00:00
|
|
|
//console.log(record);
|
2020-06-26 13:58:06 +00:00
|
|
|
let name = record.data.text;
|
|
|
|
return Ext.String.format(gettext('Are you sure you want to remove snapshot {0}'), `'${name}'`);
|
|
|
|
},
|
2020-07-08 10:21:56 +00:00
|
|
|
enableFn: (rec) => !!rec.data.leaf && rec.data.size !== null,
|
2020-06-26 13:58:06 +00:00
|
|
|
},
|
2020-07-08 10:21:56 +00:00
|
|
|
'-',
|
2020-06-23 10:09:48 +00:00
|
|
|
{
|
|
|
|
xtype: 'proxmoxButton',
|
|
|
|
text: gettext('Download Files'),
|
|
|
|
disabled: true,
|
|
|
|
parentXType: 'pbsDataStoreContent',
|
|
|
|
handler: 'openBackupFileDownloader',
|
2020-07-08 10:21:56 +00:00
|
|
|
enableFn: (rec) => !!rec.data.leaf && rec.data.size !== null,
|
2020-06-23 10:09:54 +00:00
|
|
|
},
|
|
|
|
{
|
|
|
|
xtype: "proxmoxButton",
|
|
|
|
text: gettext('PXAR File Browser'),
|
|
|
|
disabled: true,
|
|
|
|
handler: 'openPxarBrowser',
|
|
|
|
parentXType: 'pbsDataStoreContent',
|
|
|
|
enableFn: function(record) {
|
2020-07-08 10:21:56 +00:00
|
|
|
return !!record.data.leaf && record.size !== null && record.data.files.some(el => el.filename.endsWith('pxar.didx'));
|
2020-06-23 10:09:54 +00:00
|
|
|
},
|
2020-05-29 13:09:01 +00:00
|
|
|
}
|
|
|
|
],
|
2019-12-20 11:46:09 +00:00
|
|
|
});
|