Ext.ns('PBS'); console.log('Starting Backup Server GUI'); Ext.define('PBS.Utils', { singleton: true, missingText: gettext('missing'), updateLoginData: function (data) { if (data['ticket-info']) { // we received a HttpOnly ticket response, the actual cookie is handled by the browser. // set the ticket field to use the information from `ticket-info` data.ticket = data['ticket-info']; } Proxmox.Utils.setAuthData(data); }, dataStorePrefix: 'DataStore-', cryptmap: ['none', 'mixed', 'sign-only', 'encrypt'], cryptText: [Proxmox.Utils.noText, gettext('Mixed'), gettext('Signed'), gettext('Encrypted')], cryptIconCls: ['', '', 'lock faded', 'lock good'], calculateCryptMode: function (data) { let mixed = data.mixed; let encrypted = data.encrypt; let signed = data['sign-only']; let files = data.count; if (mixed > 0) { return PBS.Utils.cryptmap.indexOf('mixed'); } else if (files === encrypted && encrypted > 0) { return PBS.Utils.cryptmap.indexOf('encrypt'); } else if (files === signed && signed > 0) { return PBS.Utils.cryptmap.indexOf('sign-only'); } else if (signed + encrypted === 0) { return PBS.Utils.cryptmap.indexOf('none'); } else { return PBS.Utils.cryptmap.indexOf('mixed'); } }, noSubKeyHtml: 'You do not have a valid subscription for this server. Please visit www.proxmox.com to get a list of available options.', getDataStoreFromPath: function (path) { return path.slice(PBS.Utils.dataStorePrefix.length); }, isDataStorePath: function (path) { return path.indexOf(PBS.Utils.dataStorePrefix) === 0; }, parsePropertyString: function (value, defaultKey) { var res = {}, error; if (typeof value !== 'string' || value === '') { return res; } Ext.Array.each(value.split(','), function (p) { var kv = p.split('=', 2); if (Ext.isDefined(kv[1])) { res[kv[0]] = kv[1]; } else if (Ext.isDefined(defaultKey)) { if (Ext.isDefined(res[defaultKey])) { error = 'defaultKey may be only defined once in propertyString'; return false; // break } res[defaultKey] = kv[0]; } else { error = 'invalid propertyString, not a key=value pair and no defaultKey defined'; return false; // break } return true; }); if (error !== undefined) { console.error(error); return null; } return res; }, printPropertyString: function (data, defaultKey) { var stringparts = [], gotDefaultKeyVal = false, defaultKeyVal; Ext.Object.each(data, function (key, value) { if (defaultKey !== undefined && key === defaultKey) { gotDefaultKeyVal = true; defaultKeyVal = value; } else if (value !== '' && value !== undefined) { stringparts.push(key + '=' + value); } }); stringparts = stringparts.sort(); if (gotDefaultKeyVal) { stringparts.unshift(defaultKeyVal); } return stringparts.join(','); }, // helper for deleting field which are set to there default values delete_if_default: function (values, fieldname, default_val, create) { if (values[fieldname] === '' || values[fieldname] === default_val) { if (!create) { if (values.delete) { if (Ext.isArray(values.delete)) { values.delete.push(fieldname); } else { values.delete += ',' + fieldname; } } else { values.delete = [fieldname]; } } delete values[fieldname]; } }, render_datetime_utc: function (datetime) { let pad = (number) => (number < 10 ? '0' + number : number); return ( datetime.getUTCFullYear() + '-' + pad(datetime.getUTCMonth() + 1) + '-' + pad(datetime.getUTCDate()) + 'T' + pad(datetime.getUTCHours()) + ':' + pad(datetime.getUTCMinutes()) + ':' + pad(datetime.getUTCSeconds()) + 'Z' ); }, render_datastore_worker_id: function (id, what) { const res = id.match(/^(\S+?):(\S+?)\/(\S+?)(\/(.+))?$/); if (res) { let datastore = res[1], backupGroup = `${res[2]}/${res[3]}`; if (res[4] !== undefined) { let datetime = Ext.Date.parse(parseInt(res[5], 16), 'U'); let utctime = PBS.Utils.render_datetime_utc(datetime); return `Datastore ${datastore} ${what} ${backupGroup}/${utctime}`; } else { return `Datastore ${datastore} ${what} ${backupGroup}`; } } return `Datastore ${what} ${id}`; }, render_prune_job_worker_id: function (id, what) { const res = id.match(/^(\S+?):(\S+)$/); if (!res) { return `${what} on Datastore ${id}`; } let datastore = res[1], namespace = res[2]; return `${what} on Datastore ${datastore} Namespace ${namespace}`; }, render_tape_backup_id: function (id, what) { const res = id.match(/^(\S+?):(\S+?):(\S+?)(:(.+))?$/); if (res) { let datastore = res[1]; let pool = res[2]; let drive = res[3]; return `${what} ${datastore} (pool ${pool}, drive ${drive})`; } return `${what} ${id}`; }, render_drive_load_media_id: function (id, what) { const res = id.match(/^(\S+?):(\S+?)$/); if (res) { let drive = res[1]; let label = res[2]; return gettext('Drive') + ` ${drive} - ${what} '${label}'`; } return `${what} ${id}`; }, // mimics Display trait in backend renderKeyID: function (fingerprint) { return fingerprint.substring(0, 23); }, render_task_status: function (value, metadata, record, rowIndex, colIndex, store) { // GC tasks use 'upid' for backwards-compat, rest use 'last-run-upid' if ( !record.data['last-run-upid'] && !store.getById('last-run-upid')?.data.value && !record.data.upid && !store.getById('upid')?.data.value ) { return '-'; } if (!record.data['last-run-endtime'] && !store.getById('last-run-endtime')?.data.value) { metadata.tdCls = 'x-grid-row-loading'; return ''; } let parsed = Proxmox.Utils.parse_task_status(value); let text = value; let icon = ''; switch (parsed) { case 'unknown': icon = 'question faded'; text = Proxmox.Utils.unknownText; break; case 'error': icon = 'times critical'; text = Proxmox.Utils.errorText + ': ' + value; break; case 'warning': icon = 'exclamation warning'; break; case 'ok': icon = 'check good'; text = gettext('OK'); } return ` ${text}`; }, render_next_task_run: function (value, metadat, record) { if (!value) { return '-'; } let now = new Date(); let next = new Date(value * 1000); if (next < now) { return gettext('pending'); } return Proxmox.Utils.render_timestamp(value); }, render_optional_timestamp: function (value, metadata, record) { if (!value) { return '-'; } return Proxmox.Utils.render_timestamp(value); }, parse_datastore_worker_id: function (type, id) { let result; let res; if (type.startsWith('verif')) { res = PBS.Utils.VERIFICATION_JOB_ID_RE.exec(id); if (res) { result = res[1]; } } else if (type.startsWith('sync')) { res = PBS.Utils.SYNC_JOB_ID_RE.exec(id); if (res) { result = res[3]; } } else if (type === 'backup') { res = PBS.Utils.BACKUP_JOB_ID_RE.exec(id); if (res) { result = res[1]; } } else if (type === 'garbage_collection') { return id; } else if (type === 'prune') { return id; } return result; }, extractTokenUser: function (tokenid) { return tokenid.match(/^(.+)!([^!]+)$/)[1]; }, extractTokenName: function (tokenid) { return tokenid.match(/^(.+)!([^!]+)$/)[2]; }, render_estimate: function (value, metaData, record) { if (record.data.avail === 0) { return gettext('Full'); } if (value === undefined) { return gettext('Not enough data'); } let now = new Date(); let estimate = new Date(value * 1000); let timespan = (estimate - now) / 1000; if (Number(estimate) <= Number(now) || isNaN(timespan)) { return gettext('Never'); } let duration = Proxmox.Utils.format_duration_human(timespan); return Ext.String.format(gettext('in {0}'), duration); }, // FIXME: deprecated by Proxmox.Utils.render_size_usage ?! render_size_usage: function (val, max) { if (max === 0) { return gettext('N/A'); } return ( ((val * 100) / max).toFixed(2) + '% (' + Ext.String.format( gettext('{0} of {1}'), Proxmox.Utils.format_size(val), Proxmox.Utils.format_size(max), ) + ')' ); }, get_help_tool: function (blockid) { let info = Proxmox.Utils.get_help_info(blockid); if (info === undefined) { info = Proxmox.Utils.get_help_info('pbs_documentation_index'); } if (info === undefined) { throw 'get_help_info failed'; // should not happen } let docsURI = window.location.origin + info.link; let title = info.title; if (info.subtitle) { title += ' - ' + info.subtitle; } return { type: 'help', tooltip: title, handler: function () { window.open(docsURI); }, }; }, calculate_dedup_factor: function (gcstatus) { let dedup = 1.0; if (gcstatus['disk-bytes'] > 0) { dedup = (gcstatus['index-data-bytes'] || 0) / gcstatus['disk-bytes']; } return dedup; }, parse_snapshot_id: function (snapshot) { if (!snapshot) { return [undefined, undefined, undefined]; } let nsRegex = /(?:^|\/)(ns\/([^/]+))/g; let namespaces = []; let nsPaths = []; snapshot = snapshot.replace(nsRegex, (_, nsPath, ns) => { nsPaths.push(nsPath); namespaces.push(ns); return ''; }); let [_match, type, group, id] = /^\/?([^/]+)\/([^/]+)\/(.+)$/.exec(snapshot); return [type, group, id, namespaces.join('/'), nsPaths.join('/')]; }, get_type_icon_cls: function (btype) { var cls = ''; if (btype.startsWith('vm')) { cls = 'fa-desktop'; } else if (btype.startsWith('ct')) { cls = 'fa-cube'; } else if (btype.startsWith('host')) { cls = 'fa-building'; } return cls; }, constructor: function () { var me = this; let PROXMOX_SAFE_ID_REGEX = '([A-Za-z0-9_][A-Za-z0-9._-]*)'; me.SAFE_ID_RE = new RegExp(`^${PROXMOX_SAFE_ID_REGEX}$`); // only anchored at beginning, only parses datastore for now me.VERIFICATION_JOB_ID_RE = new RegExp('^' + PROXMOX_SAFE_ID_REGEX + ':?'); me.SYNC_JOB_ID_RE = new RegExp( '^' + PROXMOX_SAFE_ID_REGEX + ':' + PROXMOX_SAFE_ID_REGEX + ':' + PROXMOX_SAFE_ID_REGEX + ':', ); me.BACKUP_JOB_ID_RE = new RegExp('^' + PROXMOX_SAFE_ID_REGEX + ':'); // do whatever you want here Proxmox.Utils.override_task_descriptions({ 'acme-deactivate': (type, id) => Ext.String.format(gettext('Deactivate {0} Account'), 'ACME') + ` '${id || 'default'}'`, 'acme-register': (type, id) => Ext.String.format(gettext('Register {0} Account'), 'ACME') + ` '${id || 'default'}'`, 'acme-update': (type, id) => Ext.String.format(gettext('Update {0} Account'), 'ACME') + ` '${id || 'default'}'`, 'acme-new-cert': ['', gettext('Order Certificate')], 'acme-renew-cert': ['', gettext('Renew Certificate')], 'acme-revoke-cert': ['', gettext('Revoke Certificate')], backup: (type, id) => PBS.Utils.render_datastore_worker_id(id, gettext('Backup')), 'barcode-label-media': [gettext('Drive'), gettext('Barcode-Label Media')], 'catalog-media': [gettext('Drive'), gettext('Catalog Media')], 'create-datastore': [gettext('Datastore'), gettext('Create Datastore')], 'delete-datastore': [gettext('Datastore'), gettext('Remove Datastore')], 'delete-namespace': [gettext('Namespace'), gettext('Remove Namespace')], dircreate: [gettext('Directory Storage'), gettext('Create')], dirremove: [gettext('Directory'), gettext('Remove')], 'eject-media': [gettext('Drive'), gettext('Eject Media')], 'format-media': [gettext('Drive'), gettext('Format media')], 'forget-group': [gettext('Group'), gettext('Remove Group')], garbage_collection: ['Datastore', gettext('Garbage Collect')], 'realm-sync': ['Realm', gettext('User Sync')], 'inventory-update': [gettext('Drive'), gettext('Inventory Update')], 'label-media': [gettext('Drive'), gettext('Label Media')], 'load-media': (type, id) => PBS.Utils.render_drive_load_media_id(id, gettext('Load Media')), logrotate: [null, gettext('Log Rotation')], 'mount-device': [gettext('Datastore'), gettext('Mount Device')], 'mount-sync-jobs': [gettext('Datastore'), gettext('sync jobs handler triggered by mount')], prune: (type, id) => PBS.Utils.render_datastore_worker_id(id, gettext('Prune')), prunejob: (type, id) => PBS.Utils.render_prune_job_worker_id(id, gettext('Prune Job')), reader: (type, id) => PBS.Utils.render_datastore_worker_id(id, gettext('Read Objects')), 'rewind-media': [gettext('Drive'), gettext('Rewind Media')], 's3-refresh': [gettext('Datastore'), gettext('S3 Refresh')], sync: ['Datastore', gettext('Remote Sync')], syncjob: [gettext('Sync Job'), gettext('Remote Sync')], 'tape-backup': (type, id) => PBS.Utils.render_tape_backup_id(id, gettext('Tape Backup')), 'tape-backup-job': (type, id) => PBS.Utils.render_tape_backup_id(id, gettext('Tape Backup Job')), 'tape-restore': ['Datastore', gettext('Tape Restore')], 'unload-media': [gettext('Drive'), gettext('Unload Media')], 'unmount-device': [gettext('Datastore'), gettext('Unmount Device')], verificationjob: [gettext('Verify Job'), gettext('Scheduled Verification')], verify: ['Datastore', gettext('Verification')], verify_group: ['Group', gettext('Verification')], verify_snapshot: ['Snapshot', gettext('Verification')], wipedisk: ['Device', gettext('Wipe Disk')], zfscreate: [gettext('ZFS Storage'), gettext('Create')], }); Proxmox.Utils.overrideNotificationFieldName({ datastore: gettext('Datastore'), 'job-id': gettext('Job ID'), 'media-pool': gettext('Media Pool'), }); Proxmox.Utils.overrideNotificationFieldValue({ acme: gettext('ACME certificate renewal'), gc: gettext('Garbage collection'), 'package-updates': gettext('Package updates are available'), prune: gettext('Prune job'), sync: gettext('Sync job'), 'tape-backup': gettext('Tape backup notifications'), 'tape-load': gettext('Tape loading request'), verify: gettext('Verification job'), }); Proxmox.Schema.overrideAuthDomains({ pbs: { name: 'Proxmox Backup authentication server', ipanel: 'pmxAuthSimplePanel', onlineHelp: 'user-realms-pbs', add: false, edit: true, pwchange: true, sync: false, useTypeInUrl: false, }, }); // TODO: use `overrideEndpointTypes` later - not done right now to avoid // breakage if widget-toolkit is not updated yet. Proxmox.Schema.notificationEndpointTypes = { sendmail: { name: 'Sendmail', ipanel: 'pmxSendmailEditPanel', iconCls: 'fa-envelope-o', defaultMailAuthor: 'Proxmox Backup Server - $hostname', }, smtp: { name: 'SMTP', ipanel: 'pmxSmtpEditPanel', iconCls: 'fa-envelope-o', defaultMailAuthor: 'Proxmox Backup Server - $hostname', }, gotify: { name: 'Gotify', ipanel: 'pmxGotifyEditPanel', iconCls: 'fa-bell-o', }, webhook: { name: 'Webhook', ipanel: 'pmxWebhookEditPanel', iconCls: 'fa-bell-o', }, }; }, // Convert an ArrayBuffer to a base64url encoded string. // A `null` value will be preserved for convenience. bytes_to_base64url: function (bytes) { if (bytes === null) { return null; } return btoa( Array.from(new Uint8Array(bytes)) .map((val) => String.fromCharCode(val)) .join(''), ) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/[=]/g, ''); }, // Convert an a base64url string to an ArrayBuffer. // A `null` value will be preserved for convenience. base64url_to_bytes: function (b64u) { if (b64u === null) { return null; } return new Uint8Array( atob(b64u.replace(/-/g, '+').replace(/_/g, '/')) .split('') .map((val) => val.charCodeAt(0)), ); }, driveCommand: function (driveid, command, reqOpts) { let params = Ext.apply(reqOpts, { url: `/api2/extjs/tape/drive/${driveid}/${command}`, timeout: 5 * 60 * 1000, failure: function (response) { Ext.Msg.alert(gettext('Error'), response.htmlStatus); }, }); Proxmox.Utils.API2Request(params); }, showMediaLabelWindow: function (response) { let list = []; for (let [key, val] of Object.entries(response.result.data)) { if (key === 'ctime' || key === 'media-set-ctime') { val = Proxmox.Utils.render_timestamp(val); } list.push({ key: key, value: val }); } Ext.create('Ext.window.Window', { title: gettext('Label Information'), modal: true, width: 600, height: 450, layout: 'fit', scrollable: true, items: [ { xtype: 'grid', store: { data: list, }, columns: [ { text: gettext('Property'), dataIndex: 'key', width: 120, }, { text: gettext('Value'), dataIndex: 'value', flex: 1, }, ], }, ], }).show(); }, showCartridgeMemoryWindow: function (response) { Ext.create('Ext.window.Window', { title: gettext('Cartridge Memory'), modal: true, width: 600, height: 450, layout: 'fit', scrollable: true, items: [ { xtype: 'grid', store: { data: response.result.data, }, columns: [ { text: gettext('ID'), hidden: true, dataIndex: 'id', width: 60, }, { text: gettext('Name'), dataIndex: 'name', flex: 2, }, { text: gettext('Value'), dataIndex: 'value', flex: 1, }, ], }, ], }).show(); }, showVolumeStatisticsWindow: function (response) { let list = []; for (let [key, val] of Object.entries(response.result.data)) { if ( key === 'total-native-capacity' || key === 'total-used-native-capacity' || key === 'lifetime-bytes-read' || key === 'lifetime-bytes-written' || key === 'last-mount-bytes-read' || key === 'last-mount-bytes-written' ) { val = Proxmox.Utils.format_size(val); } list.push({ key: key, value: val }); } Ext.create('Ext.window.Window', { title: gettext('Volume Statistics'), modal: true, width: 600, height: 450, layout: 'fit', scrollable: true, items: [ { xtype: 'grid', store: { data: list, }, columns: [ { text: gettext('Property'), dataIndex: 'key', flex: 1, }, { text: gettext('Value'), dataIndex: 'value', flex: 1, }, ], }, ], }).show(); }, showDriveStatusWindow: function (response) { let list = []; for (let [key, val] of Object.entries(response.result.data)) { if (key === 'manufactured') { val = Proxmox.Utils.render_timestamp(val); } if (key === 'bytes-read' || key === 'bytes-written') { val = Proxmox.Utils.format_size(val); } if (key === 'drive-activity') { val = PBS.Utils.renderDriveActivity(val); } list.push({ key: key, value: val }); } Ext.create('Ext.window.Window', { title: gettext('Status'), modal: true, width: 600, height: 450, layout: 'fit', scrollable: true, items: [ { xtype: 'grid', store: { data: list, }, columns: [ { text: gettext('Property'), dataIndex: 'key', width: 120, }, { text: gettext('Value'), dataIndex: 'value', flex: 1, }, ], }, ], }).show(); }, tapeDriveActivities: { 'no-activity': gettext('No Activity'), cleaning: gettext('Cleaning'), loading: gettext('Loading'), unloading: gettext('Unloading'), other: gettext('Other Activity'), reading: gettext('Reading data'), writing: gettext('Writing data'), locating: gettext('Locating'), rewinding: gettext('Rewinding'), erasing: gettext('Erasing'), formatting: gettext('Formatting'), calibrating: gettext('Calibrating'), 'other-dt': gettext('Other DT Activity'), 'microcode-update': gettext('Updating Microcode'), 'reading-encrypted': gettext('Reading encrypted data'), 'writing-encrypted': gettext('Writing encrypted data'), }, renderDriveActivity: function (value) { if (!value) { return Proxmox.Utils.unknownText; } return PBS.Utils.tapeDriveActivities[value] ?? value; }, renderDriveState: function (value, md, rec) { if (!value) { if (rec?.data?.activity && rec?.data?.activity !== 'no-activity') { return PBS.Utils.renderDriveActivity(rec.data.activity); } return gettext('Idle'); } let icon = ''; if (value.startsWith('UPID')) { let upid = Proxmox.Utils.parse_task_upid(value); md.tdCls = 'pointer'; return `${icon} ${upid.desc}`; } return `${icon} ${value}`; }, /** * Parses maintenance mode property string. * Examples: * "offline,message=foo" -> ["offline", "foo"] * "offline" -> ["offline", null] * "message=foo,offline" -> ["offline", "foo"] * null/undefined -> [null, null] * * @param {string|null} mode - Maintenance mode string to parse. * @return {Array} - Parsed maintenance mode values. */ parseMaintenanceMode: function (mode) { if (!mode) { return [null, null]; } return mode.split(',').reduce( ([m, msg], pair) => { const [key, value] = pair.split('='); if (key === 'message') { return [m, value.replace(/^"(.*)"$/, '$1').replace(/\\"/g, '"')]; } else { return [value ?? key, msg]; } }, [null, null], ); }, renderMaintenance: function (mode, activeTasks) { if (!mode) { return gettext('None'); } let [type, message] = PBS.Utils.parseMaintenanceMode(mode); let extra = ''; if (activeTasks !== undefined) { const conflictingTasks = activeTasks.write + (type === 'offline' || type === 'unmount' ? activeTasks.read : 0); if (conflictingTasks > 0) { extra += '| '; extra += Ext.String.format( gettext('{0} conflicting tasks still active.'), conflictingTasks, ); } else { extra += ''; } } if (message) { extra += ` ("${message}")`; } let modeText = Proxmox.Utils.unknownText; switch (type) { case 'read-only': modeText = gettext('Read-only'); break; case 'offline': modeText = gettext('Offline'); break; case 'unmount': modeText = gettext('Unmounting'); break; case 's3-refresh': modeText = gettext('S3 refresh'); break; } return `${modeText} ${extra}`; }, render_optional_namespace: function (value, metadata, record) { if (!value) { return `- (${gettext('Root')})`; } return Ext.String.htmlEncode(value); }, render_optional_remote: function (value, metadata, record) { if (!value) { return `- (${gettext('Local')})`; } return Ext.String.htmlEncode(value); }, tuningOptions: { 'chunk-order': { __default__: Proxmox.Utils.defaultText + ` (${gettext('Inode')})`, none: gettext('None'), inode: gettext('Inode'), }, 'sync-level': { __default__: Proxmox.Utils.defaultText + ` (${gettext('Filesystem')})`, none: gettext('None'), file: gettext('File'), filesystem: gettext('Filesystem'), }, }, render_tuning_options: function (tuning) { let options = []; let order = tuning['chunk-order']; delete tuning['chunk-order']; order = PBS.Utils.tuningOptions['chunk-order'][order ?? '__default__']; options.push(`${gettext('Chunk Order')}: ${order}`); let sync = tuning['sync-level']; delete tuning['sync-level']; sync = PBS.Utils.tuningOptions['sync-level'][sync ?? '__default__']; options.push(`${gettext('Sync Level')}: ${sync}`); let gc_atime_safety_check = tuning['gc-atime-safety-check']; delete tuning['gc-atime-safety-check']; options.push( `${gettext('GC Access-Time Support Check')}: ${gc_atime_safety_check ?? true}`, ); let gc_atime_cutoff = tuning['gc-atime-cutoff']; delete tuning['gc-atime-cutoff']; let gc_atime_cutoff_rendered = Proxmox.Utils.format_duration_human( (gc_atime_cutoff ?? 1445) * 60, ); options.push(`${gettext('GC Access-Time Cutoff')}: ${gc_atime_cutoff_rendered}`); let gc_cache_capacity = tuning['gc-cache-capacity']; delete tuning['gc-cache-capacity']; options.push( `${gettext('GC cache capacity')}: ${gc_cache_capacity ?? Proxmox.Utils.defaultText}`, ); for (const [k, v] of Object.entries(tuning)) { options.push(`${k}: ${v}`); } return options.join(', '); }, });