Files
2026-07-27 12:58:44 +03:00

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);