mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
418 lines
18 KiB
JavaScript
418 lines
18 KiB
JavaScript
/**
|
|
* JS раздела «База данных»: вкладки, обслуживание таблиц (OPTIMIZE/REPAIR),
|
|
* создание, проверка, восстановление и удаление резервных копий. Ajax по контракту success/error,
|
|
* подтверждения — Adminx.Confirm.
|
|
*/
|
|
(function (window, document) {
|
|
'use strict';
|
|
|
|
var Adminx = window.Adminx || (window.Adminx = {});
|
|
|
|
Adminx.Database = {
|
|
init: function () {
|
|
if (!document.querySelector('[data-db-panel]')) { return; }
|
|
var self = this;
|
|
if (new URLSearchParams(window.location.search).get('tab') === 'backups') { self.switchTab('backups'); }
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var tab = e.target.closest('[data-db-tab]');
|
|
if (tab) { self.switchTab(tab.getAttribute('data-db-tab')); return; }
|
|
|
|
var opBtn = e.target.closest('[data-db-op]');
|
|
if (opBtn) { self.maintain(opBtn.getAttribute('data-db-op'), opBtn.closest('tr').dataset.table, opBtn); return; }
|
|
|
|
if (e.target.closest('[data-db-optimize-all]')) { self.optimizeAll(); return; }
|
|
if (e.target.closest('[data-db-schema-update]')) { self.updateSchema(); return; }
|
|
var backup = e.target.closest('[data-db-backup]');
|
|
if (backup) { self.backup(); return; }
|
|
if (e.target.closest('[data-db-backup-upload]')) {
|
|
var fileInput = document.querySelector('[data-db-backup-file]');
|
|
if (fileInput) { fileInput.click(); }
|
|
return;
|
|
}
|
|
|
|
if (e.target.closest('[data-db-convert-innodb]')) { self.convertToInnoDb(); return; }
|
|
|
|
var del = e.target.closest('[data-db-backup-delete]');
|
|
if (del) { self.deleteBackup(del.closest('tr')); return; }
|
|
|
|
var restore = e.target.closest('[data-db-backup-restore]');
|
|
if (restore) { self.inspectBackup(restore.closest('tr'), restore); return; }
|
|
|
|
if (e.target.closest('[data-db-restore-close]')) { self.closeRestoreProgress(); return; }
|
|
});
|
|
|
|
document.addEventListener('input', function (e) {
|
|
var filter = e.target.closest('[data-db-table-filter]');
|
|
if (filter) { self.filterTables(filter.value); }
|
|
});
|
|
|
|
document.addEventListener('change', function (e) {
|
|
var input = e.target.closest('[data-db-backup-file]');
|
|
if (input && input.files && input.files[0]) { self.uploadBackup(input); }
|
|
});
|
|
},
|
|
|
|
filterTables: function (query) {
|
|
query = (query || '').trim().toLowerCase();
|
|
var rows = document.querySelectorAll('[data-db-panel="tables"] tbody tr[data-table]');
|
|
var shown = 0;
|
|
rows.forEach(function (row) {
|
|
var match = query === '' || (row.getAttribute('data-table') || '').toLowerCase().indexOf(query) !== -1;
|
|
row.hidden = !match;
|
|
if (match) { shown++; }
|
|
});
|
|
var label = document.querySelector('[data-db-table-visible]');
|
|
if (label) { label.textContent = shown + ' из ' + rows.length; }
|
|
},
|
|
|
|
base: function () { return Adminx.base(); },
|
|
|
|
switchTab: function (name) {
|
|
document.querySelectorAll('[data-db-tab]').forEach(function (t) {
|
|
t.setAttribute('aria-selected', t.getAttribute('data-db-tab') === name ? 'true' : 'false');
|
|
});
|
|
document.querySelectorAll('[data-db-panel]').forEach(function (p) {
|
|
p.hidden = p.getAttribute('data-db-panel') !== name;
|
|
});
|
|
},
|
|
|
|
post: function (url, fd) {
|
|
Adminx.Loader.show();
|
|
return Adminx.Ajax.post(url, fd).then(function (payload) {
|
|
Adminx.Loader.hide();
|
|
return payload.data || {};
|
|
}).catch(function () {
|
|
Adminx.Loader.hide();
|
|
Adminx.Toast.show('Ошибка сети', 'error');
|
|
return { success: false };
|
|
});
|
|
},
|
|
|
|
maintain: function (op, table, btn) {
|
|
var fd = new FormData();
|
|
fd.append('op', op);
|
|
fd.append('table', table);
|
|
if (btn) { btn.disabled = true; }
|
|
this.post(this.base() + '/database/maintenance', fd).then(function (d) {
|
|
if (btn) { btn.disabled = false; }
|
|
Adminx.Toast.show(d.message || (d.success ? 'Готово' : 'Ошибка'), d.success ? 'success' : 'error');
|
|
});
|
|
},
|
|
|
|
optimizeAll: function () {
|
|
var self = this;
|
|
Adminx.Confirm.open({
|
|
kind: 'warning',
|
|
title: 'Оптимизировать все таблицы?',
|
|
message: 'Будет выполнен OPTIMIZE для всех таблиц текущего префикса.',
|
|
confirmLabel: 'Оптимизировать',
|
|
onConfirm: function () {
|
|
var fd = new FormData();
|
|
fd.append('op', 'optimize');
|
|
fd.append('table', '*');
|
|
self.post(self.base() + '/database/maintenance', fd).then(function (d) {
|
|
Adminx.Toast.show(d.message || 'Готово', d.success ? 'success' : 'error');
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
updateSchema: function () {
|
|
var self = this;
|
|
Adminx.Confirm.open({
|
|
kind: 'warning',
|
|
title: 'Применить миграции ядра?',
|
|
message: 'Будут выполнены ещё не применённые миграции встроенных разделов и синхронизированы права. Данные из резервных копий эта операция не читает.',
|
|
confirmLabel: 'Применить',
|
|
onConfirm: function () {
|
|
self.post(self.base() + '/database/schema/update', new FormData()).then(function (d) {
|
|
Adminx.Toast.show(d.message || (d.success ? 'Готово' : 'Ошибка'), d.success ? 'success' : 'error');
|
|
if (d.success) { window.setTimeout(function () { window.location.reload(); }, 500); }
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
inspectBackup: function (row, button) {
|
|
var self = this;
|
|
var name = row.dataset.backup;
|
|
var fd = new FormData();
|
|
fd.append('file', name);
|
|
button.disabled = true;
|
|
this.post(this.base() + '/database/backup/inspect', fd).then(function (d) {
|
|
button.disabled = false;
|
|
if (!d.success) {
|
|
Adminx.Toast.show(d.message || 'Не удалось проверить резервную копию', 'error');
|
|
return;
|
|
}
|
|
|
|
var info = d.data || {};
|
|
var message = 'Файл: ' + name + '. Таблиц: ' + (info.table_count || 0) +
|
|
', записей: ' + (info.inserts || 0) + ', дата копии: ' + (info.date || 'не указана') + '.';
|
|
if (info.warnings && info.warnings.length) { message += ' Внимание: ' + info.warnings.join(' '); }
|
|
message += ' Текущие данные будут заменены, но перед этим автоматически создастся страховочный бэкап.';
|
|
|
|
Adminx.Confirm.open({
|
|
kind: 'error',
|
|
title: 'Восстановить базу из копии?',
|
|
message: message,
|
|
confirmLabel: 'Восстановить',
|
|
confirmClass: 'btn-danger',
|
|
onConfirm: function () { self.restoreBackup(name, info.token); }
|
|
});
|
|
});
|
|
},
|
|
|
|
restoreBackup: function (name, token) {
|
|
var self = this;
|
|
var fd = new FormData();
|
|
fd.append('file', name);
|
|
fd.append('restore_token', token || '');
|
|
this.post(this.base() + '/database/backup/restore/start', fd).then(function (d) {
|
|
if (!d.success || !d.data || !d.data.job || !d.data.run_token) {
|
|
Adminx.Toast.show(d.message || 'Не удалось подготовить восстановление', 'error');
|
|
return;
|
|
}
|
|
|
|
self.openRestoreProgress(d.data.job);
|
|
self.runRestoreStream(d.data.job.id, d.data.run_token);
|
|
});
|
|
},
|
|
|
|
openRestoreProgress: function (job) {
|
|
var overlay = document.querySelector('[data-db-restore-progress]');
|
|
if (!overlay) { return; }
|
|
overlay.hidden = false;
|
|
document.body.classList.add('database-restore-active');
|
|
var close = overlay.querySelector('[data-db-restore-close]');
|
|
var error = overlay.querySelector('[data-db-restore-error]');
|
|
if (close) { close.hidden = true; }
|
|
if (error) { error.hidden = true; }
|
|
this.updateRestoreProgress(job || {});
|
|
},
|
|
|
|
closeRestoreProgress: function () {
|
|
var overlay = document.querySelector('[data-db-restore-progress]');
|
|
if (overlay) { overlay.hidden = true; }
|
|
document.body.classList.remove('database-restore-active');
|
|
},
|
|
|
|
updateRestoreProgress: function (job) {
|
|
var root = document.querySelector('[data-db-restore-progress]');
|
|
if (!root || !job) { return; }
|
|
var percent = Math.max(0, Math.min(100, parseInt(job.progress, 10) || 0));
|
|
var title = root.querySelector('[data-db-restore-title]');
|
|
var detail = root.querySelector('[data-db-restore-detail]');
|
|
var counter = root.querySelector('[data-db-restore-counter]');
|
|
var percentLabel = root.querySelector('[data-db-restore-percent]');
|
|
var track = root.querySelector('[data-db-restore-track]');
|
|
var bar = root.querySelector('[data-db-restore-bar]');
|
|
if (title) { title.textContent = job.title || 'Восстановление базы'; }
|
|
if (detail) { detail.textContent = job.detail || job.table || 'Выполняется операция'; }
|
|
if (percentLabel) { percentLabel.textContent = percent + '%'; }
|
|
if (track) { track.setAttribute('aria-valuenow', String(percent)); }
|
|
if (bar) { bar.style.width = percent + '%'; }
|
|
if (counter) {
|
|
counter.textContent = job.total && job.current
|
|
? 'Таблица ' + job.current + ' из ' + job.total
|
|
: (job.table ? job.table : 'Состояние сохраняется автоматически');
|
|
}
|
|
|
|
var order = ['verify', 'backup', 'clean', 'restore', 'cache'];
|
|
var active = order.indexOf(job.stage);
|
|
root.querySelectorAll('[data-restore-stage]').forEach(function (step) {
|
|
var index = order.indexOf(step.getAttribute('data-restore-stage'));
|
|
step.classList.toggle('is-active', index === active);
|
|
step.classList.toggle('is-complete', active > index || job.status === 'completed');
|
|
});
|
|
|
|
root.classList.toggle('is-complete', job.status === 'completed');
|
|
root.classList.toggle('is-error', job.status === 'failed');
|
|
if (job.status === 'failed') { this.showRestoreError(job.error || job.detail || 'Восстановление остановлено'); }
|
|
},
|
|
|
|
showRestoreError: function (message) {
|
|
var root = document.querySelector('[data-db-restore-progress]');
|
|
if (!root) { return; }
|
|
root.classList.add('is-error');
|
|
var error = root.querySelector('[data-db-restore-error]');
|
|
var close = root.querySelector('[data-db-restore-close]');
|
|
if (error) { error.hidden = false; error.querySelector('span').textContent = message; }
|
|
if (close) { close.hidden = false; }
|
|
},
|
|
|
|
finishRestore: function (payload) {
|
|
var root = document.querySelector('[data-db-restore-progress]');
|
|
if (root) {
|
|
root.classList.add('is-complete');
|
|
var close = root.querySelector('[data-db-restore-close]');
|
|
if (close) { close.hidden = false; }
|
|
}
|
|
Adminx.Toast.show(payload.message || 'База восстановлена', 'success');
|
|
window.setTimeout(function () {
|
|
window.location.href = payload.redirect || Adminx.Database.base() + '/database?tab=backups';
|
|
}, 900);
|
|
},
|
|
|
|
runRestoreStream: function (jobId, runToken) {
|
|
var self = this;
|
|
var terminal = false;
|
|
var fd = new FormData();
|
|
fd.append('job_id', jobId);
|
|
fd.append('run_token', runToken);
|
|
fd.append('_csrf', Adminx.csrf());
|
|
fetch(this.base() + '/database/backup/restore/run', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Accept': 'application/x-ndjson',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'X-CSRF-Token': Adminx.csrf()
|
|
},
|
|
body: fd
|
|
}).then(function (response) {
|
|
if (!response.ok || !response.body || !response.body.getReader) {
|
|
throw new Error('Сервер не открыл поток восстановления');
|
|
}
|
|
|
|
var reader = response.body.getReader();
|
|
var decoder = new TextDecoder('utf-8');
|
|
var buffer = '';
|
|
var consume = function () {
|
|
return reader.read().then(function (chunk) {
|
|
buffer += decoder.decode(chunk.value || new Uint8Array(), { stream: !chunk.done });
|
|
var lines = buffer.split('\n');
|
|
buffer = lines.pop();
|
|
lines.forEach(function (line) {
|
|
var type = self.handleRestoreEvent(line);
|
|
if (type === 'complete' || type === 'error') { terminal = true; }
|
|
});
|
|
if (!chunk.done) { return consume(); }
|
|
if (buffer.trim()) {
|
|
var type = self.handleRestoreEvent(buffer);
|
|
if (type === 'complete' || type === 'error') { terminal = true; }
|
|
}
|
|
if (!terminal) { self.pollRestoreStatus(jobId, 0); }
|
|
});
|
|
};
|
|
return consume();
|
|
}).catch(function () {
|
|
self.pollRestoreStatus(jobId, 0);
|
|
});
|
|
},
|
|
|
|
handleRestoreEvent: function (line) {
|
|
if (!String(line || '').trim()) { return ''; }
|
|
var payload;
|
|
try { payload = JSON.parse(line); } catch (e) { return ''; }
|
|
if (payload.type === 'progress' && payload.job) { this.updateRestoreProgress(payload.job); }
|
|
if (payload.type === 'complete') { this.finishRestore(payload); }
|
|
if (payload.type === 'error') { this.showRestoreError(payload.message || 'Восстановление остановлено'); }
|
|
return payload.type || '';
|
|
},
|
|
|
|
pollRestoreStatus: function (jobId, attempt) {
|
|
var self = this;
|
|
var fd = new FormData();
|
|
fd.append('job_id', jobId);
|
|
Adminx.Ajax.post(this.base() + '/database/backup/restore/status', fd).then(function (payload) {
|
|
var response = payload.data || {};
|
|
var job = response.data ? response.data.job : null;
|
|
if (!response.success || !job) {
|
|
self.showRestoreError('Соединение потеряно. Проверьте список резервных копий после обновления страницы.');
|
|
return;
|
|
}
|
|
|
|
self.updateRestoreProgress(job);
|
|
if (job.status === 'completed') {
|
|
self.finishRestore({ message: 'База восстановлена', result: job.result });
|
|
} else if (job.status === 'failed') {
|
|
self.showRestoreError(job.error || 'Восстановление остановлено');
|
|
} else if (attempt < 180) {
|
|
window.setTimeout(function () { self.pollRestoreStatus(jobId, attempt + 1); }, 2000);
|
|
} else {
|
|
self.showRestoreError('Восстановление выполняется дольше ожидаемого. Обновите страницу и проверьте состояние базы.');
|
|
}
|
|
}).catch(function () {
|
|
if (attempt < 10) { window.setTimeout(function () { self.pollRestoreStatus(jobId, attempt + 1); }, 2000); }
|
|
else { self.showRestoreError('Не удалось получить состояние восстановления.'); }
|
|
});
|
|
},
|
|
|
|
backup: function () {
|
|
var self = this;
|
|
Adminx.Confirm.open({
|
|
kind: 'info',
|
|
title: 'Создать резервную копию?',
|
|
message: 'Будет создан gzip-дамп единой рабочей схемы.',
|
|
confirmLabel: 'Создать',
|
|
onConfirm: function () {
|
|
var fd = new FormData();
|
|
self.post(self.base() + '/database/backup', fd).then(function (d) {
|
|
if (d.success) {
|
|
Adminx.Toast.show(d.message, 'success');
|
|
if (d.redirect) { window.location.href = d.redirect; } else { window.location.reload(); }
|
|
} else {
|
|
Adminx.Toast.show(d.message || 'Ошибка', 'error');
|
|
}
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
uploadBackup: function (input) {
|
|
var self = this;
|
|
var fd = new FormData();
|
|
fd.append('backup', input.files[0]);
|
|
this.post(this.base() + '/database/backup/upload', fd).then(function (d) {
|
|
input.value = '';
|
|
Adminx.Toast.show(d.message || (d.success ? 'Копия загружена' : 'Ошибка загрузки'), d.success ? 'success' : 'error');
|
|
if (d.success) { window.location.href = d.redirect || self.base() + '/database'; }
|
|
});
|
|
},
|
|
|
|
convertToInnoDb: function () {
|
|
var self = this;
|
|
Adminx.Confirm.open({
|
|
kind: 'warning',
|
|
title: 'Перевести MyISAM в InnoDB?',
|
|
message: 'Сначала создайте полный бэкап. ALTER TABLE может временно заблокировать запись, поэтому на production выполняйте операцию в окно обслуживания.',
|
|
confirmLabel: 'Перевести',
|
|
onConfirm: function () {
|
|
self.post(self.base() + '/database/engines/innodb', new FormData()).then(function (d) {
|
|
Adminx.Toast.show(d.message || (d.success ? 'Готово' : 'Ошибка'), d.success ? 'success' : 'error');
|
|
if (d.success) { window.location.reload(); }
|
|
});
|
|
}
|
|
});
|
|
},
|
|
|
|
deleteBackup: function (row) {
|
|
var self = this;
|
|
var name = row.dataset.backup;
|
|
Adminx.Confirm.open({
|
|
kind: 'error',
|
|
title: 'Удалить резервную копию?',
|
|
message: name + ' будет удалён без возможности восстановления.',
|
|
confirmLabel: 'Удалить',
|
|
confirmClass: 'btn-danger',
|
|
onConfirm: function () {
|
|
var fd = new FormData();
|
|
fd.append('file', name);
|
|
self.post(self.base() + '/database/backup/delete', fd).then(function (d) {
|
|
if (d.success) { row.remove(); Adminx.Toast.show(d.message, 'success'); }
|
|
else { Adminx.Toast.show(d.message || 'Ошибка', 'error'); }
|
|
});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () { Adminx.Database.init(); });
|
|
} else {
|
|
Adminx.Database.init();
|
|
}
|
|
})(window, document);
|