This commit is contained in:
Mateusz Gruszczyński committed 2026-10-28 14:55:35 +01:00
1 parent c7d09077fc
commit f813441bf0
45 files changed
+938 -334

No files matched your search

+37 -14
View File
@@ -7,7 +7,7 @@
const navItems = [['/', 'nav.dashboard', 'home'], ['/devices', 'nav.routers', 'device'], ['/files', 'ui.backups', 'files'], ['/diff-configs', 'nav.diffConfigs', 'diff'], ['/logs', 'nav.logs', 'clock'], ['/settings', 'nav.settings', 'settings']];
M.shell = () => {
const path = currentPath();
return `<div class="layout ${M.state.collapsed ? 'collapsed' : ''} ${M.state.mobile ? 'mobile-open' : ''}"><header class="topbar">${IB('sidebar', 'ui.menu', 'menu')}<a class="brand" href="/" data-nav><span>MikroTik Backup System</span></a><span class="topbar-spacer"></span><div class="user-chip">${I('user')}<span>${E(M.state.user.username)}</span></div><span class="topbar-divider"></span><div class="actions">${M.languageSelect()}${IB('theme', 'ui.changeTheme', M.state.theme === 'dark' ? 'moon' : 'sun')}${IB('logout', 'nav.logout', 'logout')}</div></header><aside class="sidebar" aria-label="${T('app.menu')}"><p class="nav-section">MikroTik / control</p><nav class="nav">${navItems.map(([url, key, icon]) => `<a href="${url}" data-nav class="${path === url || url === '/devices' && path.startsWith('/devices/') ? 'active' : ''}" ${path === url ? 'aria-current="page"' : ''} title="${T(key)}">${I(icon)}<span class="nav-label">${T(key)}</span></a>`).join('')}</nav><div class="sidebar-footer"><button type="button" class="api-status pending" data-action="health" id="health-status" title="${T('footer.apiLatencyLabel')}"><span class="dot"></span><span>${T('footer.apiChecking')}</span></button><div class="sidebar-meta"><a class="api-docs-link" href="/docs">${T('ui.apiDocumentation')}</a><span class="sidebar-meta-separator" aria-hidden="true">|</span><span class="version">v0.0.1</span></div></div></aside><button type="button" class="mobile-overlay" data-action="sidebar-close" aria-label="${T('ui.close')}"></button><main class="main" id="main" tabindex="-1"><div class="loading" aria-live="polite"><span class="spinner"></span>${T('ui.loading')}</div></main></div>`;
return `<div class="layout ${M.state.collapsed ? 'collapsed' : ''} ${M.state.mobile ? 'mobile-open' : ''}"><header class="topbar">${IB('sidebar', 'ui.menu', 'menu')}<a class="brand" href="/" data-nav><span>MikroTik Backup System</span></a><span class="topbar-spacer"></span><div class="user-chip">${I('user')}<span>${E(M.state.user.username)}</span></div><span class="topbar-divider"></span><div class="actions">${M.languageSelect()}${IB('theme', 'ui.changeTheme', M.state.theme === 'dark' ? 'moon' : 'sun')}${IB('logout', 'nav.logout', 'logout')}</div></header><aside class="sidebar" aria-label="${T('app.menu')}"><p class="nav-section">MikroTik / control</p><nav class="nav">${navItems.map(([url, key, icon]) => `<a href="${url}" data-nav class="${path === url || url === '/devices' && path.startsWith('/devices/') ? 'active' : ''}" ${path === url ? 'aria-current="page"' : ''} title="${T(key)}">${I(icon)}<span class="nav-label">${T(key)}</span></a>`).join('')}</nav><div class="sidebar-footer"><button type="button" class="api-status pending" data-action="health" id="health-status" title="${T('footer.apiLatencyLabel')}"><span class="dot"></span><span>${T('footer.apiChecking')}</span></button><div class="sidebar-meta"><a class="api-docs-link" href="/docs">${T('ui.apiDocumentation')}</a><span class="sidebar-meta-separator" aria-hidden="true">|</span><span class="version">v0.0.2</span></div></div></aside><button type="button" class="mobile-overlay" data-action="sidebar-close" aria-label="${T('ui.close')}"></button><main class="main" id="main" tabindex="-1"><div class="loading" aria-live="polite"><span class="spinner"></span>${T('ui.loading')}</div></main></div>`;
};
M.navigate = (path, replace = false) => {
if (!path.startsWith('/') || path.startsWith('//')) return;
@@ -21,7 +21,10 @@
const params = new URLSearchParams(location.search);
if (path === '/diff-configs') {
M.state.diffSelection = {
mode: params.has('mode') ? (params.get('mode') === 'cross' ? 'cross' : 'same') : (M.state.diffSelection?.mode === 'cross' ? 'cross' : 'same'),
router_id: params.get('router_id') || M.state.diffSelection?.router_id || '',
left_router_id: params.get('left_router_id') || M.state.diffSelection?.left_router_id || '',
right_router_id: params.get('right_router_id') || M.state.diffSelection?.right_router_id || '',
left: params.get('left') || M.state.diffSelection?.left || '',
right: params.get('right') || M.state.diffSelection?.right || ''
};
@@ -41,7 +44,7 @@
const revision = ++M.state.routeId;
M.applyPreferences();
document.documentElement.dataset.page = isAuthPath(path) ? 'auth' : 'app';
document.title = `MikroTik Backup System · 0.0.1`;
document.title = `MikroTik Backup System · 0.0.2`;
if (isAuthPath(path)) { M.$('#app').innerHTML = M.authView(path === '/register'); return; }
M.syncUrlState(path);
M.$('#app').innerHTML = M.shell(); M.updateHealth();
@@ -98,17 +101,26 @@
M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = position; });
if (restoreSearch) { const field = M.$('#diff-search'); field?.focus(); if (selection != null) field?.setSelectionRange(selection, selection); }
};
M.redrawDiffControls = () => {
const controls = M.$('#diff-controls');
if (controls) controls.innerHTML = M.diffControls();
};
M.compare = async (left, right) => {
left = Number(left); right = Number(right);
if (!left || !right || left === right) throw new Error(t('ui.selectPair'));
const exports = (M.state.cache.diffFiles || []).filter(M.isTextExport);
if (![left, right].every(id => exports.some(file => file.id === id))) throw new Error(t('ui.selectPair'));
const leftFile = exports.find(file => file.id === left), rightFile = exports.find(file => file.id === right);
if (!leftFile || !rightFile) throw new Error(t('ui.selectPair'));
const selection = M.state.diffSelection || {};
if (selection.mode === 'cross') {
if (!selection.left_router_id || !selection.right_router_id || String(leftFile.router_id) !== String(selection.left_router_id) || String(rightFile.router_id) !== String(selection.right_router_id) || String(leftFile.router_id) === String(rightFile.router_id)) throw new Error(t('ui.selectPair'));
} else if (!selection.router_id || String(leftFile.router_id) !== String(selection.router_id) || String(rightFile.router_id) !== String(selection.router_id)) throw new Error(t('ui.selectPair'));
const revision = M.state.routeId;
const data = await M.api(`/backups/${left}/diff/${right}?compact=true`);
if (revision !== M.state.routeId) return;
M.state.diff = data; M.state.diffPage = 1; M.state.diffSelection = { ...(M.state.diffSelection || {}), left, right };
const query = new URLSearchParams({ left: String(left), right: String(right) });
if (M.state.diffSelection.router_id) query.set('router_id', M.state.diffSelection.router_id);
M.state.diff = data; M.state.diffPage = 1; M.state.diffShowAll = false; M.state.diffSelection = { ...(M.state.diffSelection || {}), left, right };
const query = new URLSearchParams({ left: String(left), right: String(right), mode: M.state.diffSelection.mode === 'cross' ? 'cross' : 'same' });
for (const key of ['router_id', 'left_router_id', 'right_router_id']) if (M.state.diffSelection[key]) query.set(key, M.state.diffSelection[key]);
history.replaceState({}, '', `/diff-configs?${query}`);
M.redrawDiff();
};
@@ -264,23 +276,26 @@
const latest = files[0], older = latest?.id === selected.id ? files[1] : selected;
if (!latest || !older) throw new Error(t('ui.noExportPair'));
M.state.diff = null;
M.state.diffSelection = { left: older.id, right: latest.id, router_id: selected.router_id };
await M.navigate(`/diff-configs?${new URLSearchParams(M.state.diffSelection)}`);
M.state.diffSelection = { mode: 'same', left: older.id, right: latest.id, router_id: selected.router_id, left_router_id: '', right_router_id: '' };
await M.navigate(`/diff-configs?${new URLSearchParams({ mode: 'same', router_id: selected.router_id, left: older.id, right: latest.id })}`);
},
'upload-local-file': () => { const routers = (M.state.cache.routers || []).filter(router => router.device_type === 'routeros'); if (!routers.length) throw new Error(t('files.noRouterosTargets')); M.openDialog(t('files.uploadFileDialogTitle'), M.uploadFileForm(routers)); },
download: el => M.download(`/backups/${Number(el.dataset.id)}/download`, {}, M.findFile(el.dataset.id)?.file_name || `backup-${el.dataset.id}`),
email: async el => { if (!await M.confirm(t('ui.emailConfirm'), { danger: false, label: t('common.email') })) return; await M.api(`/backups/${Number(el.dataset.id)}/email`, { method: 'POST', body: {} }); M.toast(t('toast.backupSentEmail')); },
upload: async el => { if (!await M.confirm(t('ui.uploadConfirm'), { label: t('ui.upload') })) return; await M.api(`/backups/router/${Number(el.dataset.router)}/upload/${Number(el.dataset.id)}`, { method: 'POST', body: {}, timeout: 900000 }); M.toast(t('toast.binaryUploaded')); },
'delete-backup': async el => { if (!await M.confirm(t('confirm.deleteBackup'), { label: t('common.delete') })) return; await M.api(`/backups/${Number(el.dataset.id)}`, { method: 'DELETE' }); M.state.selected.delete(Number(el.dataset.id)); M.toast(t('toast.backupDeleted')); await M.render(); },
'bulk-download': async () => { const ids = [...M.state.selected]; if (!ids.length) throw new Error(t('ui.bulkEmpty')); await M.download('/backups/bulk', { method: 'POST', body: { action: 'download', backup_ids: ids } }, 'backups.zip'); },
'bulk-delete': async () => { const ids = [...M.state.selected]; if (!ids.length) throw new Error(t('ui.bulkEmpty')); if (!await M.confirm(t('confirm.deleteSelectedFiles', { count: ids.length }), { label: t('common.delete') })) return; await M.api('/backups/bulk', { method: 'POST', body: { action: 'delete', backup_ids: ids } }); M.state.selected.clear(); M.toast(t('toast.selectedBackupsDeleted')); await M.render(); },
'compare-selected': async () => { const selected = [...M.state.selected].map(id => M.findFile(id)); if (selected.length !== 2 || !selected.every(M.isTextExport)) throw new Error(t('ui.selectPair')); selected.sort((a, b) => (M.parseDate(a.created_at)?.getTime() || 0) - (M.parseDate(b.created_at)?.getTime() || 0)); M.state.diff = null; M.state.diffSelection = { left: selected[0].id, right: selected[1].id, router_id: selected[0].router_id === selected[1].router_id ? selected[0].router_id : '' }; await M.navigate(`/diff-configs?${new URLSearchParams(M.state.diffSelection)}`); },
'compare-selected': async () => { const selected = [...M.state.selected].map(id => M.findFile(id)); if (selected.length !== 2 || !selected.every(M.isTextExport)) throw new Error(t('ui.selectPair')); selected.sort((a, b) => (M.parseDate(a.created_at)?.getTime() || 0) - (M.parseDate(b.created_at)?.getTime() || 0)); const cross = selected[0].router_id !== selected[1].router_id; M.state.diff = null; M.state.diffSelection = cross ? { mode: 'cross', left: selected[0].id, right: selected[1].id, router_id: '', left_router_id: selected[0].router_id, right_router_id: selected[1].router_id } : { mode: 'same', left: selected[0].id, right: selected[1].id, router_id: selected[0].router_id, left_router_id: '', right_router_id: '' }; await M.navigate(`/diff-configs?${new URLSearchParams(Object.fromEntries(Object.entries(M.state.diffSelection).filter(([, value]) => value !== '')))}`); },
'reset-filters': () => { M.state.filters = { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' }; M.state.page = 1; M.state.selected.clear(); history.replaceState({}, '', '/files'); M.render(); },
'page-prev': () => { M.state.page = Math.max(1, M.state.page - 1); M.redrawPageTable(); },
'page-next': () => { M.state.page += 1; M.redrawPageTable(); },
'diff-page-prev': () => { M.state.diffPage = Math.max(1, M.state.diffPage - 1); M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'diff-page-next': () => { M.state.diffPage += 1; M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'swap-diff': () => { const form = M.$('#diff-form'); const left = form.elements.left.value; form.elements.left.value = form.elements.right.value; form.elements.right.value = left; M.state.diffSelection.left = form.elements.left.value; M.state.diffSelection.right = form.elements.right.value; },
'latest-diff-pair': () => { const files = M.state.cache.diffFiles || []; if (files.length < 2) throw new Error(t('ui.noExportPair')); const right = files[0], left = files.slice(1).find(f => f.router_id === right.router_id) || files[1]; const form = M.$('#diff-form'); form.elements.left.value = left.id; form.elements.right.value = right.id; M.state.diffSelection.left = left.id; M.state.diffSelection.right = right.id; },
'diff-toggle-all': () => { M.state.diffShowAll = !M.state.diffShowAll; M.state.diffPage = 1; M.redrawDiff(); M.$$('[data-diff-scroll]').forEach(el => { el.scrollTop = 0; }); },
'swap-diff': () => { const form = M.$('#diff-form'); const cross = M.state.diffSelection?.mode === 'cross'; if (cross) { const leftRouter = M.state.diffSelection.left_router_id; M.state.diffSelection.left_router_id = M.state.diffSelection.right_router_id; M.state.diffSelection.right_router_id = leftRouter; } const left = M.state.diffSelection.left; M.state.diffSelection.left = M.state.diffSelection.right; M.state.diffSelection.right = left; M.redrawDiffControls(); },
'latest-diff-pair': () => { const files = M.state.cache.diffFiles || []; if (files.length < 2) throw new Error(t('ui.noExportPair')); const right = files[0], left = files.slice(1).find(f => f.router_id === right.router_id); if (!left) throw new Error(t('ui.noExportPair')); M.state.diffSelection = { mode: 'same', router_id: right.router_id, left_router_id: '', right_router_id: '', left: left.id, right: right.id }; M.redrawDiffControls(); },
'open-html-diff': async () => { if (!M.state.diff) return; const popup = window.open('', '_blank'); if (!popup) throw new Error(t('ui.popupBlocked')); popup.document.title = t('ui.openStandaloneDiff'); popup.document.body.textContent = t('ui.loading'); try { const html = await M.api(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}/html`, { text: true }); const url = URL.createObjectURL(new Blob([html], { type: 'text/html;charset=utf-8' })); popup.location.replace(url); setTimeout(() => URL.revokeObjectURL(url), 60000); } catch (error) { popup.close(); throw error; } },
'download-diff': async () => { if (!M.state.diff) return; const full = await M.api(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}`); M.saveBlob(new Blob([full.diff_text || ''], { type: 'text/plain;charset=utf-8' }), `config-${M.state.diff.left_backup_id}-${M.state.diff.right_backup_id}.diff`); },
'download-html-diff': () => { if (!M.state.diff) return; return M.download(`/backups/${M.state.diff.left_backup_id}/diff/${M.state.diff.right_backup_id}/html`, {}, `config-${M.state.diff.left_backup_id}-${M.state.diff.right_backup_id}.html`); },
'test-email': async () => { await M.api('/settings/test-email', { method: 'POST', body: {} }); M.toast(t('toast.testEmailSent')); },
@@ -323,7 +338,9 @@
if (el.name === 'use_global_switchos_credentials' && el.form?.id === 'device-form') M.syncDeviceCredentialMode(el.form);
if (el.name === 'disable_all_backups' && el.form?.id === 'device-policy-form' && el.checked) { if (el.form.elements.disable_export_backups) el.form.elements.disable_export_backups.checked = true; el.form.elements.disable_binary_backups.checked = true; }
if (el.name === 'router_id' && el.form?.id === 'backup-form') M.syncBackupType();
if (el.name === 'router_id' && el.form?.id === 'diff-form') { M.state.diffSelection = { router_id: el.value, left: '', right: '' }; M.state.diff = null; M.navigate(`/diff-configs?router_id=${encodeURIComponent(el.value)}`); }
if (el.name === 'diff_scope' && el.form?.id === 'diff-form') { M.state.diffSelection = { mode: el.value === 'cross' ? 'cross' : 'same', router_id: '', left_router_id: '', right_router_id: '', left: '', right: '' }; M.state.diff = null; M.redrawDiffControls(); }
if (el.name === 'router_id' && el.form?.id === 'diff-form') { M.state.diffSelection.router_id = el.value; M.state.diffSelection.left = ''; M.state.diffSelection.right = ''; M.state.diff = null; M.redrawDiffControls(); }
if ((el.name === 'left_router_id' || el.name === 'right_router_id') && el.form?.id === 'diff-form') { M.state.diffSelection[el.name] = el.value; M.state.diffSelection[el.name === 'left_router_id' ? 'left' : 'right'] = ''; M.state.diff = null; M.redrawDiffControls(); }
if (el.name === 'left' || el.name === 'right') { if (el.form?.id === 'diff-form') M.state.diffSelection[el.name] = el.value; }
if (el.id === 'diff-changes-only') { M.state.diffChangesOnly = el.checked; M.state.diffPage = 1; M.redrawDiff(); }
if (el.closest('[data-schedule]')) M.updateScheduleEditor(el.closest('[data-schedule]'));
@@ -388,6 +405,12 @@
await M.api(`/routers/${id}`, { method: 'PUT', body: payload }); M.toast(t('toast.routerUpdated')); await M.render(); return;
}
if (form.id === 'backup-form') { const type = data.backup_type; if (!['export', 'binary'].includes(type)) throw new Error(t('ui.notSupported')); await M.api(`/backups/router/${Number(data.router_id)}/${type}`, { method: 'POST', body: {}, timeout: 900000 }); M.closeDialog(); M.toast(t(type === 'export' ? 'toast.exportCreated' : 'toast.binaryCreated')); await M.render(); return; }
if (form.id === 'device-file-upload-form') {
const upload = new FormData(form), file = upload.get('file'), routerId = Number(upload.get('router_id'));
if (!routerId || !file || typeof file.name !== 'string' || !/\.(rsc|backup)$/i.test(file.name)) throw new Error(t('files.uploadFileInvalid'));
await M.api(`/routers/${routerId}/files/upload?filename=${encodeURIComponent(file.name)}`, { method: 'POST', body: file, raw: true, timeout: 900000 });
M.closeDialog(); M.toast(t('toast.fileUploaded')); return;
}
if (form.id === 'files-filter') { M.state.filters = data; M.state.page = 1; M.state.selected.clear(); await M.render(); return; }
if (form.id === 'device-filter') { M.state.deviceFilters = data; M.state.page = 1; M.redrawPageTable(); return; }
if (form.id === 'diff-form') { await M.compare(data.left, data.right); return; }
@@ -395,9 +418,9 @@
if (form.id === 'settings-form') {
const payload = {};
for (const key of ['backup_retention_days', 'log_retention_days', 'connection_test_interval_minutes', 'smtp_port']) payload[key] = Number(data[key]);
for (const key of ['default_switchos_username', 'smtp_host', 'smtp_login', 'recipient_email']) payload[key] = data[key]?.trim() || null;
for (const key of ['default_routeros_username', 'default_switchos_username', 'smtp_host', 'smtp_login', 'recipient_email']) payload[key] = data[key]?.trim() || null;
for (const key of ['smtp_notifications_enabled', 'notify_failures_only']) payload[key] = form.elements[key].checked;
for (const key of ['global_ssh_key', 'default_switchos_password', 'smtp_password', 'pushover_token', 'pushover_userkey']) {
for (const key of ['global_ssh_key', 'default_routeros_password', 'default_switchos_password', 'smtp_password', 'pushover_token', 'pushover_userkey']) {
if (data[key]) payload[key] = key === 'global_ssh_key' ? data[key].trim() : data[key];
if (data[`clear_${key}`]) payload[`clear_${key}`] = true;
}