что то амт
This commit is contained in:
@@ -151,16 +151,27 @@
|
||||
`${dbInfo.host}:${dbInfo.port}/${dbInfo.database}`;
|
||||
document.getElementById('roleBadge').textContent = this.currentUser.role || 'viewer';
|
||||
document.getElementById('logsButton').classList.toggle('hidden', !this.getPermissions().canViewLogs);
|
||||
document.getElementById('usersButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||||
document.getElementById('backupsButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||||
document.getElementById('settingsButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||||
document.getElementById('auditButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||||
document.getElementById('managementButton').classList.toggle('hidden', !this.getPermissions().canManageUsers);
|
||||
document.querySelector('button[onclick="app.showSQLPanel()"]').style.display = this.getPermissions().canRunSql ? '' : 'none';
|
||||
document.querySelector('button[onclick="app.showCreateTableModal()"]').style.display = this.getPermissions().canCreate ? '' : 'none';
|
||||
|
||||
this.loadTables();
|
||||
}
|
||||
|
||||
hideWorkspacePanels() {
|
||||
['emptyState', 'dataGrid', 'sqlPanel', 'logsPanel', 'managementPanel'].forEach((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node) {
|
||||
node.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setToolbarMode(mode) {
|
||||
document.getElementById('tableActions').classList.toggle('hidden', mode !== 'table');
|
||||
document.getElementById('recordControls').classList.toggle('hidden', mode !== 'table');
|
||||
}
|
||||
|
||||
getPermissions() {
|
||||
if (this.currentUser?.role === 'superadmin') {
|
||||
return {
|
||||
@@ -293,10 +304,8 @@
|
||||
this.closeSidebar();
|
||||
|
||||
document.getElementById('currentTableTitle').textContent = tableName;
|
||||
document.getElementById('tableActions').classList.remove('hidden');
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
document.getElementById('sqlPanel').classList.add('hidden');
|
||||
document.getElementById('logsPanel').classList.add('hidden');
|
||||
this.setToolbarMode('table');
|
||||
this.hideWorkspacePanels();
|
||||
document.getElementById('dataGrid').classList.remove('hidden');
|
||||
|
||||
// Update action buttons based on permissions
|
||||
@@ -841,10 +850,10 @@
|
||||
this.showToast('SQL доступ разрешен только администраторам', 'error');
|
||||
return;
|
||||
}
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
document.getElementById('dataGrid').classList.add('hidden');
|
||||
document.getElementById('logsPanel').classList.add('hidden');
|
||||
this.setToolbarMode('workspace');
|
||||
this.hideWorkspacePanels();
|
||||
document.getElementById('sqlPanel').classList.remove('hidden');
|
||||
document.getElementById('currentTableTitle').textContent = 'SQL Query';
|
||||
}
|
||||
|
||||
executeSQL() {
|
||||
@@ -936,10 +945,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('emptyState').classList.add('hidden');
|
||||
document.getElementById('dataGrid').classList.add('hidden');
|
||||
document.getElementById('sqlPanel').classList.add('hidden');
|
||||
this.setToolbarMode('workspace');
|
||||
this.hideWorkspacePanels();
|
||||
document.getElementById('logsPanel').classList.remove('hidden');
|
||||
document.getElementById('currentTableTitle').textContent = 'Container logs';
|
||||
await this.loadContainers();
|
||||
}
|
||||
|
||||
@@ -1070,13 +1079,41 @@
|
||||
return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`)).map(input => input.value);
|
||||
}
|
||||
|
||||
async showManagementPanel(section = 'settings') {
|
||||
if (!this.getPermissions().canManageUsers) {
|
||||
this.showToast('Management is available only for admins', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.setToolbarMode('workspace');
|
||||
this.hideWorkspacePanels();
|
||||
document.getElementById('managementPanel').classList.remove('hidden');
|
||||
document.getElementById('currentTableTitle').textContent = 'Management';
|
||||
|
||||
['settings', 'backups', 'users', 'audit'].forEach((item) => {
|
||||
document.getElementById(`managementSection-${item}`).classList.toggle('hidden', item !== section);
|
||||
document.getElementById(`managementTab-${item}`).className = item === section
|
||||
? 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left bg-slate-100 text-slate-900'
|
||||
: 'w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100';
|
||||
});
|
||||
|
||||
if (section === 'settings') {
|
||||
await this.loadSettings();
|
||||
} else if (section === 'backups') {
|
||||
await this.loadBackups();
|
||||
} else if (section === 'users') {
|
||||
this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name));
|
||||
await this.loadUsers();
|
||||
this.resetUserForm();
|
||||
} else if (section === 'audit') {
|
||||
await this.loadAuditLog();
|
||||
}
|
||||
}
|
||||
|
||||
async showUsersModal() {
|
||||
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name));
|
||||
await this.loadUsers();
|
||||
this.resetUserForm();
|
||||
document.getElementById('usersModal').classList.remove('hidden');
|
||||
await this.showManagementPanel('users');
|
||||
}
|
||||
|
||||
async loadUsers() {
|
||||
@@ -1087,7 +1124,7 @@
|
||||
throw new Error(users.error || 'Failed to load users');
|
||||
}
|
||||
|
||||
document.getElementById('usersTableBody').innerHTML = users.map(user => `
|
||||
document.getElementById('managementUsersTableBody').innerHTML = users.map(user => `
|
||||
<tr class="border-b border-slate-200">
|
||||
<td class="p-3">${user.username}</td>
|
||||
<td class="p-3">${user.role}</td>
|
||||
@@ -1105,43 +1142,43 @@
|
||||
|
||||
editUser(serializedUser) {
|
||||
const user = JSON.parse(serializedUser);
|
||||
document.getElementById('userEditMode').value = user.username;
|
||||
document.getElementById('userUsername').value = user.username;
|
||||
document.getElementById('userUsername').disabled = true;
|
||||
document.getElementById('userPassword').value = '';
|
||||
document.getElementById('userRole').value = user.role;
|
||||
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []);
|
||||
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []);
|
||||
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []);
|
||||
document.getElementById('userDisabled').checked = Boolean(user.disabled);
|
||||
document.getElementById('managementUserEditMode').value = user.username;
|
||||
document.getElementById('managementUserUsername').value = user.username;
|
||||
document.getElementById('managementUserUsername').disabled = true;
|
||||
document.getElementById('managementUserPassword').value = '';
|
||||
document.getElementById('managementUserRole').value = user.role;
|
||||
this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.view?.folders || []);
|
||||
this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'), user.access?.delete?.folders || []);
|
||||
this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name), user.access?.edit?.tables || []);
|
||||
document.getElementById('managementUserDisabled').checked = Boolean(user.disabled);
|
||||
}
|
||||
|
||||
resetUserForm() {
|
||||
document.getElementById('userEditMode').value = '';
|
||||
document.getElementById('userUsername').value = '';
|
||||
document.getElementById('userUsername').disabled = false;
|
||||
document.getElementById('userPassword').value = '';
|
||||
document.getElementById('userRole').value = 'viewer';
|
||||
this.renderOptionChecklist('userViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('userDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('userEditTablesList', this.tables.map(table => table.name));
|
||||
document.getElementById('userDisabled').checked = false;
|
||||
document.getElementById('managementUserEditMode').value = '';
|
||||
document.getElementById('managementUserUsername').value = '';
|
||||
document.getElementById('managementUserUsername').disabled = false;
|
||||
document.getElementById('managementUserPassword').value = '';
|
||||
document.getElementById('managementUserRole').value = 'viewer';
|
||||
this.renderOptionChecklist('managementUserViewFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('managementUserDeleteFoldersList', this.getAvailableFolders().filter(folder => folder !== 'default'));
|
||||
this.renderOptionChecklist('managementUserEditTablesList', this.tables.map(table => table.name));
|
||||
document.getElementById('managementUserDisabled').checked = false;
|
||||
}
|
||||
|
||||
async saveUser() {
|
||||
const username = document.getElementById('userUsername').value.trim();
|
||||
const editMode = document.getElementById('userEditMode').value;
|
||||
const password = document.getElementById('userPassword').value;
|
||||
const username = document.getElementById('managementUserUsername').value.trim();
|
||||
const editMode = document.getElementById('managementUserEditMode').value;
|
||||
const password = document.getElementById('managementUserPassword').value;
|
||||
const payload = {
|
||||
username,
|
||||
password,
|
||||
role: document.getElementById('userRole').value,
|
||||
disabled: document.getElementById('userDisabled').checked,
|
||||
role: document.getElementById('managementUserRole').value,
|
||||
disabled: document.getElementById('managementUserDisabled').checked,
|
||||
access: {
|
||||
view: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] },
|
||||
create: { folders: this.getCheckedValues('userViewFoldersList'), tables: [] },
|
||||
edit: { folders: [], tables: this.getCheckedValues('userEditTablesList') },
|
||||
delete: { folders: this.getCheckedValues('userDeleteFoldersList'), tables: [] },
|
||||
view: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] },
|
||||
create: { folders: this.getCheckedValues('managementUserViewFoldersList'), tables: [] },
|
||||
edit: { folders: [], tables: this.getCheckedValues('managementUserEditTablesList') },
|
||||
delete: { folders: this.getCheckedValues('managementUserDeleteFoldersList'), tables: [] },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1219,18 +1256,15 @@
|
||||
}
|
||||
|
||||
async showAuditModal() {
|
||||
await this.loadAuditLog();
|
||||
document.getElementById('auditModal').classList.remove('hidden');
|
||||
await this.showManagementPanel('audit');
|
||||
}
|
||||
|
||||
async showBackupsModal() {
|
||||
await this.loadBackups();
|
||||
document.getElementById('backupsModal').classList.remove('hidden');
|
||||
await this.showManagementPanel('backups');
|
||||
}
|
||||
|
||||
async showSettingsModal() {
|
||||
await this.loadSettings();
|
||||
document.getElementById('settingsModal').classList.remove('hidden');
|
||||
await this.showManagementPanel('settings');
|
||||
}
|
||||
|
||||
async loadBackups() {
|
||||
@@ -1241,14 +1275,17 @@
|
||||
throw new Error(backups.error || 'Failed to load backups');
|
||||
}
|
||||
|
||||
document.getElementById('backupsList').innerHTML = backups.length
|
||||
document.getElementById('managementBackupsList').innerHTML = backups.length
|
||||
? backups.map(backup => `
|
||||
<div class="border border-slate-200 rounded-xl p-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="font-medium text-slate-800">${backup.filename}</div>
|
||||
<div class="text-sm text-slate-500">${backup.createdAt} - ${backup.kind} - ${backup.size} bytes</div>
|
||||
</div>
|
||||
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
|
||||
<div class="flex items-center gap-2">
|
||||
<button onclick='app.restoreBackup(${JSON.stringify(backup.filename)})' class="px-4 py-2 bg-amber-50 text-amber-700 rounded-lg">Restore</button>
|
||||
<a href="/api/backups/${encodeURIComponent(backup.filename)}/download" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Download</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')
|
||||
: '<div class="text-sm text-slate-500">No backups yet.</div>';
|
||||
@@ -1267,13 +1304,44 @@
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to create backup');
|
||||
}
|
||||
this.showToast('Backup created', 'success');
|
||||
this.showToast('Archive created', 'success');
|
||||
this.loadBackups();
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async restoreBackup(filename) {
|
||||
if (!confirm(`Restore backup ${filename}? The current database will be replaced.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/backups/${encodeURIComponent(filename)}/restore`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Request-Source': 'WEB',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
restoreAppSnapshot: document.getElementById('managementRestoreAppSnapshot').checked,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(result.error || 'Failed to restore backup');
|
||||
}
|
||||
|
||||
this.showToast('Backup restored', 'success');
|
||||
await this.loadTables();
|
||||
if (this.currentTable) {
|
||||
await this.selectTable(this.currentTable);
|
||||
}
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
@@ -1283,32 +1351,32 @@
|
||||
}
|
||||
|
||||
this.currentSettings = settings;
|
||||
document.getElementById('settingsBackupsEnabled').checked = Boolean(settings.backups?.enabled);
|
||||
document.getElementById('settingsBackupTime').value = `${String(settings.backups?.hour ?? 3).padStart(2, '0')}:${String(settings.backups?.minute ?? 0).padStart(2, '0')}`;
|
||||
document.getElementById('settingsKeepLast').value = settings.backups?.keepLast ?? 14;
|
||||
document.getElementById('settingsIncludeAppSnapshot').checked = settings.backups?.includeAppSnapshot !== false;
|
||||
document.getElementById('settingsTelegramEnabled').checked = Boolean(settings.telegram?.enabled);
|
||||
document.getElementById('settingsTelegramToken').value = settings.telegram?.botToken || '';
|
||||
document.getElementById('settingsTelegramChatId').value = settings.telegram?.chatId || '';
|
||||
document.getElementById('managementSettingsBackupsEnabled').checked = Boolean(settings.backups?.enabled);
|
||||
document.getElementById('managementSettingsBackupTime').value = `${String(settings.backups?.hour ?? 3).padStart(2, '0')}:${String(settings.backups?.minute ?? 0).padStart(2, '0')}`;
|
||||
document.getElementById('managementSettingsKeepLast').value = settings.backups?.keepLast ?? 14;
|
||||
document.getElementById('managementSettingsIncludeAppSnapshot').checked = settings.backups?.includeAppSnapshot !== false;
|
||||
document.getElementById('managementSettingsTelegramEnabled').checked = Boolean(settings.telegram?.enabled);
|
||||
document.getElementById('managementSettingsTelegramToken').value = settings.telegram?.botToken || '';
|
||||
document.getElementById('managementSettingsTelegramChatId').value = settings.telegram?.chatId || '';
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const [hour, minute] = (document.getElementById('settingsBackupTime').value || '03:00').split(':').map(Number);
|
||||
const [hour, minute] = (document.getElementById('managementSettingsBackupTime').value || '03:00').split(':').map(Number);
|
||||
const payload = {
|
||||
backups: {
|
||||
enabled: document.getElementById('settingsBackupsEnabled').checked,
|
||||
enabled: document.getElementById('managementSettingsBackupsEnabled').checked,
|
||||
hour,
|
||||
minute,
|
||||
keepLast: Number(document.getElementById('settingsKeepLast').value || 14),
|
||||
includeAppSnapshot: document.getElementById('settingsIncludeAppSnapshot').checked,
|
||||
keepLast: Number(document.getElementById('managementSettingsKeepLast').value || 14),
|
||||
includeAppSnapshot: document.getElementById('managementSettingsIncludeAppSnapshot').checked,
|
||||
},
|
||||
telegram: {
|
||||
enabled: document.getElementById('settingsTelegramEnabled').checked,
|
||||
botToken: document.getElementById('settingsTelegramToken').value.trim(),
|
||||
chatId: document.getElementById('settingsTelegramChatId').value.trim(),
|
||||
enabled: document.getElementById('managementSettingsTelegramEnabled').checked,
|
||||
botToken: document.getElementById('managementSettingsTelegramToken').value.trim(),
|
||||
chatId: document.getElementById('managementSettingsTelegramChatId').value.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1328,7 +1396,6 @@
|
||||
|
||||
this.currentSettings = result.settings;
|
||||
this.showToast('Settings saved', 'success');
|
||||
this.closeModal('settingsModal');
|
||||
} catch (err) {
|
||||
this.showToast(err.message, 'error');
|
||||
}
|
||||
@@ -1342,7 +1409,7 @@
|
||||
throw new Error(entries.error || 'Failed to load audit log');
|
||||
}
|
||||
|
||||
document.getElementById('auditList').innerHTML = entries.length
|
||||
document.getElementById('managementAuditList').innerHTML = entries.length
|
||||
? entries.map(entry => `
|
||||
<div class="border border-slate-200 rounded-xl p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -66,21 +66,9 @@
|
||||
<option value="dark">Dark</option>
|
||||
<option value="system">System</option>
|
||||
</select>
|
||||
<button id="usersButton" onclick="app.showUsersModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
Users
|
||||
</button>
|
||||
<button id="backupsButton" onclick="app.showBackupsModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="archive" class="w-4 h-4"></i>
|
||||
Backups
|
||||
</button>
|
||||
<button id="settingsButton" onclick="app.showSettingsModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="sliders-horizontal" class="w-4 h-4"></i>
|
||||
Settings
|
||||
</button>
|
||||
<button id="auditButton" onclick="app.showAuditModal()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="history" class="w-4 h-4"></i>
|
||||
Audit
|
||||
<button id="managementButton" onclick="app.showManagementPanel('settings')" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="panel-left-open" class="w-4 h-4"></i>
|
||||
Menu
|
||||
</button>
|
||||
<button id="logsButton" onclick="app.showLogsPanel()" class="hidden flex items-center gap-2 px-4 py-2 bg-slate-100 text-slate-700 rounded-lg hover:bg-slate-200 transition-colors text-sm font-medium">
|
||||
<i data-lucide="scroll-text" class="w-4 h-4"></i>
|
||||
@@ -260,6 +248,178 @@ SELECT * FROM users LIMIT 10;"></textarea>
|
||||
</div>
|
||||
<div class="bg-slate-900 text-slate-200 rounded-xl border border-slate-800 p-4 font-mono text-sm log-terminal overflow-auto" id="logOutput">Select a container to load recent logs.</div>
|
||||
</div>
|
||||
<div id="managementPanel" class="hidden h-full">
|
||||
<div class="grid lg:grid-cols-[240px,1fr] gap-6 h-full">
|
||||
<aside class="bg-white rounded-2xl border border-slate-200 p-4 space-y-2">
|
||||
<button id="managementTab-settings" onclick="app.showManagementPanel('settings')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left bg-slate-100 text-slate-700">
|
||||
<i data-lucide="sliders-horizontal" class="w-4 h-4"></i>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button id="managementTab-backups" onclick="app.showManagementPanel('backups')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100">
|
||||
<i data-lucide="archive" class="w-4 h-4"></i>
|
||||
<span>Backups</span>
|
||||
</button>
|
||||
<button id="managementTab-users" onclick="app.showManagementPanel('users')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100">
|
||||
<i data-lucide="users" class="w-4 h-4"></i>
|
||||
<span>Users</span>
|
||||
</button>
|
||||
<button id="managementTab-audit" onclick="app.showManagementPanel('audit')" class="w-full flex items-center gap-3 px-4 py-3 rounded-xl text-left text-slate-700 hover:bg-slate-100">
|
||||
<i data-lucide="history" class="w-4 h-4"></i>
|
||||
<span>Audit</span>
|
||||
</button>
|
||||
</aside>
|
||||
<section class="min-h-0">
|
||||
<div id="managementSection-settings" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto p-6 space-y-6">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-slate-800">System settings</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">Telegram, automatic backups and retention are configured here.</p>
|
||||
</div>
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-800">Automatic backups</h4>
|
||||
<p class="text-sm text-slate-500">The panel creates a database archive and, optionally, a site snapshot every day.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="managementSettingsBackupsEnabled" class="w-4 h-4">
|
||||
Enable automatic backups
|
||||
</label>
|
||||
<div class="grid sm:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Time</label>
|
||||
<input type="time" id="managementSettingsBackupTime" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Keep last archives</label>
|
||||
<input type="number" id="managementSettingsKeepLast" min="1" max="90" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="managementSettingsIncludeAppSnapshot" class="w-4 h-4">
|
||||
Include site settings and audit snapshot
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-slate-800">Telegram notifications</h4>
|
||||
<p class="text-sm text-slate-500">Used for backup errors, restore events and scheduled notifications.</p>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="managementSettingsTelegramEnabled" class="w-4 h-4">
|
||||
Enable Telegram notifications
|
||||
</label>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Bot token</label>
|
||||
<input type="text" id="managementSettingsTelegramToken" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="123456:ABC...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Chat ID</label>
|
||||
<input type="text" id="managementSettingsTelegramChatId" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="-1001234567890">
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="flex justify-end">
|
||||
<button onclick="app.saveSettings()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">Save settings</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="managementSection-backups" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto p-6 space-y-4">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-slate-800">Backups</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">Archives contain the SQL dump and, if enabled, the application snapshot.</p>
|
||||
</div>
|
||||
<button onclick="app.createBackup()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Create archive</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="managementRestoreAppSnapshot" class="w-4 h-4" checked>
|
||||
Restore users, settings and audit snapshot together with the database
|
||||
</label>
|
||||
<div id="managementBackupsList" class="space-y-3"></div>
|
||||
</div>
|
||||
<div id="managementSection-users" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto">
|
||||
<div class="grid xl:grid-cols-[1.15fr,0.85fr] min-h-full">
|
||||
<div class="border-r border-slate-200 overflow-auto">
|
||||
<div class="p-6 border-b border-slate-200">
|
||||
<h3 class="text-2xl font-bold text-slate-800">Users</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">Create accounts and tune access to folders, tables and destructive actions.</p>
|
||||
</div>
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-slate-50 border-b border-slate-200">
|
||||
<tr>
|
||||
<th class="text-left p-3">Username</th>
|
||||
<th class="text-left p-3">Role</th>
|
||||
<th class="text-left p-3">Status</th>
|
||||
<th class="text-left p-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="managementUsersTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="p-6 overflow-auto space-y-3">
|
||||
<input type="hidden" id="managementUserEditMode" value="">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Username</label>
|
||||
<input type="text" id="managementUserUsername" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Password</label>
|
||||
<input type="text" id="managementUserPassword" class="w-full px-4 py-2 border border-slate-300 rounded-lg" placeholder="Leave empty to keep current">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Role</label>
|
||||
<select id="managementUserRole" class="w-full px-4 py-2 border border-slate-300 rounded-lg">
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="moderator">moderator</option>
|
||||
<option value="admin">admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Readable folders</label>
|
||||
<details class="border border-slate-300 rounded-lg p-3">
|
||||
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
|
||||
<div id="managementUserViewFoldersList" class="mt-3 grid gap-2"></div>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Editable tables</label>
|
||||
<details class="border border-slate-300 rounded-lg p-3">
|
||||
<summary class="cursor-pointer text-sm text-slate-700">Choose tables</summary>
|
||||
<div id="managementUserEditTablesList" class="mt-3 grid gap-2 max-h-48 overflow-auto"></div>
|
||||
</details>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Deletable folders</label>
|
||||
<details class="border border-slate-300 rounded-lg p-3">
|
||||
<summary class="cursor-pointer text-sm text-slate-700">Choose folders</summary>
|
||||
<div id="managementUserDeleteFoldersList" class="mt-3 grid gap-2"></div>
|
||||
</details>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" id="managementUserDisabled" class="w-4 h-4">
|
||||
Disable login
|
||||
</label>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button onclick="app.resetUserForm()" class="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg">Reset</button>
|
||||
<button onclick="app.saveUser()" class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">Save user</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="managementSection-audit" class="hidden bg-white rounded-2xl border border-slate-200 h-full overflow-auto p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold text-slate-800">Audit log</h3>
|
||||
<p class="text-sm text-slate-500 mt-1">Human-readable actions with time, source and actor.</p>
|
||||
</div>
|
||||
<button onclick="app.loadAuditLog()" class="px-4 py-2 bg-slate-100 text-slate-700 rounded-lg">Refresh</button>
|
||||
</div>
|
||||
<div id="managementAuditList" class="space-y-3"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
30
server.js
30
server.js
@@ -36,6 +36,7 @@ const {
|
||||
getBackupPath,
|
||||
listBackups,
|
||||
pruneBackups,
|
||||
restoreBackup,
|
||||
} = require('./src/services/backups');
|
||||
const {
|
||||
notifyError,
|
||||
@@ -136,10 +137,10 @@ async function runScheduledBackupIfNeeded() {
|
||||
|
||||
lastAutoBackupSlot = slot;
|
||||
appendAudit('backup.auto_created', 'system', {
|
||||
files: backup.files,
|
||||
filename: backup.filename,
|
||||
source: 'WEB',
|
||||
});
|
||||
notifyInfo('Scheduled backup completed', backup.files.map((file) => `${file.kind}: ${file.filename}`)).catch(() => {});
|
||||
notifyInfo('Scheduled backup completed', [backup.filename]).catch(() => {});
|
||||
}
|
||||
|
||||
function startBackupScheduler() {
|
||||
@@ -405,7 +406,7 @@ app.post('/api/backups', requireAuth, requirePermission(
|
||||
includeAppSnapshot: settings.backups.includeAppSnapshot,
|
||||
keepLast: settings.backups.keepLast,
|
||||
});
|
||||
appendAudit('backup.created', req.currentUser.username, { files: backup.files, source: getAuditSource(req) });
|
||||
appendAudit('backup.created', req.currentUser.username, { filename: backup.filename, source: getAuditSource(req) });
|
||||
res.json({ success: true, backup });
|
||||
} catch (err) {
|
||||
notifyError('Backup creation failed', err, { actor: req.currentUser.username }).catch(() => {});
|
||||
@@ -413,6 +414,29 @@ app.post('/api/backups', requireAuth, requirePermission(
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/backups/:filename/restore', requireAuth, requirePermission(
|
||||
(permissions) => permissions.canManageUsers,
|
||||
'Backup access denied'
|
||||
), async (req, res) => {
|
||||
try {
|
||||
const result = await restoreBackup(req.params.filename, {
|
||||
restoreAppSnapshot: req.body?.restoreAppSnapshot !== false,
|
||||
});
|
||||
appendAudit('backup.restored', req.currentUser.username, {
|
||||
filename: req.params.filename,
|
||||
source: getAuditSource(req),
|
||||
});
|
||||
notifyInfo('Backup restored', [req.params.filename, `actor: ${req.currentUser.username}`]).catch(() => {});
|
||||
res.json({ success: true, result });
|
||||
} catch (err) {
|
||||
notifyError('Backup restore failed', err, {
|
||||
actor: req.currentUser.username,
|
||||
filename: req.params.filename,
|
||||
}).catch(() => {});
|
||||
res.status(500).json({ success: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/settings', requireAuth, requirePermission(
|
||||
(permissions) => permissions.canManageUsers,
|
||||
'Settings access denied'
|
||||
|
||||
@@ -11,8 +11,9 @@ function formatAuditSummary(event, details = {}) {
|
||||
'user.created': `created user ${details.username || ''}`.trim(),
|
||||
'user.updated': `updated user ${details.username || ''}`.trim(),
|
||||
'user.deleted': `deleted user ${details.username || ''}`.trim(),
|
||||
'backup.created': `created backup ${details.filename || details.files?.map((file) => file.filename).join(', ') || ''}`.trim(),
|
||||
'backup.auto_created': `created scheduled backup ${details.files?.map((file) => file.filename).join(', ') || ''}`.trim(),
|
||||
'backup.created': `created backup ${details.filename || ''}`.trim(),
|
||||
'backup.auto_created': `created scheduled backup ${details.filename || ''}`.trim(),
|
||||
'backup.restored': `restored backup ${details.filename || ''}`.trim(),
|
||||
'settings.updated': 'updated system settings',
|
||||
'table.created': `created table ${details.table || ''}`.trim(),
|
||||
'table.deleted': `deleted table ${details.table || ''}`.trim(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
@@ -7,6 +8,7 @@ const USERS_FILE = path.join(__dirname, '..', '..', 'users.json');
|
||||
const AUDIT_LOG_FILE = path.join(__dirname, '..', '..', 'audit.log');
|
||||
const SETTINGS_FILE = path.join(__dirname, '..', '..', 'settings.json');
|
||||
const BACKUP_PREFIX = 'backup-';
|
||||
const BACKUP_EXTENSION = '.tar.gz';
|
||||
|
||||
function ensureBackupsDir() {
|
||||
fs.mkdirSync(BACKUPS_DIR, { recursive: true });
|
||||
@@ -16,6 +18,42 @@ function makeBackupStamp() {
|
||||
return new Date().toISOString().replace(/[:.]/g, '-');
|
||||
}
|
||||
|
||||
function makeTempDir() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'pg-admin-backup-'));
|
||||
}
|
||||
|
||||
function cleanupDir(dirPath) {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, options);
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
|
||||
if (child.stdout) {
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
}
|
||||
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(Buffer.concat(stderr).toString('utf8') || `${command} exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdout).toString('utf8'),
|
||||
stderr: Buffer.concat(stderr).toString('utf8'),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function collectAppSnapshot(pool, actor = 'system') {
|
||||
const tablesResult = await pool.query(`
|
||||
SELECT table_name
|
||||
@@ -49,7 +87,7 @@ async function collectAppSnapshot(pool, actor = 'system') {
|
||||
meta: {
|
||||
createdAt: new Date().toISOString(),
|
||||
createdBy: actor,
|
||||
version: 1,
|
||||
version: 2,
|
||||
},
|
||||
users: fs.existsSync(USERS_FILE) ? JSON.parse(fs.readFileSync(USERS_FILE, 'utf8')) : { users: [] },
|
||||
settings: fs.existsSync(SETTINGS_FILE) ? JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8')) : null,
|
||||
@@ -60,60 +98,72 @@ async function collectAppSnapshot(pool, actor = 'system') {
|
||||
};
|
||||
}
|
||||
|
||||
function createArchive(tempDir, archivePath, fileNames) {
|
||||
return runCommand('tar', ['-czf', archivePath, '-C', tempDir, ...fileNames], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function extractArchive(archivePath, tempDir) {
|
||||
return runCommand('tar', ['-xzf', archivePath, '-C', tempDir], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function runPgDump() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = [
|
||||
'-h', process.env.DB_HOST || 'db',
|
||||
'-p', String(process.env.DB_PORT || '5432'),
|
||||
'-U', process.env.DB_USER || 'postgres',
|
||||
'-d', process.env.DB_NAME || 'postgres',
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
];
|
||||
const args = [
|
||||
'-h', process.env.DB_HOST || 'db',
|
||||
'-p', String(process.env.DB_PORT || '5432'),
|
||||
'-U', process.env.DB_USER || 'postgres',
|
||||
'-d', process.env.DB_NAME || 'postgres',
|
||||
'--clean',
|
||||
'--if-exists',
|
||||
'--no-owner',
|
||||
'--no-privileges',
|
||||
];
|
||||
|
||||
const child = spawn('pg_dump', args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.DB_PASSWORD || '',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
return runCommand('pg_dump', args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.DB_PASSWORD || '',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).then((result) => result.stdout);
|
||||
}
|
||||
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(Buffer.concat(stderr).toString('utf8') || `pg_dump exited with code ${code}`));
|
||||
return;
|
||||
}
|
||||
resolve(Buffer.concat(stdout).toString('utf8'));
|
||||
});
|
||||
function runPsqlFile(filePath) {
|
||||
const args = [
|
||||
'-h', process.env.DB_HOST || 'db',
|
||||
'-p', String(process.env.DB_PORT || '5432'),
|
||||
'-U', process.env.DB_USER || 'postgres',
|
||||
'-d', process.env.DB_NAME || 'postgres',
|
||||
'-v', 'ON_ERROR_STOP=1',
|
||||
'-f', filePath,
|
||||
];
|
||||
|
||||
return runCommand('psql', args, {
|
||||
env: {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.DB_PASSWORD || '',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
function formatBackupEntry(filePath, filename) {
|
||||
const stats = fs.statSync(filePath);
|
||||
const kind = filename.endsWith('.sql') ? 'database' : 'application';
|
||||
const match = filename.match(/^backup-(.+?)-(db|app)\.(sql|json)$/);
|
||||
return {
|
||||
filename,
|
||||
size: stats.size,
|
||||
createdAt: stats.birthtime.toISOString(),
|
||||
kind,
|
||||
bundle: match ? match[1] : null,
|
||||
kind: 'archive',
|
||||
};
|
||||
}
|
||||
|
||||
function pruneBackups(keepLast = 14) {
|
||||
ensureBackupsDir();
|
||||
const maxFiles = Math.max(1, keepLast) * 2;
|
||||
const files = fs.readdirSync(BACKUPS_DIR)
|
||||
.filter((name) => name.startsWith(BACKUP_PREFIX) && (name.endsWith('.json') || name.endsWith('.sql')))
|
||||
.filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_EXTENSION))
|
||||
.map((name) => ({
|
||||
name,
|
||||
filePath: path.join(BACKUPS_DIR, name),
|
||||
@@ -121,46 +171,49 @@ function pruneBackups(keepLast = 14) {
|
||||
}))
|
||||
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
||||
|
||||
files.slice(maxFiles).forEach((file) => {
|
||||
files.slice(Math.max(1, keepLast)).forEach((file) => {
|
||||
fs.unlinkSync(file.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
async function createBackup(pool, actor = 'system', options = {}) {
|
||||
ensureBackupsDir();
|
||||
|
||||
const tempDir = makeTempDir();
|
||||
const stamp = makeBackupStamp();
|
||||
const createdAt = new Date().toISOString();
|
||||
const includeAppSnapshot = options.includeAppSnapshot !== false;
|
||||
const files = [];
|
||||
const archiveFilename = `${BACKUP_PREFIX}${stamp}${BACKUP_EXTENSION}`;
|
||||
const archivePath = path.join(BACKUPS_DIR, archiveFilename);
|
||||
const fileNames = [];
|
||||
|
||||
const sqlDump = await runPgDump();
|
||||
const sqlFilename = `${BACKUP_PREFIX}${stamp}-db.sql`;
|
||||
const sqlPath = path.join(BACKUPS_DIR, sqlFilename);
|
||||
fs.writeFileSync(sqlPath, sqlDump, 'utf8');
|
||||
files.push(formatBackupEntry(sqlPath, sqlFilename));
|
||||
try {
|
||||
const sqlDump = await runPgDump();
|
||||
const sqlFilename = 'database.sql';
|
||||
fs.writeFileSync(path.join(tempDir, sqlFilename), sqlDump, 'utf8');
|
||||
fileNames.push(sqlFilename);
|
||||
|
||||
if (includeAppSnapshot) {
|
||||
const snapshot = await collectAppSnapshot(pool, actor);
|
||||
const jsonFilename = `${BACKUP_PREFIX}${stamp}-app.json`;
|
||||
const jsonPath = path.join(BACKUPS_DIR, jsonFilename);
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(snapshot, null, 2), 'utf8');
|
||||
files.push(formatBackupEntry(jsonPath, jsonFilename));
|
||||
if (options.includeAppSnapshot !== false) {
|
||||
const snapshot = await collectAppSnapshot(pool, actor);
|
||||
const jsonFilename = 'application.json';
|
||||
fs.writeFileSync(path.join(tempDir, jsonFilename), JSON.stringify(snapshot, null, 2), 'utf8');
|
||||
fileNames.push(jsonFilename);
|
||||
}
|
||||
|
||||
await createArchive(tempDir, archivePath, fileNames);
|
||||
|
||||
if (options.keepLast) {
|
||||
pruneBackups(options.keepLast);
|
||||
}
|
||||
|
||||
return formatBackupEntry(archivePath, archiveFilename);
|
||||
} finally {
|
||||
cleanupDir(tempDir);
|
||||
}
|
||||
|
||||
if (options.keepLast) {
|
||||
pruneBackups(options.keepLast);
|
||||
}
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function listBackups() {
|
||||
ensureBackupsDir();
|
||||
return fs.readdirSync(BACKUPS_DIR)
|
||||
.filter((name) => name.startsWith(BACKUP_PREFIX) && (name.endsWith('.json') || name.endsWith('.sql')))
|
||||
.filter((name) => name.startsWith(BACKUP_PREFIX) && name.endsWith(BACKUP_EXTENSION))
|
||||
.map((name) => formatBackupEntry(path.join(BACKUPS_DIR, name), name))
|
||||
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
}
|
||||
@@ -173,10 +226,55 @@ function getBackupPath(filename) {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async function restoreBackup(filename, options = {}) {
|
||||
ensureBackupsDir();
|
||||
const archivePath = getBackupPath(filename);
|
||||
const tempDir = makeTempDir();
|
||||
|
||||
try {
|
||||
await extractArchive(archivePath, tempDir);
|
||||
|
||||
const sqlPath = path.join(tempDir, 'database.sql');
|
||||
const jsonPath = path.join(tempDir, 'application.json');
|
||||
|
||||
if (!fs.existsSync(sqlPath)) {
|
||||
throw new Error('Archive does not contain database.sql');
|
||||
}
|
||||
|
||||
await runPsqlFile(sqlPath);
|
||||
|
||||
let restoredAppSnapshot = false;
|
||||
if (options.restoreAppSnapshot !== false && fs.existsSync(jsonPath)) {
|
||||
const snapshot = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
|
||||
if (snapshot.users) {
|
||||
fs.writeFileSync(USERS_FILE, JSON.stringify(snapshot.users, null, 2), 'utf8');
|
||||
}
|
||||
if (snapshot.settings) {
|
||||
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(snapshot.settings, null, 2), 'utf8');
|
||||
}
|
||||
if (Array.isArray(snapshot.audit)) {
|
||||
fs.writeFileSync(AUDIT_LOG_FILE, `${snapshot.audit.join('\n')}${snapshot.audit.length ? '\n' : ''}`, 'utf8');
|
||||
}
|
||||
|
||||
restoredAppSnapshot = true;
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
restoredDatabase: true,
|
||||
restoredAppSnapshot,
|
||||
};
|
||||
} finally {
|
||||
cleanupDir(tempDir);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BACKUPS_DIR,
|
||||
createBackup,
|
||||
getBackupPath,
|
||||
listBackups,
|
||||
pruneBackups,
|
||||
restoreBackup,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user