Files
mailcow-dockerized/data/web/js/site/backup.js
T

1812 lines
94 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Backup with restic. System > Configuration > Backup (admins): status, actions, settings, jobs and snapshots.
// E-Mail > Restore (admins, domain admins): snapshot, a tree of its domains and mailboxes (also deleted ones), the
// restore modal, the restores. Both pages share the job details modal. Texts from lang.backup. Loaded via $js_minifier.
var mailcowBackup = (function($) {
'use strict';
var POLL_MS = 5000;
var COMPONENTS = ['mails', 'imap_acl', 'aliases', 'groups', 'sender_acl', 'mail_policies', 'retention_rules', 'filters',
'sieve_activate', 'spam', 'bcc', 'syncjobs', 'app_passwords', 'tfa', 'sogo', 'settings', 'password'];
// unchecked for existing targets, preselected when everything to restore was deleted
var RISKY = ['tfa', 'app_passwords', 'retention_rules', 'sieve_activate'];
// overwrite current values; for deleted targets SOGo is a normal item and settings/password are not sent
var OVERWRITE = ['sogo', 'settings', 'password'];
// existing targets: only the mails by default
var EXISTING_DEFAULT = ['mails'];
// items that need the mails, not in snapshots without them (RESTIC_BACKUP_MODE=config, as JobService::MAIL_COMPONENTS)
var MAIL_COMPONENTS = ['mails', 'imap_acl', 'sogo', 'sieve_activate'];
var BACKUP_MODES = ['all', 'data', 'config'];
// domain admin ACL an item needs (as JobService::COMPONENT_ACL)
var COMPONENT_ACL = {groups: 'mailbox_groups', mail_policies: 'mail_policies', retention_rules: 'retention', filters: 'filters',
sieve_activate: 'filters', spam: 'spam_policy', bcc: 'bcc_maps', syncjobs: 'syncjobs', app_passwords: 'app_passwds'};
// the restore of the dovecot container (mails, IMAP ACLs, Sieve) cannot be cancelled while it runs
var DOVECOT_STEPS = ['mails', 'mails_running'];
var SECRETS = ['password', 's3_secret_key', 'rest_password'];
// value of a password field while its secret is stored: shown as dots, never sent
var SECRET_MASK = '************';
var JOB_TYPES = ['backup', 'check', 'prune', 'init'];
var STATE_CLASS = {queued: 'bg-secondary', running: 'bg-info text-black', done: 'bg-success', failed: 'bg-danger', cancelled: 'bg-warning text-black'};
var RESULT_COUNTS = ['added', 'skipped', 'errors'];
// a scheduled backup is overdue this long after the longest gap of the schedule (26 hours for one backup a day)
var STALE_MARGIN = 2 * 3600;
// entries of the schedule (as SettingsService::MAX_SCHEDULE)
var MAX_SCHEDULE = 24;
// weekdays in the order of the form, 0 = Sunday
var WEEKDAYS = [1, 2, 3, 4, 5, 6, 0];
// errors of a job that could not open the repository
var REPO_ERROR = /unable to open|wrong (repository )?password|does not exist|host key|connection refused|no such host|could not resolve|timed? ?out|permission denied|repository/i;
// lang.backup of the page, '' if missing
function L(key) {
var src = (typeof lang !== 'undefined' && lang && lang.backup) ? lang.backup : {};
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
}
function esc(s) {
return escapeHtml(s === null || s === undefined ? '' : String(s));
}
// replaces %s (and %1$s) one after the other, like vsprintf in the PHP alert box
function fill(text) {
var args = Array.prototype.slice.call(arguments, 1), next = 0;
return String(text).replace(/%(?:(\d+)\$)?s/g, function(match, pos) {
var value = pos ? args[pos - 1] : args[next++];
return (value === undefined || value === null) ? '' : String(value);
});
}
function pad(n) {
return (n < 10 ? '0' : '') + n;
}
function now() {
return Math.floor(Date.now() / 1000);
}
function pageLang() {
return document.documentElement.lang || undefined;
}
// the time zone of mailcow (TZ in mailcow.conf), so times match the schedule and the "Restored <time>" folders
function timeZone() {
return (typeof backup_timezone !== 'undefined' && backup_timezone) ? backup_timezone : undefined;
}
function formatWith(ts, options) {
options = $.extend({hourCycle: 'h23'}, options);
try {
return new Intl.DateTimeFormat(pageLang(), $.extend({timeZone: timeZone()}, options)).format(new Date(ts * 1000));
} catch (e) {
return new Date(ts * 1000).toLocaleString(undefined, options);
}
}
// date and time in the language of the page, like mailcow's DATETIME_FORMAT without seconds
function formatTime(ts) {
return ts ? formatWith(ts, {year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'}) : '';
}
// a period: the end without the date when it is the same day
function formatPeriod(from, to) {
var day = {year: 'numeric', month: '2-digit', day: '2-digit'};
if (!to) return formatTime(from);
return formatTime(from) + ' – ' + (formatWith(from, day) === formatWith(to, day) ? formatWith(to, {hour: '2-digit', minute: '2-digit'}) : formatTime(to));
}
// without the year: status line
function formatShort(ts) {
return ts ? formatWith(ts, {month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'}) : '';
}
// "vor 9 Std." / "9 hr. ago"
function relative(ts) {
var diff = ts - now(), abs = Math.abs(diff), unit = 'second', value = diff;
if (abs >= 86400) { unit = 'day'; value = Math.round(diff / 86400); }
else if (abs >= 3600) { unit = 'hour'; value = Math.round(diff / 3600); }
else if (abs >= 60) { unit = 'minute'; value = Math.round(diff / 60); }
try {
return new Intl.RelativeTimeFormat(pageLang(), {numeric: 'auto', style: 'short'}).format(value, unit);
} catch (e) {
return abs < 120 ? fill(L('seconds_ago'), abs) : fill(L('minutes_ago'), Math.round(abs / 60));
}
}
// name of the folder the dovecot runner restores into: "Restored YYYY-MM-DD HH:MM", snapshot time in the time zone of mailcow
function folderName(ts) {
var d = new Date(ts * 1000), parts = {};
try {
new Intl.DateTimeFormat('en-GB', {timeZone: timeZone(), hourCycle: 'h23',
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit'}).formatToParts(d).forEach(function(part) {
parts[part.type] = part.value;
});
return 'Restored ' + parts.year + '-' + parts.month + '-' + parts.day + ' ' + parts.hour + ':' + parts.minute;
} catch (e) {
return 'Restored ' + d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes());
}
}
function formatSize(bytes) {
if (bytes === null || bytes === undefined || bytes === '' || isNaN(bytes)) return '';
bytes = Number(bytes);
if (Math.abs(bytes) < 1024) return bytes + ' B';
var units = ['KiB', 'MiB', 'GiB', 'TiB', 'PiB'], i = -1;
do {
bytes /= 1024;
i++;
} while (Math.abs(bytes) >= 1024 && i < units.length - 1);
try {
return bytes.toLocaleString(pageLang(), {minimumFractionDigits: 1, maximumFractionDigits: 1}) + ' ' + units[i];
} catch (e) {
return bytes.toFixed(1) + ' ' + units[i];
}
}
function formatDuration(seconds) {
if (seconds === null || seconds === undefined || seconds < 0) return '';
seconds = Math.round(seconds);
var h = Math.floor(seconds / 3600), m = Math.floor(seconds % 3600 / 60), s = seconds % 60;
return (h ? h + ' h ' : '') + (h || m ? m + ' min ' : '') + (h ? '' : s + ' s');
}
function firstLine(text, max) {
text = String(text || '').split('\n')[0];
return text.length > max ? text.substr(0, max) + '…' : text;
}
function urlHost(url) {
try {
return new URL(url).host;
} catch (e) {
return url || '';
}
}
function typeLabel(type) {
return L('type_' + type) || type;
}
function componentLabel(component) {
return L('component_' + component) || component;
}
function isActive(job) {
return job.state === 'queued' || job.state === 'running';
}
function isCancellable(job) {
// domain admins cancel only restores domain admins requested (JobService::cancel)
if (typeof role !== 'undefined' && role === 'domainadmin' && (job.params || {}).requested_role !== 'domainadmin') return false;
return job.state === 'queued' || (job.state === 'running' && !(job.type === 'restore' && DOVECOT_STEPS.indexOf(job.step) !== -1));
}
function stepText(job) {
return job.step ? (L('step_' + job.step) || job.step) : '';
}
function stateBadge(job) {
var html = '<span class="badge ' + (STATE_CLASS[job.state] || 'bg-secondary') + '">' + esc(L('state_' + job.state) || job.state) + '</span>';
if (job.state === 'running' && job.step) html += ' <span class="small text-muted">' + esc(stepText(job)) + '</span>';
return html;
}
// domain admins see "Administrator" for restores an admin requested
function requestedBy(job) {
if (job.requested_by === 'schedule') return L('requested_schedule');
if (typeof role !== 'undefined' && role === 'domainadmin' && (job.params || {}).requested_role === 'admin') return L('requested_admin');
return job.requested_by;
}
function jobDuration(job) {
return job.started ? formatDuration((job.finished || now()) - job.started) : '';
}
// label of a setting in messages: the field name of the form
function settingLabel(name) {
if (/^keep_/.test(name)) return L('keep') + ': ' + L(name);
return L(name) || name;
}
// json_api.php answers with all messages of the session since the last page load: only the new ones are shown
var seenMessages = 0;
function messageText(message) {
var msg = $.isArray(message.msg) ? message.msg.slice() : [message.msg];
var key = msg.shift();
var texts = (message.type === 'success') ? lang_success : lang_danger;
var text = (texts && texts[key] !== undefined) ? texts[key] : String(key);
// singular and plural of the number of mailboxes
if (key === 'backup_restore_added' && L('restore_added_one')) text = (msg[1] == 1) ? L('restore_added_one') : L('restore_added_many');
msg = $.map(msg, function(param) {
if (key === 'backup_job_added' || key === 'backup_job_active') return L('type_' + param) || param;
if (key === 'backup_component_denied' || key === 'backup_components_invalid' || key === 'backup_component_requires') return param ? componentLabel(param) : param;
if (key === 'backup_setting_invalid') return settingLabel(param);
return param;
});
return fill.apply(null, [text].concat(msg));
}
function showMessages(res) {
var list = $.isArray(res) ? res : (res ? [res] : []);
var fresh = (list.length > seenMessages) ? list.slice(seenMessages) : list;
seenMessages = list.length;
var ok = fresh.length > 0;
$.each(fresh, function(i, message) {
if (!message || typeof message !== 'object') return;
var type = (message.type === 'success' || message.type === 'warning') ? message.type : 'danger';
if (type !== 'success') ok = false;
mailcow_alert_box(esc(messageText(message)), type);
});
return {ok: ok, messages: fresh};
}
// POST with a JSON body (the shape json_api.php expects for add, edit and delete); done(ok, messages)
function post(path, body, done, $button) {
if ($button) $button.prop('disabled', true);
$.ajax({
type: 'POST',
url: '/api/v1/' + path,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(body),
success: function(res) {
var result = showMessages(res);
if (done) done(result.ok, result.messages);
},
error: function() {
mailcow_alert_box(esc(L('load_error')), 'danger');
if (done) done(false, []);
},
complete: function() {
if ($button) $button.prop('disabled', false);
}
});
}
function getJson(path, done) {
return $.ajax({type: 'GET', url: '/api/v1/get/' + path, dataType: 'json', cache: false, success: done});
}
// a failed list load is reported once per list (the lists reload while jobs run)
var reported = {};
function tableError(id) {
return function(xhr, status) {
// a reload cancels the request before it
if (status === 'abort' || (xhr && xhr.readyState === 0)) return;
if (!reported[id]) mailcow_alert_box(esc(L('load_error')), 'danger');
reported[id] = true;
};
}
function reloadTable(selector) {
if ($.fn.DataTable.isDataTable(selector)) $(selector).DataTable().ajax.reload(null, false);
}
// calls back each time the element becomes visible (tab or card opened)
function onVisible(selector, callback) {
var element = document.querySelector(selector);
if (element === null || typeof IntersectionObserver === 'undefined') return;
new IntersectionObserver(function(entries) {
entries.forEach(function(entry) {
if (entry.intersectionRatio > 0) callback(element);
});
}).observe(element);
}
// small lists: table and pages only, 10 rows
function tableOptions(options, empty) {
return $.extend({
responsive: true,
processing: false,
serverSide: false,
stateSave: false,
pageLength: 10,
dom: "tr<'d-flex justify-content-end mt-2'p>",
language: empty ? $.extend({}, lang_datatables, {emptyTable: empty}) : lang_datatables
}, options);
}
// mailcow's empty first column: the responsive control (+) does not cover a value
function placeholderColumn() {
return {title: '', data: null, defaultContent: '', orderable: false, searchable: false, responsivePriority: 1};
}
function actionColumn() {
return {title: '', data: 'action', orderable: false, searchable: false, defaultContent: '', className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right', responsivePriority: 2};
}
// a DataTable with new data (local data, not ajax)
function setTable(selector, options, data) {
if ($.fn.DataTable.isDataTable(selector)) {
$(selector).DataTable().clear().rows.add(data).draw(false);
} else {
$(selector).DataTable($.extend({data: data}, options));
}
$(selector).DataTable().columns.adjust().responsive.recalc();
}
// DataTables: the formatted value for display, the raw one for sorting
function renderTime(data, type) {
return (type === 'display' || type === 'filter') ? esc(formatTime(data)) : data;
}
function renderSize(data, type) {
return (type === 'display' || type === 'filter') ? esc(formatSize(data)) : data;
}
// ---- restore jobs: what, where, result ----
function restoreMailboxes(job) {
var params = job.params || {};
return $.isArray(params.mailboxes) ? params.mailboxes : [];
}
// what a restore writes into; short: a single mailbox without the word "Mailbox" (list column)
function restoreTarget(job, short) {
var params = job.params || {}, mailboxes = restoreMailboxes(job);
if (params.scope === 'domain') return fill(L('restore_target_domain'), params.domain || job.domain);
if (!mailboxes.length) return job.domain || '';
if (mailboxes.length === 1) return short ? mailboxes[0] : fill(L('restore_target_mailbox'), mailboxes[0]);
return fill(L('restore_target_mailboxes'), mailboxes.length, mailboxes.slice(0, 3).join(', ') + (mailboxes.length > 3 ? ', …' : ''));
}
function restoreSnapshot(job) {
var params = job.params || {}, result = job.result || {}, snapshot = result.snapshot || {};
var id = String(snapshot.short_id || params.snapshot || '').substr(0, 8);
var time = params.snapshot_time ? formatTime(params.snapshot_time) : (snapshot.time || '');
return time ? time + ' · ' + id : id;
}
// per mailbox: {component: {added, skipped, errors, ...}, error: "..."}
function restoreResults(job) {
var result = job.result || {};
return (result.mailboxes && typeof result.mailboxes === 'object') ? result.mailboxes : {};
}
// results of the domain level: the domain itself (a deleted domain created from the backup) and its aliases
// (scope domain), each {added, skipped, errors, ...}
function restoreDomainResults(job) {
var result = job.result || {};
return $.grep([['domain', result.domain], ['aliases', result.aliases]], function(part) {
return part[1] && typeof part[1] === 'object' && !$.isArray(part[1]);
});
}
function restoreTotals(job) {
var totals = {added: 0, skipped: 0, errors: 0, any: false};
$.each(restoreDomainResults(job), function(i, part) {
totals.any = true;
totals.added += parseInt(part[1].added, 10) || 0;
totals.skipped += parseInt(part[1].skipped, 10) || 0;
totals.errors += $.isArray(part[1].errors) ? part[1].errors.length : 0;
});
$.each(restoreResults(job), function(user, entry) {
if (!entry || typeof entry !== 'object') return;
totals.any = true;
if (entry.error) totals.errors++;
$.each(entry, function(key, value) {
if (!value || typeof value !== 'object' || $.isArray(value)) return;
totals.added += parseInt(value.added, 10) || 0;
totals.skipped += parseInt(value.skipped, 10) || 0;
totals.errors += $.isArray(value.errors) ? value.errors.length : 0;
});
});
return totals;
}
function restoreSummary(job) {
var totals = restoreTotals(job), html = '';
if (totals.any) html = esc(fill(L('result_summary'), totals.added, totals.skipped));
else if (!job.error) html = '<span class="text-muted">' + esc(L(isActive(job) ? 'result_none' : 'result_empty')) + '</span>';
if (totals.errors) html += ' <span class="text-danger">' + esc(fill(L('result_errors'), totals.errors)) + '</span>';
return html;
}
function jobComponents(job) {
return (job.params && $.isArray(job.params.components)) ? job.params.components : [];
}
function componentsText(job) {
return $.map(jobComponents(job), function(c) { return componentLabel(c); }).join(', ');
}
// the items the requester could choose: all for admins, those of his ACL (stored with the job) for domain admins
function choosable(job) {
var params = job.params || {}, acl = params.acl;
return $.grep(COMPONENTS, function(c) {
return params.requested_role !== 'domainadmin' || !acl || !COMPONENT_ACL[c] || acl[COMPONENT_ACL[c]] == 1;
});
}
// "all items", or one or two labels and "+ n more"
function componentsShort(job) {
var components = jobComponents(job);
if (choosable(job).every(function(c) { return components.indexOf(c) !== -1; })) return L('all_items');
if (components.length <= 2) return $.map(components, componentLabel).join(', ');
return componentLabel(components[0]) + ' ' + fill(L('more_items'), components.length - 1);
}
// list cell and details: the short form, the full list as tooltip
function componentsCell(job, cls) {
if (!jobComponents(job).length) return '';
return '<span class="' + (cls === undefined ? 'small text-muted' : cls) + '" title="' + esc(componentsText(job)) + '">' + esc(componentsShort(job)) + '</span>';
}
function extraValue(value) {
if ($.isArray(value)) return value.join(', ');
if (value && typeof value === 'object') {
return $.map(value, function(v, k) { return k + ': ' + (typeof v === 'object' ? JSON.stringify(v) : v); }).join(', ');
}
return String(value);
}
function errorList(errors, cls) {
if (!errors || !errors.length) return '';
return '<ul class="mb-0 ps-3 small text-break ' + (cls === undefined ? 'text-danger' : cls) + '">' +
$.map(errors, function(error) { return '<li>' + esc(error) + '</li>'; }).join('') + '</ul>';
}
// a further value of an item result, shown after its name: translated, without the empty ones and zeros
function resultExtra(key, value) {
// created: the mailbox row counts it as added
if (key === 'created' || value === null || value === '' || value === 0 || value === '0' || ($.isArray(value) && !value.length)) return null;
if (key === 'note') return esc(L('result_note_' + value) || String(value));
return esc((L('result_' + key) || key) + ': ' + extraValue(value));
}
// the item results of a mailbox in their order: the creation of a deleted mailbox first, then the items
function entryParts(entry) {
var keys = $.grep(['mailbox'].concat(COMPONENTS), function(c) { return entry[c] !== undefined; });
$.each(entry, function(key) {
if (key !== 'error' && keys.indexOf(key) === -1) keys.push(key);
});
return $.map(keys, function(key) { return [[key, entry[key]]]; });
}
function partCounts(parts) {
var counts = {added: 0, skipped: 0, errors: 0};
$.each(parts, function(i, part) {
var value = part[1];
if (!value || typeof value !== 'object' || $.isArray(value)) return;
counts.added += parseInt(value.added, 10) || 0;
counts.skipped += parseInt(value.skipped, 10) || 0;
counts.errors += $.isArray(value.errors) ? value.errors.length : 0;
});
return counts;
}
// the items of an entry: a row for each with something to tell, its errors right below it; the others in one line
function resultBody(parts) {
var rows = '', unchanged = [];
$.each(parts, function(i, part) {
var key = part[0], value = part[1];
if (!value || typeof value !== 'object' || $.isArray(value)) {
rows += '<tr><td>' + esc(L('result_' + key) || key) + '</td><td colspan="2" class="text-break">' + esc(extraValue(value)) + '</td></tr>';
return;
}
var errors = $.isArray(value.errors) ? value.errors : [];
var extras = $.map(value, function(v, k) { return RESULT_COUNTS.indexOf(k) !== -1 ? null : resultExtra(k, v); });
if (!(parseInt(value.added, 10) || 0) && !(parseInt(value.skipped, 10) || 0) && !errors.length && !extras.length) {
unchanged.push(componentLabel(key));
return;
}
rows += '<tr><td class="text-break">' + esc(componentLabel(key)) + (extras.length ? '<span class="text-muted"> · ' + extras.join(' · ') + '</span>' : '') + '</td><td class="num">' + esc(value.added === undefined ? '' : value.added) + '</td>' +
'<td class="num">' + esc(value.skipped === undefined ? '' : value.skipped) + '</td></tr>';
$.each(errors, function(j, text) {
rows += '<tr><td colspan="3" class="text-danger text-break ps-3"><i class="bi bi-arrow-return-right me-1"></i>' + esc(text) + '</td></tr>';
});
});
var html = '';
if (rows) {
html += '<table class="table table-sm table-borderless mb-0"><thead><tr class="text-muted"><th class="fw-normal">' + esc(L('component')) + '</th>' +
'<th class="fw-normal num">' + esc(L('added')) + '</th><th class="fw-normal num">' + esc(L('skipped')) + '</th></tr></thead><tbody>' + rows + '</tbody></table>';
}
if (unchanged.length) html += '<div class="text-muted mt-1">' + esc(fill(L('result_unchanged'), unchanged.join(', '))) + '</div>';
return html;
}
// one entry to open: state, name and counts in its line; entries with errors come open
function resultEntry(entry) {
var counts = partCounts(entry.parts), line, icon;
if (entry.missing) {
line = '<span class="text-muted">' + esc(L(entry.missing)) + '</span>';
icon = 'bi-hourglass-split text-muted';
} else if (entry.failed) {
line = '<span class="text-danger text-break">' + esc(entry.error || fill(L('result_errors'), counts.errors)) + '</span>';
icon = 'bi-exclamation-triangle-fill text-danger';
} else {
line = '<span class="text-muted">' + esc(fill(L('result_summary'), counts.added, counts.skipped)) + '</span>';
icon = 'bi-check-circle-fill text-success';
}
return '<details' + (entry.failed ? ' open' : '') + '><summary class="d-flex align-items-center gap-2 px-2 py-1">' +
'<i class="bi bi-chevron-right small"></i><i class="bi ' + icon + '"></i><span class="fw-semibold text-break">' + esc(entry.title) + '</span>' +
'<span class="ms-auto small text-end">' + line + '</span></summary>' +
(entry.missing ? '' : '<div class="small px-2 pb-2 backup-result-body">' + resultBody(entry.parts) + '</div>') + '</details>';
}
function restoreResultList(job) {
if (!restoreTotals(job).any) return '';
var results = restoreResults(job), users = restoreMailboxes(job).slice(), entries = [], domainParts = restoreDomainResults(job);
$.each(results, function(user) {
if (users.indexOf(user) === -1) users.push(user);
});
if (domainParts.length) {
entries.push({title: fill(L('restore_target_domain_short'), (job.params || {}).domain || job.domain), parts: domainParts, error: ''});
}
$.each(users, function(i, user) {
var entry = results[user], known = !!entry && typeof entry === 'object';
entries.push({title: user, parts: known ? entryParts(entry) : [], error: known ? (entry.error || '') : '',
missing: known ? '' : (isActive(job) ? 'result_none' : 'result_empty')});
});
var failed = 0;
$.each(entries, function(i, entry) {
entry.failed = !entry.missing && (!!entry.error || partCounts(entry.parts).errors > 0);
entry.order = (entry.failed ? 0 : 1) * 100000 + i;
if (entry.failed && entry.title.indexOf('@') !== -1) failed++;
});
// those with errors first
entries.sort(function(a, b) { return a.order - b.order; });
var totals = restoreTotals(job);
var overview = [fill(L('result_mailboxes'), users.length)].concat(failed ? [fill(L('result_failed'), failed)] : [])
.concat([fill(L('result_summary'), totals.added, totals.skipped)]);
return '<h5 class="mt-3 fs-6 fw-semibold">' + esc(L('result')) + '</h5>' +
'<div class="small text-muted mb-2">' + esc(overview.join(' · ')) + '</div>' +
'<div class="rounded mb-3 backup-result">' + $.map(entries, resultEntry).join('') + '</div>';
}
// ---- other jobs ----
function jobSummary(job) {
if (job.type === 'restore') return restoreSummary(job);
var result = job.result || {}, parts = [];
if (job.type === 'backup' && result.summary) {
parts.push(fill(L('summary_backup'), String(result.snapshot || '').substr(0, 8), formatSize(result.summary.data_added)));
}
if (job.type === 'check' && result.subset) parts.push(fill(L('summary_check'), result.subset));
if (job.type === 'init' && job.state === 'done') parts.push(result.created ? L('summary_created') : L('connection_ok'));
if ((job.type === 'prune' || job.type === 'init') && result.snapshots !== undefined) parts.push(fill(L('summary_snapshots'), result.snapshots));
var html = esc(parts.join(' · '));
if ($.isArray(result.warnings) && result.warnings.length) {
html += '<div class="small"><i class="bi bi-exclamation-triangle-fill text-warning"></i> ' + esc(L('warnings') + ': ' + result.warnings.length) + '</div>';
}
return html;
}
function errorCell(job) {
return job.error ? '<div class="small text-danger" title="' + esc(job.error) + '">' + esc(firstLine(job.error, 160)) + '</div>' : '';
}
function detailsButton(job) {
return '<a href="#" class="btn btn-xs btn-xs-lg btn-secondary backup-job-details" data-job="' + esc(job.id) + '"><i class="bi bi-card-text"></i> ' + esc(L('details')) + '</a>';
}
function cancelButton(job) {
return '<a href="#" class="btn btn-xs btn-xs-lg btn-danger backup-job-cancel-row" data-job="' + esc(job.id) + '" data-state="' + esc(job.state) + '"><i class="bi bi-x-circle"></i> ' + esc(L('cancel')) + '</a>';
}
function jobActions(job) {
return '<div class="btn-group">' + detailsButton(job) + (isCancellable(job) ? cancelButton(job) : '') + '</div>';
}
// ---- job details modal ----
var jobId = null;
var jobTimer = null;
function dlRow(label, html) {
return '<dt class="col-sm-3">' + esc(label) + '</dt><dd class="col-sm-9 text-break">' + html + '</dd>';
}
function jobDetails(job) {
var params = job.params || {}, html = '<dl class="row mb-3">';
html += dlRow(L('state'), stateBadge(job) + (job.requested_by ? ' <span class="small text-muted ms-1">' + esc(fill(L('requested_by_short'), requestedBy(job))) + '</span>' : ''));
html += dlRow(L('time'), esc(job.started ? formatPeriod(job.started, job.finished) + ' (' + jobDuration(job) + ')' : formatTime(job.created)));
if (job.type === 'restore') {
html += dlRow(L('snapshot'), esc(restoreSnapshot(job)));
html += dlRow(L('scope'), esc(restoreTarget(job)));
html += dlRow(L('components'), componentsCell(job, ''));
if ($.isArray(params.components) && params.components.indexOf('mails') !== -1) {
html += dlRow(L('mail_target'), esc(params.mail_target === 'original' ? L('mail_target_original_short')
: (params.snapshot_time ? fill(L('mail_target_folder_named'), folderName(params.snapshot_time)) : L('mail_target_folder_short'))));
}
var created = [];
if (params.create_domain == 1) created.push(fill(L('restore_target_domain_short'), params.domain || job.domain));
if ($.isArray(params.create_mailboxes) && params.create_mailboxes.length) created.push(params.create_mailboxes.join(', '));
if (created.length) html += dlRow(L('created_from_backup'), esc(created.join(', ')));
} else {
var summary = jobSummary(job);
if (summary) html += dlRow(L('result'), summary);
}
html += '</dl>';
if (job.error) {
html += '<div class="alert alert-danger"><b>' + esc(L('error')) + '</b><pre class="mb-0 mt-1 p-0 small text-wrap text-break text-reset bg-transparent border-0">' + esc(job.error) + '</pre></div>';
}
var result = job.result || {};
if (job.type === 'restore') {
html += restoreResultList(job);
} else {
if ($.isArray(result.warnings) && result.warnings.length) {
html += '<div class="alert alert-warning"><b>' + esc(L('warnings')) + '</b>' + errorList(result.warnings, '') + '</div>';
}
if (job.result) {
html += '<details class="mb-3"><summary>' + esc(L('result')) + '</summary><pre class="small rounded p-2 mt-2 text-wrap text-break backup-pre">' +
esc(JSON.stringify(job.result, null, 2)) + '</pre></details>';
}
}
html += '<h5 class="mt-3 fs-6 fw-semibold">' + esc(L('log')) + '</h5>' +
'<pre class="small rounded p-2 mb-0 backup-pre backup-job-log" style="max-height:24rem;overflow:auto;white-space:pre-wrap;word-break:break-word">' +
(job.log ? esc(job.log) : '<span class="text-muted">' + esc(L('log_empty')) + '</span>') + '</pre>';
return html;
}
function loadJob() {
clearTimeout(jobTimer);
var id = jobId, $modal = $('#backupJobModal');
if (id === null) return;
getJson('backup-job/' + encodeURIComponent(id), function(job) {
if (id !== jobId) return;
if (!job || !job.id) {
$modal.find('.backup-job-body').html('<p class="text-danger">' + esc(L('load_error')) + '</p>');
return;
}
var $log = $modal.find('.backup-job-log'), atEnd = !$log.length || ($log[0].scrollHeight - $log.scrollTop() - $log.outerHeight() < 20);
$modal.find('.modal-title').text(fill(L('job'), job.id) + ' · ' + typeLabel(job.type));
$modal.find('.backup-job-body').html(jobDetails(job));
$modal.find('.backup-job-cancel').toggleClass('d-none', !isCancellable(job)).data('state', job.state);
$modal.find('.backup-job-refresh').toggleClass('d-none', !isActive(job));
if (atEnd) {
$log = $modal.find('.backup-job-log');
$log.scrollTop($log[0].scrollHeight);
}
if (isActive(job)) jobTimer = setTimeout(loadJob, POLL_MS);
}).fail(function() {
$modal.find('.backup-job-body').html('<p class="text-danger">' + esc(L('load_error')) + '</p>');
});
}
function showJob(id) {
jobId = parseInt(id, 10);
var $modal = $('#backupJobModal');
$modal.find('.modal-title').text(fill(L('job'), jobId));
$modal.find('.backup-job-body').html('<p class="text-muted">' + esc(lang_footer.loading) + '</p>');
$modal.find('.backup-job-cancel, .backup-job-refresh').addClass('d-none');
$modal.modal('show');
loadJob();
}
function cancelJob(id, state, $button) {
if (state === 'running' && !confirm(L('cancel_running_confirm'))) return;
post('delete/backup-job', [parseInt(id, 10)], function() {
refreshAfterChange();
if (jobId !== null) loadJob();
}, $button);
}
// after a job was queued or cancelled (on the admin page the status reloads the job list)
function refreshAfterChange() {
reloadTable('#backup_restores_table');
if ($('#backup_status').length) loadStatus();
}
// ---- System > Configuration > Backup: status ----
var statusTimer = null;
var lastActive = null;
var lastStatus = null;
var settingsCache = null;
function alertLine(type, icon, html, buttons) {
return '<div class="alert alert-' + type + ' py-2 px-3 mb-2 small d-flex flex-wrap align-items-center gap-2">' +
'<span class="flex-grow-1">' + icon + html + '</span>' + (buttons ? '<span class="btn-group">' + buttons + '</span>' : '') + '</div>';
}
// the target in one line: "SFTP backup.example.org"
function targetText(target) {
var s = settingsCache || {};
switch (target) {
case 's3': return 'S3 ' + [s.s3_bucket, urlHost(s.s3_endpoint)].filter(Boolean).join(' @ ');
case 'sftp': return 'SFTP ' + (s.sftp_host || '');
case 'rest': return 'REST ' + urlHost(s.rest_url);
case 'local': return L('target_local') + (s.local_path ? ': ' + s.local_path : '');
}
return '';
}
function renderStatus(s) {
var jobs = s.jobs || {}, active = s.active || [], alerts = '', t = now();
lastStatus = s;
// the newest finished job of the repository: when it could not open the repository, nothing is green
var latest = null;
$.each(JOB_TYPES, function(i, type) {
var job = jobs[type];
if (job && (job.state === 'done' || job.state === 'failed') && (!latest || (job.finished || 0) > (latest.finished || 0))) latest = job;
});
var repoFailed = !!(latest && latest.state === 'failed' && REPO_ERROR.test(latest.error || ''));
var failed = $.grep(JOB_TYPES, function(type) { return jobs[type] && jobs[type].state === 'failed'; });
var stale = !!(s.enabled && s.last_success && t - s.last_success > (s.schedule_gap || 86400) + STALE_MARGIN);
var healthy = s.runner_ok && s.configured && s.initialized && s.last_success && !repoFailed && !failed.length && !stale && !(s.secrets_error && s.secrets_error !== '0') &&
!(s.mode && BACKUP_MODES.indexOf(s.mode) === -1);
var icon = healthy ? '<i class="bi bi-check-circle-fill text-success"></i> ' : (s.configured ? '<i class="bi bi-exclamation-triangle-fill text-warning"></i> '
: '<i class="bi bi-dash-circle text-muted"></i> ');
var line = s.last_success ? fill(L('status_last'), formatShort(s.last_success), relative(s.last_success)) : L('status_never');
line += ' · ' + (s.next_run ? fill(L('status_next'), formatShort(s.next_run)) : L('status_schedule_off'));
var facts = [];
if (s.repo_size !== null && s.repo_size !== undefined) facts.push(formatSize(s.repo_size));
if (s.snapshots) facts.push(fill(L(s.snapshots == 1 ? 'status_snapshot' : 'status_snapshots'), s.snapshots));
if (s.configured && s.target) facts.push(targetText(s.target));
if (s.mode === 'data' || s.mode === 'config') facts.push(L('mode_' + s.mode));
$('#backup_status .backup-status-summary').html('<div class="fw-semibold">' + icon + esc(line) + '</div>' +
(facts.length ? '<div class="small text-muted">' + esc(facts.join(' · ')) + '</div>' : ''));
if (!s.configured) {
alerts += '<div class="alert alert-info py-2 px-3 mb-2 small"><b>' + esc(L('first_run')) + '</b><ol class="mb-0 ps-3">' +
$.map([1, 2, 3, 4, 5], function(n) { return '<li>' + esc(L('first_run_' + n)) + '</li>'; }).join('') + '</ol></div>';
}
if (!s.runner_ok) {
alerts += alertLine('danger', '<i class="bi bi-x-circle-fill"></i> ', esc(s.runner_seen ? fill(L('runner_missing'), formatTime(s.runner_seen)) : L('runner_never')));
}
var secretsError = !!(s.secrets_error && s.secrets_error !== '0');
if (secretsError) alerts += alertLine('danger', '<i class="bi bi-key-fill"></i> ', esc(L('secrets_error')));
if (s.mode && BACKUP_MODES.indexOf(s.mode) === -1) alerts += alertLine('danger', '<i class="bi bi-x-circle-fill"></i> ', esc(fill(L('mode_invalid'), s.mode)));
if (s.configured && !s.password_confirmed) alerts += alertLine('warning', '<i class="bi bi-exclamation-triangle-fill"></i> ', esc(L('password_unconfirmed')));
var testing = active.some(function(job) { return job.type === 'init'; });
if (s.configured && !s.initialized && !testing && !repoFailed) {
alerts += alertLine('warning', '<i class="bi bi-exclamation-triangle-fill"></i> ', esc(L('repo_not_tested')));
}
if (stale) alerts += alertLine('warning', '<i class="bi bi-exclamation-triangle-fill"></i> ', esc(fill(L('status_stale'), relative(s.last_success))));
$.each(active, function(i, job) {
var what = job.state === 'running' ? (stepText(job) || L('state_running')) + (job.started ? ', ' + jobDuration(job) : '') : L('state_queued');
alerts += alertLine('info', (job.state === 'running' ? '<span class="spinner-border spinner-border-sm me-1" role="status"></span>' : '<i class="bi bi-hourglass-split"></i> '),
esc(fill(L('active_line'), typeLabel(job.type), job.id, what)) + (job.type === 'restore' ? ' <span class="text-muted">' + esc(restoreTarget(job, true)) + '</span>' : ''),
detailsButton(job) + (isCancellable(job) ? cancelButton(job) : ''));
});
$.each(failed, function(i, type) {
var job = jobs[type];
alerts += alertLine('danger', '<i class="bi bi-x-circle-fill"></i> ', esc(fill(L('failed_line'), typeLabel(type), job.id)) +
(job.error ? ': ' + esc(firstLine(job.error, 200)) : ''), detailsButton(job));
});
$('#backup_status_alerts').html(alerts);
// backups and maintenance once the repository is set up and tested; one job of each type at a time
var ready = !!(s.configured && s.initialized);
var busy = function(type) { return active.some(function(job) { return job.type === type; }); };
$('#backup_status .backup-job-request').each(function() {
var type = $(this).data('type'), off = (type === 'init' ? !s.configured : !ready) || busy(type);
if ($(this).is('a')) $(this).toggleClass('disabled', off).attr('aria-disabled', off ? 'true' : null);
else $(this).prop('disabled', off);
});
$('#backup_status .backup-maintenance').prop('disabled', !ready);
}
function loadStatus() {
clearTimeout(statusTimer);
getJson('backup-status', function(status) {
status = status || {};
renderStatus(status);
var active = (status.active || []).length;
if (active || (lastActive !== null && active !== lastActive)) loadJobs();
if (lastActive && !active) {
loadSnapshotList();
loadRunnerFields();
}
lastActive = active;
if (active) statusTimer = setTimeout(loadStatus, POLL_MS);
}).fail(function() {
$('#backup_status .backup-status-summary').html('<span class="text-danger">' + esc(L('load_error')) + '</span>');
if (lastActive) statusTimer = setTimeout(loadStatus, POLL_MS);
});
}
function requestJob($button) {
var type = $button.data('type');
if ($button.hasClass('disabled') || $button.prop('disabled')) return;
if (type === 'prune' && !confirm(L('prune_confirm'))) return;
post('add/backup-job', {type: type}, function() {
refreshAfterChange();
}, $button.is('button') ? $button : null);
}
// ---- System > Configuration > Backup: settings ----
function showTarget() {
var target = $('#backup_target').val();
$('#backup_settings .backup-target').each(function() {
$(this).toggleClass('d-none', $(this).data('target') !== target);
});
}
function toggleFts() {
var $index = $('#backup_settings input[name="include_index"]');
$('#backup_settings input[name="include_fts"]').prop('disabled', !$index.is(':checked'));
}
// values the runner writes: SSH key, host key; stored secrets as dots (a typed value stays)
function fillRunnerFields(s) {
var $form = $('#backup_settings');
settingsCache = $.extend(settingsCache || {}, s);
$.each(SECRETS, function(i, name) {
var set = s[name + '_set'] == 1, $input = $form.find('input[name="' + name + '"]');
$input.attr('placeholder', set ? L('secret_keep') : '').data('stored', set);
if ($input.data('masked') || $input.val() === '') $input.val(set ? SECRET_MASK : '').data('masked', set);
});
showScope(s.state_backup_mode || '');
$('#backup_sftp_public_key').val(s.sftp_public_key || '');
$form.find('.backup-key-missing').toggleClass('d-none', !!s.sftp_public_key);
$form.find('.backup-host-key').toggleClass('d-none', !s.sftp_host_key).find('code').text(s.sftp_host_key || '');
if (lastStatus) renderStatus(lastStatus);
}
// RESTIC_BACKUP_MODE as the runner reported it: what the backups hold and what not
function showScope(mode) {
var $scope = $('#backup_settings .backup-scope'), known = BACKUP_MODES.indexOf(mode) !== -1;
$scope.find('.backup-scope-title').toggleClass('text-danger', !!mode && !known)
.text(!mode ? L('scope_unknown') : (known ? L('scope_' + mode) : fill(L('mode_invalid'), mode)));
$scope.find('.backup-scope-var').text(known ? 'RESTIC_BACKUP_MODE=' + mode : '');
$.each(['in', 'out'], function(i, part) {
var text = known ? L('scope_' + mode + '_' + part) : '';
$scope.find('.backup-scope-' + part).toggleClass('d-none', !text).find('span').text(text);
});
}
function fillSettings(s) {
var $form = $('#backup_settings');
$form.find('input[name]:not([type="hidden"]), select[name]').each(function() {
var $el = $(this), name = $el.attr('name'), value = s[name];
if (SECRETS.indexOf(name) !== -1) {
$el.val('').attr('type', 'password').data('masked', false);
} else if ($el.is(':checkbox')) {
$el.prop('checked', value == 1);
} else if ($el.is('select')) {
$el.selectpicker('val', (value === null || value === undefined) ? '' : String(value));
} else if (name === 'max_unused') {
$el.val(parseInt(value, 10) || 0);
} else {
$el.val((value === null || value === undefined) ? '' : value);
}
});
// the confirmation is for the stored password: typing a new one asks for it again
$form.find('input[name="password_confirmed"]').data('stored', s.password_confirmed == 1);
$form.find('.backup-password-confirm').removeClass('text-danger');
$form.find('.backup-password-change').addClass('d-none');
$form.find('.inputMissingAttr').removeClass('inputMissingAttr');
drawSchedule($.isArray(s.schedule) ? s.schedule : []);
fillRunnerFields(s);
showTarget();
toggleFts();
}
// schedule: entries of weekdays and a time ("Mo, We – 12:00"), the row above the list adds one
function scheduleText(entry) {
var days = (entry.days.length === 7) ? L('schedule_daily') : $.map(WEEKDAYS, function(day) {
return (entry.days.indexOf(day) !== -1) ? L('weekday_' + day).slice(0, 2) : null;
}).join(', ');
return fill(L('schedule_entry'), days, entry.time);
}
function scheduleEntries() {
return $('#backup_settings .backup-schedule-list li').map(function() { return $(this).data('entry'); }).get();
}
function drawSchedule(entries) {
var $form = $('#backup_settings'), $list = $form.find('.backup-schedule-list').empty(), perDay = {};
$.each(entries, function(i, entry) {
$('<li class="mb-1"><span></span><button type="button" class="btn btn-link btn-sm p-0 ms-2 align-baseline text-reset backup-schedule-remove" title="' +
esc(L('schedule_remove')) + '" aria-label="' + esc(L('schedule_remove')) + '"><i class="bi bi-x-lg"></i></button></li>')
.data('entry', entry).find('span').text(scheduleText(entry)).end().appendTo($list);
$.each(entry.days, function(j, day) { perDay[day] = (perDay[day] || 0) + 1; });
});
$list.toggleClass('d-none', !entries.length);
$form.find('.backup-schedule-none').toggleClass('d-none', entries.length > 0);
$form.find('.backup-schedule-add').prop('disabled', entries.length >= MAX_SCHEDULE);
// hourly snapshots only matter with several backups a day (or when set)
var several = Object.keys(perDay).some(function(day) { return perDay[day] > 1; });
$form.find('.backup-keep-hourly').toggleClass('d-none', !several && !(parseInt($('#backup_keep_hourly').val(), 10) > 0));
}
// the weekdays and time of the new row as an entry (mark: highlight what is missing); whether one was added
function addScheduleEntry(mark) {
var $new = $('#backup_settings .backup-schedule-new'), time = ($new.find('.backup-schedule-time').val() || '').slice(0, 5);
var days = $new.find('.backup-schedule-days input:checked').map(function() { return parseInt(this.value, 10); }).get()
.sort(function(a, b) { return a - b; });
if (mark) {
$new.find('label.btn').toggleClass('inputMissingAttr', !days.length);
$new.find('.backup-schedule-time').toggleClass('inputMissingAttr', !time);
}
if (!days.length || !time) return false;
var entries = scheduleEntries(), key = JSON.stringify({days: days, time: time});
if (!entries.some(function(entry) { return JSON.stringify(entry) === key; })) entries.push({days: days, time: time});
drawSchedule(entries);
$new.find('.backup-schedule-days input').prop('checked', false);
$new.find('.backup-schedule-time').val('');
return true;
}
function loadSettings() {
getJson('backup-settings', function(s) {
fillSettings(s || {});
}).fail(function() {
mailcow_alert_box(esc(L('load_error')), 'danger');
});
}
function loadRunnerFields() {
getJson('backup-settings', function(s) {
fillRunnerFields(s || {});
});
}
// the fields of the chosen target and the general ones; the other targets keep their stored values
function collectSettings() {
var target = $('#backup_target').val(), attr = {};
$('#backup_settings').find('input[name]:not([type="hidden"]), select[name]').each(function() {
var $el = $(this), name = $el.attr('name'), $section = $el.closest('.backup-target');
if ($section.length && $section.data('target') !== target) return;
if ($el.is(':checkbox')) {
attr[name] = $el.is(':checked') ? '1' : '0';
} else if (SECRETS.indexOf(name) !== -1) {
if ($el.val() !== '' && !$el.data('masked')) attr[name] = $el.val();
} else if (name === 'max_unused') {
attr[name] = $el.val() + '%';
} else {
attr[name] = $el.val();
}
});
var $form = $('#backup_settings');
attr.schedule = scheduleEntries();
return attr;
}
function saveSettings($button) {
// weekdays and time filled in but not added yet: they are meant to be part of the schedule
addScheduleEntry(false);
var $form = $('#backup_settings'), attr = collectSettings();
$form.find('.inputMissingAttr').removeClass('inputMissingAttr');
$form.find('.backup-password-confirm').removeClass('text-danger');
if ((attr.password || attr.enabled === '1') && attr.password_confirmed !== '1') {
$form.find('.backup-password-confirm').addClass('text-danger');
$form.find('input[name="password_confirmed"]').focus();
mailcow_alert_box(esc(lang_danger.backup_password_not_confirmed || L('password_confirmed')), 'danger');
return;
}
post('edit/backup-settings', {items: ['settings'], attr: attr}, function(ok, messages) {
$.each(messages, function(i, message) {
var msg = $.isArray(message.msg) ? message.msg : [message.msg];
if (msg[0] === 'backup_setting_invalid') {
var $field = $form.find('[name="' + msg[1] + '"]').add($form.find('[data-setting="' + msg[1] + '"]').find('input[type="time"], label.btn'))
.addClass('inputMissingAttr');
// a field of the collapsed section: open it
if ($field.closest('#backup_advanced').length) $('#backup_advanced').collapse('show');
}
if (msg[0] === 'backup_password_too_short') $form.find('[name="password"]').addClass('inputMissingAttr');
// secrets are encrypted with the key of the backup container: it must have run once
if (msg[0] === 'backup_secrets_key_missing') $form.find('.backup-secret').filter(function() { return this.value !== '' && !$(this).data('masked'); }).addClass('inputMissingAttr');
if (msg[0] === 'backup_password_not_confirmed') $form.find('.backup-password-confirm').addClass('text-danger');
});
if (ok) {
loadSettings();
loadStatus();
}
}, $button);
}
function generatePassword() {
var chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789', bytes = new Uint32Array(32), password = '';
window.crypto.getRandomValues(bytes);
for (var i = 0; i < bytes.length; i++) password += chars.charAt(bytes[i] % chars.length);
return password;
}
function copyField(selector) {
var $field = $(selector), value = $field.val();
if (!value) return;
var done = function() {
mailcow_alert_box(esc((typeof lang !== 'undefined' && lang.copy_to_clipboard) || L('copy')), 'success');
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(value).then(done);
} else {
$field.trigger('select');
if (document.execCommand('copy')) done();
}
}
// ---- System > Configuration > Backup: jobs and snapshots (sections hidden while empty) ----
function loadJobs() {
getJson('backup-jobs/200', function(json) {
// restores are listed on E-Mail > Restore
var jobs = $.grep($.isArray(json) ? json : [], function(job) { return job.type !== 'restore'; });
var $section = $('#tab-config-backup .backup-jobs-section').toggleClass('d-none', !jobs.length);
if (!jobs.length) return;
$.each(jobs, function(i, job) {
job.type_display = esc(typeLabel(job.type)) + (job.requested_by && job.requested_by !== 'schedule'
? '<div class="small text-muted">' + esc(fill(L('manual_by'), job.requested_by)) + '</div>' : '');
job.state_display = stateBadge(job);
job.time = job.started || job.created;
job.duration_display = esc(jobDuration(job));
job.result_display = jobSummary(job) + errorCell(job);
job.action = jobActions(job);
});
if ($section.is(':visible')) setTable('#backup_jobs_table', tableOptions({
order: [[3, 'desc']],
columns: [
placeholderColumn(),
{title: L('type'), data: 'type_display', defaultContent: '', responsivePriority: 2},
{title: L('state'), data: 'state_display', defaultContent: '', responsivePriority: 1},
{title: L('time'), data: 'time', defaultContent: '', render: renderTime, responsivePriority: 3},
{title: L('duration'), data: 'duration_display', defaultContent: '', responsivePriority: 5},
{title: L('result'), data: 'result_display', defaultContent: '', responsivePriority: 4},
actionColumn()
]
}, L('jobs_none')), jobs);
}).fail(tableError('backup_jobs_table'));
}
function loadSnapshotList() {
getJson('backup-snapshots', function(list) {
list = $.isArray(list) ? list : [];
$('#tab-config-backup .backup-snapshots-section').toggleClass('d-none', !list.length);
$('#tab-config-backup .backup-snapshot-count').text(list.length);
if (!list.length) return;
var host = (typeof backup_hostname !== 'undefined') ? backup_hostname : '';
var other = list.some(function(snapshot) { return snapshot.hostname && host && snapshot.hostname !== host; });
$.each(list, function(i, snapshot) {
var summary = snapshot.summary || {}, domains = $.isArray(snapshot.domains) ? snapshot.domains : null;
snapshot.id_display = '<code title="' + esc(snapshot.id) + '">' + esc(snapshot.short_id || String(snapshot.id).substr(0, 8)) + '</code>' +
(modeText(snapshot) ? ' <span class="small text-muted">' + esc(modeText(snapshot)) + '</span>' : '');
snapshot.domains_display = domains ? '<span title="' + esc(domains.join(', ')) + '">' + esc(domains.length) + '</span>' : '–';
snapshot.host_display = esc(snapshot.hostname || '');
snapshot.size = summary.total_bytes_processed;
snapshot.added = summary.data_added;
});
var options = tableOptions({
order: [[1, 'desc']],
columns: [
placeholderColumn(),
{title: L('time'), data: 'time', defaultContent: '', render: renderTime, responsivePriority: 1},
{title: L('id'), data: 'id_display', defaultContent: '', responsivePriority: 2},
{title: L('domains'), data: 'domains_display', defaultContent: '', responsivePriority: 3},
{title: L('size'), data: 'size', defaultContent: '', render: renderSize, responsivePriority: 4},
{title: L('data_added'), data: 'added', defaultContent: '', render: renderSize, responsivePriority: 4},
{title: L('hostname'), data: 'host_display', defaultContent: '', visible: other, responsivePriority: 5}
]
}, L('snapshots_none'));
if ($.fn.DataTable.isDataTable('#backup_snapshots_table')) $('#backup_snapshots_table').DataTable().column(6).visible(other);
setTable('#backup_snapshots_table', options, list);
}).fail(tableError('backup_snapshots_table'));
}
// ---- E-Mail > Restore: the restores ----
var restoresTimer = null;
// while restores are queued or running: reload the list when it is visible
function pollRestores() {
if ($('#backup_restores_table').is(':visible')) reloadTable('#backup_restores_table');
else restoresTimer = setTimeout(pollRestores, POLL_MS);
}
function drawRestores() {
if ($.fn.DataTable.isDataTable('#backup_restores_table')) {
$('#backup_restores_table').DataTable().columns.adjust().responsive.recalc();
return;
}
$('#backup_restores_table').DataTable(tableOptions({
order: [[1, 'desc']],
ajax: {
type: 'GET',
url: '/api/v1/get/backup-jobs/500/restore',
dataSrc: function(json) {
if (!$.isArray(json)) return [];
var jobs = $.grep(json, function(job) { return job.type === 'restore'; });
$.each(jobs, function(i, job) {
job.state_display = stateBadge(job);
job.target_display = esc(restoreTarget(job, true)) + '<div>' + componentsCell(job) + '</div>';
job.snapshot_display = esc(restoreSnapshot(job));
job.requested_display = esc(requestedBy(job));
job.result_display = restoreSummary(job) + errorCell(job);
job.action = jobActions(job);
});
updateActiveRestores(jobs);
clearTimeout(restoresTimer);
if (jobs.some(isActive)) restoresTimer = setTimeout(pollRestores, POLL_MS);
return jobs;
},
error: tableError('backup_restores_table')
},
columns: [
placeholderColumn(),
{title: L('id'), data: 'id', defaultContent: '', responsivePriority: 3},
{title: L('state'), data: 'state_display', defaultContent: '', responsivePriority: 1},
{title: L('mailboxes'), data: 'target_display', defaultContent: '', responsivePriority: 2},
{title: L('snapshot'), data: 'snapshot_display', defaultContent: '', responsivePriority: 5},
{title: L('result'), data: 'result_display', defaultContent: '', responsivePriority: 4},
{title: L('requested_by'), data: 'requested_display', defaultContent: '', responsivePriority: 6},
{title: L('created'), data: 'created', defaultContent: '', render: renderTime, responsivePriority: 5},
actionColumn()
]
}, L('restores_none')));
}
// ---- E-Mail > Restore: snapshot, tree of its domains and mailboxes, selection, restore modal ----
// rows rendered per step ("show more"), so the page stays small for big snapshots
var CHUNK = 200;
var SUMMARY_MAX = 10;
var SEARCH_MIN = 2;
// snapshot: chosen id; snapshots: id => snapshot; available: the snapshot has a content list; domains: its domains
// (rows of get/backup-snapshot-content); nodes: domain => {info, index, expanded, loading, failed, mailboxes (null until
// loaded), known: username => mailbox, shown}; shownDomains: domain rows rendered; term/search: the server search and
// its result; onlyDeleted: filter; selection: {domain, scope: domain|mailbox, mailboxes: {username: true}} or null;
// replaced: the domain whose selection was replaced; active: restores queued or running (domain => {all, users});
// target: what the modal restores; deletedOnly: everything in the modal target was deleted
var tree = {snapshot: null, snapshots: {}, available: 1, domains: [], nodes: {}, shownDomains: CHUNK, term: '', search: null, onlyDeleted: false,
selection: null, replaced: null, notice: null, active: {}, activeIds: [], activeKey: '', target: null, deletedOnly: false, seq: 0, loaded: false};
var searchTimer = null;
var searchRequest = null;
function isAdmin() {
return typeof role !== 'undefined' && role === 'admin';
}
function pageHint(type, html) {
$('#backup_restore_page .backup-restore-hints').html(html ? '<div class="alert alert-' + type + '">' + html + '</div>' : '');
}
function settingsLink(text) {
return ' <a href="/admin/system#tab-config-backup">' + esc(text) + '</a>';
}
function deletedBadge() {
return '<span class="badge bg-danger">' + esc(L('status_deleted')) + '</span>';
}
function restoringBadge() {
return '<span class="badge bg-info text-black">' + esc(L('status_restoring')) + '</span>';
}
function loadSnapshots() {
if (typeof backup_configured !== 'undefined' && !backup_configured) {
pageHint('warning', esc(L(isAdmin() ? 'not_configured' : 'not_configured_da')) + (isAdmin() ? settingsLink(L('to_backup_settings')) : ''));
setSnapshotOptions([]);
return;
}
getJson('backup-snapshots', function(list) {
list = $.isArray(list) ? list : [];
list.sort(function(a, b) { return b.time - a.time; });
setSnapshotOptions(list);
if (!list.length) {
pageHint('info', esc(L('no_snapshots_any')) + (isAdmin() ? settingsLink(L('no_snapshots_backup_now')) : ''));
return;
}
selectSnapshot(list[0].id);
}).fail(function() {
pageHint('danger', esc(L('load_error')));
});
}
// the options are rebuilt and bootstrap-select is built anew: its refresh repeats the list and shows hidden options
function setSnapshotOptions(list) {
var $select = $('#backup_restore_snapshot');
tree.snapshots = {};
$select.selectpicker('destroy');
$select.empty();
$.each(list, function(i, snapshot) {
tree.snapshots[snapshot.id] = snapshot;
$select.append($('<option></option>').val(snapshot.id).text(formatTime(snapshot.time) + ' · ' + relative(snapshot.time))
.attr('data-subtext', (snapshot.short_id || String(snapshot.id).substr(0, 8)) + (snapshot.mode === 'config' ? ' · ' + modeText(snapshot) : '')));
});
$select.prop('disabled', !list.length);
$select.selectpicker({styleBase: 'btn btn-xs-lg', noneSelectedText: lang_footer.nothing_selected, showSubtext: true});
}
// what a snapshot lacks (RESTIC_BACKUP_MODE), '' for complete ones
function modeText(snapshot) {
return (snapshot.mode === 'data' || snapshot.mode === 'config') ? L('snapshot_mode_' + snapshot.mode) : '';
}
function noMails() {
var snapshot = tree.snapshots[tree.snapshot];
return !!(snapshot && snapshot.mode === 'config');
}
function snapshotLabel(snapshot) {
return formatTime(snapshot.time) + ' · ' + (snapshot.short_id || String(snapshot.id).substr(0, 8));
}
function treeBody() {
return $('#backup_tree tbody');
}
function treeMessage(text, cls) {
treeBody().html('<tr class="backup-tree-message"><td colspan="4" class="' + (cls || 'text-muted') + '">' + esc(text) + '</td></tr>');
}
// keep: reload after a restore finished (expanded domains stay expanded, the selection stays)
function selectSnapshot(id, keep) {
var $panel = $('#backup_restore_page .backup-tree-panel').removeClass('d-none');
var expanded = keep ? $.grep(Object.keys(tree.nodes), function(name) { return tree.nodes[name].expanded; }) : [];
tree.snapshot = id;
if (keep && tree.selection) {
// states changed (deleted mailboxes may exist now): a kept selection would restore with the old states
tree.selection = null;
tree.replaced = null;
tree.notice = L('selection_reset');
updateChecks();
}
if (!keep) {
tree.domains = [];
tree.nodes = {};
tree.selection = null;
tree.replaced = null;
tree.shownDomains = CHUNK;
tree.search = null;
tree.term = '';
$panel.find('.backup-tree-search').val('');
$panel.find('.backup-content-note').empty();
treeMessage(lang_footer.loading);
updateChecks();
}
getJson('backup-snapshot-content/' + encodeURIComponent(id), function(content) {
if (tree.snapshot !== id) return;
if (!content || !$.isArray(content.domains)) {
treeMessage(L('load_error'), 'text-danger');
return;
}
tree.available = (content.available === undefined) ? 1 : content.available;
// domain admins never get deleted domains (the API leaves them out as well)
tree.domains = $.grep(content.domains, function(d) { return isAdmin() || d.exists == 1; });
tree.domains.sort(function(a, b) { return String(a.domain).localeCompare(String(b.domain)); });
tree.nodes = {};
$.each(tree.domains, function(i, d) {
tree.nodes[d.domain] = {info: d, index: i, expanded: false, loading: false, failed: false, mailboxes: null, known: {}, shown: CHUNK};
});
$panel.find('.backup-content-note').html(tree.available == 1 ? '' : '<div class="alert alert-info">' + esc(L('content_unavailable')) + '</div>');
tree.loaded = true;
// one domain (typical for domain admins): open at once
if (tree.domains.length === 1 && !keep) expanded = [tree.domains[0].domain];
$.each(expanded, function(i, name) {
var node = tree.nodes[name];
if (node) {
node.expanded = true;
loadMailboxes(node);
}
});
if (tree.term.length >= SEARCH_MIN) runSearch();
else renderTree();
}).fail(function() {
if (tree.snapshot === id) treeMessage(L('load_error'), 'text-danger');
});
}
// ---- tree rows ----
function domainVisible(node) {
return !tree.onlyDeleted || node.info.exists != 1 || node.info.missing > 0;
}
function mailboxVisible(m) {
return !tree.onlyDeleted || m.exists != 1;
}
// a deleted domain can only be restored as a whole, and only from a snapshot with a content list
function domainRestorable(node) {
return node.info.exists == 1 || tree.available == 1;
}
function mailboxesSelectable(node) {
return node.info.exists == 1;
}
function restoringDomain(node) {
var active = tree.active[node.info.domain];
return !!(active && active.all);
}
function restoringMailbox(node, m) {
var active = tree.active[node.info.domain];
return !!(active && (active.all || active.users[m.username]));
}
function restoreRowButton(label, disabled) {
return '<button type="button" class="btn btn-xs btn-xs-lg btn-secondary text-nowrap backup-tree-restore" title="' + esc(fill(L('restore_item'), label)) +
'" aria-label="' + esc(fill(L('restore_item'), label)) + '"' + (disabled ? ' disabled' : '') + '><i class="bi bi-clock-history"></i><span class="d-none d-sm-inline"> ' + esc(L('restore')) + '</span></button>';
}
function domainCount(d) {
if (d.mailboxes === null || d.mailboxes === undefined) return '';
return d.missing > 0 ? fill(L('count_deleted'), d.mailboxes, d.missing) : String(d.mailboxes);
}
function domainRowHtml(node) {
var d = node.info, count = domainCount(d), restoring = restoringDomain(node), id = 'backup_tree_c' + (++tree.seq);
var badges = (d.exists != 1 ? deletedBadge() : '') + (restoring ? ' ' + restoringBadge() : '');
return '<tr class="backup-tree-domain" data-index="' + node.index + '" style="cursor:pointer">' +
'<td><div class="d-flex align-items-center gap-1">' +
'<button type="button" class="btn btn-link p-0 text-reset d-inline-flex align-items-center justify-content-center flex-shrink-0 backup-tree-toggle" style="width:2rem;height:2rem;min-width:32px;min-height:32px" aria-expanded="' +
(node.expanded ? 'true' : 'false') + '" aria-label="' + esc(fill(L(node.expanded ? 'collapse' : 'expand'), d.domain)) + '"><i class="bi bi-chevron-' + (node.expanded ? 'down' : 'right') + '"></i></button>' +
'<input type="checkbox" class="form-check-input mt-0 flex-shrink-0 backup-tree-check" id="' + id + '"' + (domainRestorable(node) && !restoring ? '' : ' disabled') + '>' +
'<label class="text-break mb-0 ms-1 fw-semibold" for="' + id + '">' + esc(d.domain) + '</label>' +
(badges ? '<span class="d-md-none ms-1">' + badges + '</span>' : '') + (count ? '<span class="d-sm-none small text-muted ms-1">· ' + esc(count) + '</span>' : '') + '</div></td>' +
'<td class="d-none d-md-table-cell">' + badges + '</td>' +
'<td class="d-none d-sm-table-cell text-nowrap">' + esc(count) + '</td>' +
'<td class="text-end">' + (domainRestorable(node) ? restoreRowButton(d.domain, restoring) : '') + '</td></tr>';
}
function childRow(node, html, cls) {
return '<tr class="backup-tree-child" data-index="' + node.index + '"><td colspan="4" class="small ' + (cls || 'text-muted') + '" style="padding-left:2.75rem">' + html + '</td></tr>';
}
function mailboxRowHtml(node, m) {
var selectable = mailboxesSelectable(node), restoring = restoringMailbox(node, m), id = 'backup_tree_c' + (++tree.seq);
var badges = (m.exists != 1 ? deletedBadge() : '') + (restoring ? ' ' + restoringBadge() : '');
node.known[m.username] = m;
return '<tr class="backup-tree-mailbox" data-index="' + node.index + '" data-user="' + esc(m.username) + '">' +
'<td style="padding-left:2.75rem"><div class="d-flex align-items-center gap-2">' +
'<input type="checkbox" class="form-check-input mt-0 flex-shrink-0 backup-tree-check" id="' + id + '"' + (selectable && !restoring ? '' : ' disabled') + '>' +
'<label class="text-break mb-0" for="' + id + '">' + esc(m.username) + (m.name ? ' <span class="small text-muted">' + esc(m.name) + '</span>' : '') + '</label>' +
(badges ? '<span class="d-md-none">' + badges + '</span>' : '') + '</div></td>' +
'<td class="d-none d-md-table-cell">' + badges + '</td>' +
'<td class="d-none d-sm-table-cell"></td>' +
'<td class="text-end">' + (selectable ? restoreRowButton(m.username, restoring) : '') + '</td></tr>';
}
// the mailboxes a domain shows: the search hits or its loaded mailboxes, with the filter
function shownMailboxes(node) {
var list = tree.search ? (node.hits || []) : node.mailboxes;
return list ? $.grep(list, mailboxVisible) : null;
}
function childRowsHtml(node) {
if (node.loading && !tree.search) return childRow(node, '<span class="spinner-border spinner-border-sm me-1" role="status"></span>' + esc(lang_footer.loading));
if (node.failed && !tree.search) return childRow(node, esc(L('load_error')), 'text-danger');
var list = shownMailboxes(node);
if (list === null) return '';
var html = mailboxesSelectable(node) ? '' : childRow(node, '<i class="bi bi-info-circle"></i> ' + esc(L('deleted_domain_children')));
if (!list.length) return html + childRow(node, esc(L(tree.term || tree.onlyDeleted ? 'no_matches' : 'no_mailboxes')));
$.each(list.slice(0, node.shown), function(i, m) {
html += mailboxRowHtml(node, m);
});
if (list.length > node.shown) {
html += childRow(node, '<button type="button" class="btn btn-xs btn-xs-lg btn-outline-secondary backup-tree-more">' +
esc(fill(L('show_more'), Math.min(CHUNK, list.length - node.shown), list.length - node.shown)) + '</button>');
}
return html;
}
// the domains shown: search hits or all, with the filter
function shownDomains() {
if (tree.search) {
return $.map(tree.search.domains, function(hit) {
var node = tree.nodes[hit.domain];
if (!node) return null;
node.hits = $.isArray(hit.mailboxes) ? hit.mailboxes : [];
$.each(node.hits, function(i, m) { node.known[m.username] = m; });
return domainVisible(node) ? node : null;
});
}
return $.grep($.map(tree.domains, function(d) { return tree.nodes[d.domain]; }), domainVisible);
}
function renderTree() {
var nodes = shownDomains(), html = '';
$.each(nodes.slice(0, tree.shownDomains), function(i, node) {
// search results show their hits right away
html += domainRowHtml(node) + ((node.expanded || tree.search) ? childRowsHtml(node) : '');
});
if (nodes.length > tree.shownDomains) {
html += '<tr class="backup-tree-child"><td colspan="4"><button type="button" class="btn btn-xs btn-xs-lg btn-outline-secondary backup-tree-more-domains">' +
esc(fill(L('show_more_domains'), Math.min(CHUNK, nodes.length - tree.shownDomains), nodes.length - tree.shownDomains)) + '</button></td></tr>';
}
if (tree.search && tree.search.truncated) html += '<tr class="backup-tree-child"><td colspan="4" class="small text-muted"><i class="bi bi-info-circle"></i> ' + esc(L('search_truncated')) + '</td></tr>';
if (!tree.domains.length) treeMessage(L('no_domains'));
else if (!nodes.length) treeMessage(L('no_matches'));
else treeBody().html(html);
updateChecks();
}
// the domain row and its mailbox rows anew; the focus stays on the same control of the row
function renderDomain(node, focusIndex) {
var $row = treeBody().find('tr.backup-tree-domain[data-index="' + node.index + '"]');
if (!$row.length) return;
var $focus = $(document.activeElement), control = null;
if ($focus.closest('tr').is($row)) {
control = $focus.hasClass('backup-tree-toggle') ? '.backup-tree-toggle' : ($focus.hasClass('backup-tree-check') ? '.backup-tree-check'
: ($focus.hasClass('backup-tree-restore') ? '.backup-tree-restore' : null));
}
treeBody().find('tr.backup-tree-child[data-index="' + node.index + '"], tr.backup-tree-mailbox[data-index="' + node.index + '"]').remove();
$row.replaceWith(domainRowHtml(node) + ((node.expanded || tree.search) ? childRowsHtml(node) : ''));
updateChecks();
var $new = treeBody().find('tr.backup-tree-domain[data-index="' + node.index + '"]');
if (control) $new.find(control).trigger('focus');
// "show more": the first new mailbox
if (focusIndex !== undefined) treeBody().find('tr.backup-tree-mailbox[data-index="' + node.index + '"]').eq(focusIndex).find('.backup-tree-check').trigger('focus');
}
function nodeOf($el) {
var d = tree.domains[parseInt($el.closest('tr').data('index'), 10)];
return d ? tree.nodes[d.domain] : null;
}
function mailboxOf($el, node) {
return node ? node.known[$el.closest('tr').attr('data-user')] || null : null;
}
function toggleDomain(node) {
if (tree.search) return;
node.expanded = !node.expanded;
if (node.expanded && node.mailboxes === null && !node.loading) loadMailboxes(node);
renderDomain(node);
}
function loadMailboxes(node, done) {
var id = tree.snapshot;
node.loading = true;
node.failed = false;
getJson('backup-snapshot-content/' + encodeURIComponent(id) + '/' + encodeURIComponent(node.info.domain), function(res) {
if (tree.snapshot !== id) return;
node.loading = false;
node.mailboxes = (res && $.isArray(res.mailboxes)) ? res.mailboxes : [];
node.mailboxes.sort(function(a, b) { return String(a.username).localeCompare(String(b.username)); });
$.each(node.mailboxes, function(i, m) { node.known[m.username] = m; });
if (done) done();
renderDomain(node);
updateChecks();
}).fail(function() {
if (tree.snapshot !== id) return;
node.loading = false;
node.failed = true;
renderDomain(node);
});
}
// ---- search in the snapshot (server side, also in collapsed domains) ----
function runSearch() {
var term = tree.term, id = tree.snapshot;
if (searchRequest) searchRequest.abort();
if (term.length < SEARCH_MIN) {
tree.search = null;
renderTree();
return;
}
treeMessage(lang_footer.loading);
// nginx decodes the path and passes it in ?query= of json_api.php as it is: "&", "+" and "%" are encoded twice
searchRequest = getJson('backup-snapshot-search/' + encodeURIComponent(id) + '/' + encodeURIComponent(encodeURIComponent(term)), function(res) {
if (tree.snapshot !== id || tree.term !== term) return;
tree.search = {domains: (res && $.isArray(res.domains)) ? res.domains : [], truncated: res && res.truncated ? 1 : 0};
tree.shownDomains = CHUNK;
renderTree();
});
searchRequest.fail(function(xhr, status) {
if (status !== 'abort' && tree.term === term) treeMessage(L('load_error'), 'text-danger');
});
}
// ---- selection: one domain, either the whole domain or some of its mailboxes ----
function setSelection(selection, replaced) {
var old = tree.selection;
tree.notice = null;
tree.replaced = (old && selection && old.domain !== selection.domain) ? old.domain : (replaced || null);
tree.selection = selection;
updateChecks();
}
function selectedMailboxes() {
var sel = tree.selection;
return (sel && sel.scope === 'mailbox') ? Object.keys(sel.mailboxes) : [];
}
// all mailboxes of the domain ticked one by one
function allTicked() {
var sel = tree.selection, node = sel ? tree.nodes[sel.domain] : null;
if (!node || sel.scope !== 'mailbox' || !node.mailboxes || !node.mailboxes.length) return false;
return node.mailboxes.every(function(m) { return sel.mailboxes[m.username] || restoringMailbox(node, m); });
}
function updateChecks() {
var sel = tree.selection;
treeBody().find('tr.backup-tree-domain').each(function() {
var node = nodeOf($(this)), mine = !!(node && sel && sel.domain === node.info.domain);
$(this).find('.backup-tree-check').prop('checked', mine && sel.scope === 'domain').prop('indeterminate', mine && sel.scope === 'mailbox');
});
treeBody().find('tr.backup-tree-mailbox').each(function() {
var node = nodeOf($(this)), m = mailboxOf($(this), node), mine = !!(m && sel && sel.domain === node.info.domain);
$(this).find('.backup-tree-check').prop('checked', mine && (sel.scope === 'domain' || !!sel.mailboxes[m.username]));
});
updateToolbar();
}
function updateToolbar() {
var sel = tree.selection, $page = $('#backup_restore_page'), text = L('selection_none'), count = selectedMailboxes().length, hint = '';
if (sel && sel.scope === 'domain') text = fill(L('selected_domain'), sel.domain);
else if (sel) text = fill(L(count === 1 ? 'selected_mailbox_in' : 'selected_mailboxes_in'), count, sel.domain);
$page.find('.backup-selection-text').text(text);
$page.find('.backup-selection-clear').toggleClass('d-none', !sel);
$page.find('.backup-selection-restore').prop('disabled', !sel);
if (tree.replaced) hint = '<i class="bi bi-exclamation-triangle-fill text-warning"></i> ' + esc(fill(L('selection_replaced'), tree.replaced));
else if (tree.notice) hint = '<i class="bi bi-info-circle"></i> ' + esc(tree.notice);
else if (allTicked()) hint = '<i class="bi bi-info-circle"></i> ' + esc(L('all_ticked')) +
' <button type="button" class="btn btn-link btn-sm p-0 align-baseline backup-select-domain">' + esc(L('select_whole_domain')) + '</button>';
$page.find('.backup-selection-hint').toggleClass('d-none', !hint).html(hint);
}
function checkDomain(node, checked) {
setSelection(checked ? {domain: node.info.domain, scope: 'domain', mailboxes: {}} : null);
}
function checkMailbox(node, m, checked) {
var sel = tree.selection, domain = node.info.domain, set = {};
if (sel && sel.domain === domain && sel.scope === 'domain') {
// unchecking a mailbox of a whole-domain selection keeps the other mailboxes of the domain (all of them loaded first)
if (node.mailboxes === null) {
loadMailboxes(node, function() { checkMailbox(node, m, checked); });
return;
}
$.each(node.mailboxes, function(i, other) {
if (other.username !== m.username && !restoringMailbox(node, other)) set[other.username] = true;
});
} else {
if (sel && sel.domain === domain) set = $.extend({}, sel.mailboxes);
if (checked) set[m.username] = true;
else delete set[m.username];
}
setSelection(Object.keys(set).length ? {domain: domain, scope: 'mailbox', mailboxes: set} : null);
}
// ---- restores in progress: rows marked, reloaded when they are done ----
function updateActiveRestores(jobs) {
var active = {}, ids = [];
$.each(jobs, function(i, job) {
if (!isActive(job)) return;
var params = job.params || {}, domain = params.domain || job.domain;
ids.push(job.id);
if (!domain) return;
active[domain] = active[domain] || {all: false, users: {}};
if (params.scope === 'domain') active[domain].all = true;
$.each(restoreMailboxes(job), function(j, user) { active[domain].users[user] = true; });
});
var finished = tree.activeIds.some(function(id) { return ids.indexOf(id) === -1; }), key = JSON.stringify(active);
tree.activeIds = ids;
if (key === tree.activeKey && !finished) return;
tree.active = active;
tree.activeKey = key;
if (!tree.loaded) return;
// "deleted" turns into "exists" when a restore created the mailbox or domain
if (finished) selectSnapshot(tree.snapshot, true);
else if (tree.term.length >= SEARCH_MIN) renderTree();
else renderTree();
}
// ---- restore modal ----
function domainTarget(node) {
return {scope: 'domain', domain: node.info.domain, node: node};
}
function mailboxesTarget(node, usernames) {
return {scope: 'mailbox', domain: node.info.domain, node: node,
mailboxes: $.map(usernames, function(u) { return node.known[u] || {username: u, exists: 1}; })};
}
function selectionTarget() {
var sel = tree.selection, node = sel ? tree.nodes[sel.domain] : null;
if (!node) return null;
return sel.scope === 'domain' ? domainTarget(node) : mailboxesTarget(node, selectedMailboxes());
}
function createdBadge() {
return ' <span class="badge bg-warning text-black">' + esc(L('will_be_created')) + '</span>';
}
// number of mailboxes a restore covers (domain: those of the snapshot)
function targetCount(target) {
return target.scope === 'domain' ? (target.node.info.mailboxes || 0) : target.mailboxes.length;
}
function openRestore(target) {
var $modal = $('#backupRestoreModal'), snapshot = tree.snapshots[tree.snapshot], html = '', deleted, deletedOnly, title;
if (!target) return;
tree.target = target;
tree.replaced = null;
updateToolbar();
if (target.scope === 'domain') {
var d = target.node.info;
deleted = d.exists != 1 || d.missing > 0;
deletedOnly = d.exists != 1 || (d.mailboxes > 0 && d.missing >= d.mailboxes);
title = fill(L('modal_title_domain'), d.domain);
html += '<div class="fw-bold">' + esc(fill(L('restore_target_domain'), d.domain)) + (d.exists != 1 ? createdBadge() : '') + '</div>';
if (d.mailboxes !== null && d.mailboxes !== undefined) {
var count = (d.mailboxes == 1) ? L(d.missing > 0 ? 'target_count_one_deleted' : 'target_count_one')
: (d.missing > 0 ? fill(L('target_count'), d.mailboxes, d.missing) : fill(L('target_count_all'), d.mailboxes));
html += '<div class="small text-muted">' + esc(count) + '</div>';
}
} else {
var gone = $.grep(target.mailboxes, function(m) { return m.exists != 1; }).length;
deleted = gone > 0;
deletedOnly = gone === target.mailboxes.length;
if (target.mailboxes.length === 1) {
title = fill(L('modal_title_mailbox'), target.mailboxes[0].username);
html += '<div class="fw-bold">' + esc(fill(L('restore_target_mailbox'), target.mailboxes[0].username)) + (gone ? createdBadge() : '') + '</div>';
} else {
title = fill(L('modal_title_mailboxes'), target.mailboxes.length, target.domain);
html += '<div class="fw-bold">' + esc(fill(L('restore_target_mailboxes_of'), target.mailboxes.length, target.domain)) + '</div>';
html += '<ul class="mb-0 small">' + $.map(target.mailboxes.slice(0, SUMMARY_MAX), function(m) {
return '<li>' + esc(m.username) + (m.exists != 1 ? createdBadge() : '') + '</li>';
}).join('') + (target.mailboxes.length > SUMMARY_MAX ? '<li class="text-muted">' + esc(fill(L('and_more'), target.mailboxes.length - SUMMARY_MAX)) + '</li>' : '') + '</ul>';
}
}
$modal.find('.modal-title').text(title);
$modal.find('.backup-restore-subtitle').text(L('snapshot_label') + ': ' + (snapshot ? snapshotLabel(snapshot) : tree.snapshot));
$modal.find('.backup-restore-summary').html(html);
$modal.find('.backup-create-note').toggleClass('d-none', !deleted);
setOverwriteMode(deletedOnly);
applyDefaults(deletedOnly);
updateFolderName();
restoreError('');
$modal.modal('show');
}
function updateFolderName() {
var snapshot = tree.snapshots[tree.snapshot];
$('#backup_restore_form .backup-folder-name').text(snapshot ? folderName(snapshot.time) : L('mail_target_folder_name'));
}
// everything deleted: nothing to overwrite, SOGo is a normal item, settings and password are not sent
function setOverwriteMode(deletedOnly) {
var $form = $('#backup_restore_form'), $sogo = $form.find('.backup-sogo-item'), $label = $sogo.find('label');
tree.deletedOnly = deletedOnly;
if (deletedOnly) $form.find('.backup-sogo-normal').append($sogo);
else $form.find('.backup-overwrite > p').after($sogo);
$form.find('.backup-sogo-normal').toggleClass('d-none', !deletedOnly);
$form.find('.backup-overwrite').toggleClass('d-none', deletedOnly);
$sogo.find('.backup-sogo-note').toggleClass('d-none', deletedOnly);
$label.text(deletedOnly ? $label.data('normal') : $label.data('overwrite'));
$form.find('.backup-component[value="settings"], .backup-component[value="password"]').prop('disabled', deletedOnly);
}
// existing targets: only the mails into a separate folder; everything deleted: all items, mails into their folders
function applyDefaults(deletedOnly) {
$('#backup_restore_form .backup-component').each(function() {
var key = this.value;
var on = deletedOnly ? (OVERWRITE.indexOf(key) === -1 || key === 'sogo') : EXISTING_DEFAULT.indexOf(key) !== -1;
$(this).prop('checked', on && !(noMails() && MAIL_COMPONENTS.indexOf(key) !== -1));
});
$('#backup_restore_form input[name="mail_target"][value="' + (deletedOnly ? 'original' : 'folder') + '"]').prop('checked', true);
toggleSubOptions();
}
function checkedOverwrite() {
if (tree.deletedOnly) return [];
return $('#backup_restore_form .backup-overwrite .backup-component:checked:enabled').map(function() { return this.value; }).get();
}
// sub-items (Sieve activation) need their item; the mail target needs the mails; overwriting turns the button red
function toggleSubOptions() {
var $form = $('#backup_restore_form'), missing = noMails();
$form.find('.backup-no-mail-note').toggleClass('d-none', !missing);
$.each(MAIL_COMPONENTS, function(i, key) {
var $item = $form.find('.backup-component[value="' + key + '"]');
if (missing) $item.prop('checked', false);
$item.prop('disabled', missing).closest('.form-check').toggleClass('text-muted', missing);
});
$form.find('.backup-component[data-parent]').each(function() {
var parent = $form.find('.backup-component[value="' + $(this).data('parent') + '"]');
var off = !parent.is(':checked') || (missing && MAIL_COMPONENTS.indexOf(this.value) !== -1);
$(this).prop('disabled', off).closest('.form-check').toggleClass('text-muted', off);
});
var mails = $form.find('.backup-component[value="mails"]').is(':checked');
$form.find('input[name="mail_target"]').prop('disabled', !mails);
$form.find('.backup-mail-target').toggleClass('text-muted', !mails);
var $submit = $form.find('.backup-restore-submit'), over = checkedOverwrite().length > 0;
$submit.toggleClass('btn-danger', over).toggleClass('btn-success', !over).find('span').text($submit.data(over ? 'overwrite' : 'normal'));
}
function restoreError(text) {
$('#backup_restore_form .backup-restore-error').toggleClass('d-none', !text).text(text || '');
}
function queuedAlert(id) {
$('#backup_restore_page .backup-restore-queued').html('<div class="alert alert-success alert-dismissible py-2 small">' +
'<i class="bi bi-check-circle-fill"></i> ' + esc(fill(L('queued_inline'), id)) +
' <a href="#" class="backup-job-details" data-job="' + esc(id) + '">' + esc(L('details')) + '</a>' +
'<button type="button" class="btn-close btn-sm" data-bs-dismiss="alert" aria-label="' + esc(L('close')) + '"></button></div>');
}
function submitRestore($button) {
var $form = $('#backup_restore_form'), target = tree.target;
var components = $form.find('.backup-component:checked:enabled').map(function() { return this.value; }).get();
restoreError('');
if (!target || !tree.snapshot) return;
if (!components.length) {
restoreError(L('no_components'));
return;
}
var over = checkedOverwrite();
if (over.length && !confirm(fill(L('overwrite_confirm'), $.map(over, function(c) { return L('overwrite_short_' + c) || c; }).join(', '), targetCount(target)))) return;
var body = {snapshot: tree.snapshot, scope: target.scope, domain: target.domain, components: components,
mail_target: $form.find('input[name="mail_target"]:checked').val() || 'folder'};
if (target.scope === 'mailbox') body.mailboxes = $.map(target.mailboxes, function(m) { return m.username; });
post('add/backup-restore', body, function(ok, messages) {
if (ok) {
$.each(messages, function(i, message) {
if ($.isArray(message.msg) && message.msg[0] === 'backup_restore_added') queuedAlert(message.msg[1]);
});
$('#backupRestoreModal').modal('hide');
setSelection(null);
reloadTable('#backup_restores_table');
return;
}
var errors = $.grep(messages, function(m) { return m && m.type !== 'success'; });
restoreError(errors.length ? messageText(errors[0]) : L('load_error'));
}, $button);
}
// the sticky toolbar below mailcow's sticky navbar
function stickToolbar() {
$('#backup_restore_page .backup-tree-toolbar').css('top', ($('nav.navbar.sticky-top').outerHeight() || 0) + 'px');
}
$(document).ready(function() {
// mailcow's handler for page forms (button text "loading", no second submit, F5 off) is not for these AJAX forms
$('#backup_settings, #backup_restore_form').off('submit');
// job details and cancel: both pages
$(document).on('click', '.backup-job-details', function(e) {
e.preventDefault();
showJob($(this).data('job'));
});
$(document).on('click', '.backup-job-cancel-row', function(e) {
e.preventDefault();
cancelJob($(this).data('job'), $(this).data('state'), $(this));
});
$('#backupJobModal').on('click', '.backup-job-cancel', function(e) {
e.preventDefault();
if (jobId !== null) cancelJob(jobId, $(this).data('state'), $(this));
}).on('click', '.backup-job-refresh', function(e) {
e.preventDefault();
loadJob();
}).on('hidden.bs.modal', function() {
clearTimeout(jobTimer);
jobId = null;
});
// "+"/"-" of the collapsible sections
$(document).on('show.bs.collapse hide.bs.collapse', '#backup_advanced, #backup_snapshots_collapse', function(e) {
if (e.target !== this) return;
$('[data-bs-target="#' + this.id + '"] i').toggleClass('bi-plus-square', e.type === 'hide').toggleClass('bi-dash-square', e.type === 'show');
}).on('shown.bs.collapse', '#backup_snapshots_collapse', function() {
if ($.fn.DataTable.isDataTable('#backup_snapshots_table')) $('#backup_snapshots_table').DataTable().columns.adjust().responsive.recalc();
});
// admin: System > Configuration > Backup
if ($('#tab-config-backup').length) {
var loaded = false, listsLoaded = 0;
onVisible('#backup_status', function() {
if (!loaded) loadSettings();
loaded = true;
loadStatus();
// the lists again when the tab is shown, not on every scroll
if (now() - listsLoaded >= 10) {
listsLoaded = now();
loadJobs();
loadSnapshotList();
}
});
// links to the tab (e.g. from E-Mail > Restore)
if (window.location.hash === '#tab-config-backup') {
setTimeout(function() {
$('[data-bs-target="#tab-config-backup"]').first().trigger('click');
// phones: the tabs are collapsible cards
$('#collapse-tab-config-backup').collapse('show');
}, 0);
}
$('#backup_target').on('change', showTarget);
$('#backup_settings input[name="include_index"]').on('change', toggleFts);
$('#backup_settings').on('click', '.backup-schedule-add', function(e) {
e.preventDefault();
addScheduleEntry(true);
}).on('keydown', '.backup-schedule-new input', function(e) {
// Enter adds the entry instead of saving the form
if (e.key === 'Enter') {
e.preventDefault();
addScheduleEntry(true);
}
}).on('change', '.backup-schedule-new input', function() {
$(this).removeClass('inputMissingAttr').closest('.backup-schedule-new').find('label.btn').removeClass('inputMissingAttr');
}).on('click', '.backup-schedule-remove', function(e) {
e.preventDefault();
$(this).closest('li').remove();
drawSchedule(scheduleEntries());
});
// a stored secret: empty while focused (typing replaces it), dots again when left empty
$('#backup_settings .backup-secret').on('focus', function() {
if ($(this).data('masked')) $(this).val('').data('masked', false);
}).on('input', function() {
$(this).data('masked', false);
}).on('blur', function() {
if ($(this).data('stored') && $(this).val() === '') $(this).val(SECRET_MASK).data('masked', true);
});
$('#backup_settings input[name="password"]').on('input', function() {
var $confirm = $('#backup_settings input[name="password_confirmed"]');
if ($confirm.data('stored')) $confirm.prop('checked', false).data('stored', false);
// a new password only works for a new, empty repository
$('#backup_settings .backup-password-change').toggleClass('d-none', !($(this).data('stored') && $(this).val() !== ''));
});
$('#backup_settings').on('submit', function(e) {
e.preventDefault();
saveSettings($(this).find('.backup-settings-save'));
});
$('#backup_settings .backup-password-generate').on('click', function(e) {
e.preventDefault();
$('#backup_password').attr('type', 'text').val(generatePassword()).trigger('input');
});
$('#tab-config-backup').on('click', '.backup-copy', function(e) {
e.preventDefault();
copyField($(this).data('copy'));
}).on('click', '.backup-job-request', function(e) {
e.preventDefault();
requestJob($(this));
});
$('#tab-config-backup .backup-refresh').on('click', function(e) {
e.preventDefault();
loadStatus();
loadRunnerFields();
loadJobs();
loadSnapshotList();
});
}
// E-Mail > Restore
if ($('#backup_restore_page').length) {
stickToolbar();
$(window).on('resize', stickToolbar);
loadSnapshots();
drawRestores();
$('#backup_restore_snapshot').on('change', function() {
if ($(this).val() && $(this).val() !== tree.snapshot) selectSnapshot($(this).val());
});
var $tree = $('#backup_tree tbody');
$tree.on('click', '.backup-tree-toggle', function(e) {
e.preventDefault();
e.stopPropagation();
var node = nodeOf($(this));
if (node) toggleDomain(node);
}).on('click', '.backup-tree-more', function(e) {
e.preventDefault();
e.stopPropagation();
var node = nodeOf($(this));
if (node) {
var first = node.shown;
node.shown += CHUNK;
renderDomain(node, first);
}
}).on('click', '.backup-tree-more-domains', function(e) {
e.preventDefault();
e.stopPropagation();
var first = tree.shownDomains;
tree.shownDomains += CHUNK;
renderTree();
treeBody().find('tr.backup-tree-domain').eq(first).find('.backup-tree-toggle').trigger('focus');
}).on('click', '.backup-tree-restore', function(e) {
e.preventDefault();
e.stopPropagation();
var node = nodeOf($(this)), m = $(this).closest('tr').hasClass('backup-tree-mailbox') ? mailboxOf($(this), node) : null;
if (node) openRestore(m ? mailboxesTarget(node, [m.username]) : domainTarget(node));
}).on('change', '.backup-tree-check', function() {
var $row = $(this).closest('tr'), node = nodeOf($row);
if (!node) return;
if ($row.hasClass('backup-tree-domain')) checkDomain(node, this.checked);
else checkMailbox(node, mailboxOf($row, node), this.checked);
}).on('click', 'tr', function(e) {
// mailcow selects a row's checkbox on a click into the row: the tree handles its rows itself
e.stopPropagation();
if ($(e.target).closest('input, label, button, a').length) return;
var $row = $(this), node = nodeOf($row);
if (!node) return;
if ($row.hasClass('backup-tree-domain')) toggleDomain(node);
else if ($row.hasClass('backup-tree-mailbox')) {
var $check = $row.find('.backup-tree-check');
if (!$check.prop('disabled')) $check.trigger('click');
}
});
$('#backup_restore_page').on('input', '.backup-tree-search', function() {
var value = String($(this).val()).trim().toLowerCase().replace(/\//g, '');
clearTimeout(searchTimer);
searchTimer = setTimeout(function() {
tree.term = value;
tree.replaced = null;
runSearch();
}, 300);
}).on('change', '.backup-tree-deleted', function() {
tree.onlyDeleted = this.checked;
tree.shownDomains = CHUNK;
renderTree();
}).on('click', '.backup-selection-clear', function(e) {
e.preventDefault();
setSelection(null);
}).on('click', '.backup-select-domain', function(e) {
e.preventDefault();
var sel = tree.selection;
if (sel) setSelection({domain: sel.domain, scope: 'domain', mailboxes: {}});
}).on('click', '.backup-selection-restore', function(e) {
e.preventDefault();
openRestore(selectionTarget());
}).on('click', '.backup-table-refresh', function(e) {
e.preventDefault();
reloadTable('#' + $(this).data('table'));
});
$('#backup_restore_form').on('change', '.backup-component', toggleSubOptions).on('submit', function(e) {
e.preventDefault();
submitRestore($(this).find('.backup-restore-submit'));
});
}
});
return {
showJob: showJob
};
})(jQuery);