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;
}
+5 -4
View File
@@ -9,7 +9,7 @@
theme: read(localStorage, 'routeros_theme') === 'light' ? 'light' : 'dark',
collapsed: read(localStorage, 'mbs_sidebar') === 'collapsed', mobile: false,
routeId: 0, cache: {}, selected: new Set(), page: 1, pageSize: 20,
health: { online: null, latency: null }, diff: null, diffMode: 'split', diffChangesOnly: true, diffSearch: '', diffPage: 1, diffPageSize: 200,
health: { online: null, latency: null }, diff: null, diffMode: 'split', diffChangesOnly: true, diffSearch: '', diffPage: 1, diffPageSize: 200, diffShowAll: false,
filters: { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' }
};
M.$ = (selector, root = document) => root.querySelector(selector);
@@ -119,18 +119,19 @@
M.card = (title, body, extras = '', classes = '') => `<section class="card ${classes}"><header class="card-header"><h2>${M.tr(title)}</h2>${extras}</header>${body}</section>`;
M.errorMessage = error => error && error.message ? error.message : M.t('ui.operationError');
M.api = async (path, options = {}) => {
const { method = 'GET', body, blob = false, text = false, timeout = 180000, quietAuth = false, signal } = options;
const { method = 'GET', body, blob = false, text = false, raw = false, timeout = 180000, quietAuth = false, signal } = options;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const forwardAbort = () => controller.abort();
if (signal) signal.addEventListener('abort', forwardAbort, { once: true });
const headers = { Accept: blob ? '*/*' : text ? 'text/html' : 'application/json' };
if (M.state.token) headers.Authorization = `Bearer ${M.state.token}`;
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (body !== undefined && raw) headers['Content-Type'] = 'application/octet-stream';
else if (body !== undefined) headers['Content-Type'] = 'application/json';
let response;
try {
response = await fetch(`${M.$('meta[name="api-prefix"]')?.content || '/api'}${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: controller.signal,
method, headers, body: body === undefined ? undefined : raw ? body : JSON.stringify(body), signal: controller.signal,
cache: 'no-store', credentials: 'same-origin', redirect: 'error'
});
if (!response.ok) {
+1 -2
View File
@@ -1,3 +1,2 @@
/* Source language dictionaries preserved from the original application. */
window.MBS = window.MBS || {};
window.MBS.translations = {"pl":{"app":{"menu":"Menu"},"sidebar":{"title":"Mikrotik Backup System","subtitle":"RouterOS/SwitchOS"},"topbar":{"caption":"mikrotik / control center","role":"administrator","languageSelector":"Wybór języka"},"common":{"apply":"Zastosuj","reset":"Resetuj","delete":"Usuń","confirm":"Potwierdź","cancel":"Anuluj","download":"Pobierz","email":"Wyślij e-mail","preview":"Podgląd","restore":"Przywróć","actions":"Akcje","open":"Otwórz","edit":"Edytuj","diff":"Diff","ok":"OK","idle":"Brak","asc":"Rosnąco","desc":"Malejąco","enabled":"Włączone","disabled":"Wyłączone","failed":"Błąd","save":"Zapisz"},"nav":{"dashboard":"Dashboard","routers":"Urządzenia","files":"Repozytorium","settings":"Ustawienia","logs":"Logi","logout":"Wyloguj","theme":"Motyw","changePassword":"Zmień hasło","diffConfigs":"Diff konfiguracji","switchosBeta":"SwitchOS beta"},"auth":{"username":"Użytkownik","password":"Hasło","login":"Zaloguj","register":"Rejestracja","confirmPassword":"Potwierdź hasło","changePassword":"Zmień hasło","currentPassword":"Obecne hasło","newPassword":"Nowe hasło","backToLogin":"Powrót do logowania","loginSubtitle":"Zaloguj się, aby kontynuować.","loginFailed":"Logowanie nie powiodło się","accountCreated":"Konto zostało utworzone","registrationFailed":"Rejestracja nie powiodła się","passwordsMismatch":"Hasła nie są takie same","changePasswordFailed":"Zmiana hasła nie powiodła się","securityEyebrow":"konto / bezpieczeństwo","changePasswordSubtitle":"Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.","changePasswordCardSubtitle":"Podaj obecne hasło i ustaw nowe dane logowania.","passwordPanelSubtitle":"Szybki podgląd siły hasła i zgodności pól przed zapisem.","passwordStrength":"Siła hasła","passwordWeak":"Słabe","passwordMedium":"Średnie","passwordStrong":"Mocne","ruleLength":"Minimum 8 znaków","ruleDigit":"Przynajmniej jedna cyfra","ruleMatch":"Oba pola są zgodne","passwordsMatchHint":"Nowe hasło i potwierdzenie są zgodne."},"dashboard":{"title":"Dashboard","eyebrow":"strona główna / dashboard","subtitle":"Przegląd backupów, eksportów i aktywności systemu.","exportAll":"Exportuj wszystko","binaryAll":"Backup binarny","managedRouters":"Urządzenia","managedRoutersHint":"Wszystkie zarządzane urządzenia","inventoryTag":"Flota","exportsCard":"Exporty","exportsHint":"Czytelne snapshoty konfiguracji","textTag":"Tekst","binaryCard":"Backupy binarne","binaryHint":"Punkty odtworzenia","binaryTag":"Binary","allFilesCard":"Wszystkie pliki","allFilesHint":"Artefakty w repozytorium","archiveTag":"Archiwum","storageTitle":"Wykorzystanie przestrzeni","storageSubtitle":"Bieżący podgląd zajętości repozytorium i wolnego miejsca.","folderUsage":"Zajętość katalogu","diskUsage":"Użycie dysku","totalDisk":"Cały dysk","freeSpace":"Wolne miejsce","activityTitle":"Ostatnia aktywność","activitySubtitle":"Najnowsze zdarzenia operacyjne z backendu.","noActivity":"Brak ostatnich zdarzeń do wyświetlenia.","avgBackupsPerRouter":"Śr. backupów / urządzenie","activitySuccess":"Zadanie zakończone","activityFailure":"Wymaga uwagi","activityMaintenance":"Utrzymanie","activityDelivery":"Dystrybucja","operationsTitle":"Centrum operacji","operationsSubtitle":"Główne akcje i szybkie wskaźniki pracy repozytorium.","latestSnapshot":"Najnowszy snapshot","coverageLabel":"Pokrycie floty","coverageHint":"Urządzenia z co najmniej jednym backupem","weeklyActivityLabel":"Aktywność 7 dni","weeklyActivityHint":"Nowe backupy z ostatniego tygodnia","busiestRouterLabel":"Najaktywniejsze urządzenie","routerSnapshotsHint":"{{count}} snapshotów w repozytorium","exportShareLabel":"Udział exportów","activityTodayLabel":"Zdarzenia dzisiaj","noneLabel":"Brak","activityTodayHint":"Wpisy z bieżącego dnia","usedSpace":"Zajęte miejsce","storageViewCapacity":"Pojemność","storageViewCapacityHint":"Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.","storageViewMix":"Typy backupów","storageViewMixHint":"Podział wszystkich kopii na exporty tekstowe i backupy binarne.","storageViewActivity":"Aktywność 7 dni","storageViewActivityHint":"Liczba nowych backupów z ostatnich siedmiu dni.","storageViewRouters":"Top urządzenia","storageViewRoutersHint":"Urządzenia z największą liczbą snapshotów w repozytorium.","storageChartEmpty":"Brak danych do narysowania wykresu.","storageSnapshotTitle":"Metryki repozytorium","storageSnapshotHint":"Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."},"routers":{"title":"Urządzenia","detailTitle":"Szczegóły urządzenia","add":"Dodaj urządzenie","eyebrow":"inwentaryzacja urządzeń","subtitle":"Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.","registeredDevices":"Zarejestrowane urządzenia","fleetTag":"Flota","sshPassword":"Hasło","passwordHint":"Dostęp hasłem","credsTag":"Dostęp","sshKey":"Klucz SSH","keyHint":"Dostęp kluczem","securityTag":"BezLine truncated
window.MBS.translations = { "pl": { "app": { "menu": "Menu" }, "sidebar": { "title": "Mikrotik Backup System", "subtitle": "RouterOS/SwitchOS" }, "topbar": { "caption": "mikrotik / control center", "role": "administrator", "languageSelector": "Wybór języka" }, "common": { "apply": "Zastosuj", "reset": "Resetuj", "delete": "Usuń", "confirm": "Potwierdź", "cancel": "Anuluj", "download": "Pobierz", "email": "Wyślij e-mail", "preview": "Podgląd", "restore": "Przywróć", "actions": "Akcje", "open": "Otwórz", "edit": "Edytuj", "diff": "Diff", "ok": "OK", "idle": "Brak", "asc": "Rosnąco", "desc": "Malejąco", "enabled": "Włączone", "disabled": "Wyłączone", "failed": "Błąd", "save": "Zapisz" }, "nav": { "dashboard": "Dashboard", "routers": "Urządzenia", "files": "Repozytorium", "settings": "Ustawienia", "logs": "Logi", "logout": "Wyloguj", "theme": "Motyw", "changePassword": "Zmień hasło", "diffConfigs": "Diff konfiguracji", "switchosBeta": "SwitchOS beta" }, "auth": { "username": "Użytkownik", "password": "Hasło", "login": "Zaloguj", "register": "Rejestracja", "confirmPassword": "Potwierdź hasło", "changePassword": "Zmień hasło", "currentPassword": "Obecne hasło", "newPassword": "Nowe hasło", "backToLogin": "Powrót do logowania", "loginSubtitle": "Zaloguj się, aby kontynuować.", "loginFailed": "Logowanie nie powiodło się", "accountCreated": "Konto zostało utworzone", "registrationFailed": "Rejestracja nie powiodła się", "passwordsMismatch": "Hasła nie są takie same", "changePasswordFailed": "Zmiana hasła nie powiodła się", "securityEyebrow": "konto / bezpieczeństwo", "changePasswordSubtitle": "Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.", "changePasswordCardSubtitle": "Podaj obecne hasło i ustaw nowe dane logowania.", "passwordPanelSubtitle": "Szybki podgląd siły hasła i zgodności pól przed zapisem.", "passwordStrength": "Siła hasła", "passwordWeak": "Słabe", "passwordMedium": "Średnie", "passwordStrong": "Mocne", "ruleLength": "Minimum 8 znaków", "ruleDigit": "Przynajmniej jedna cyfra", "ruleMatch": "Oba pola są zgodne", "passwordsMatchHint": "Nowe hasło i potwierdzenie są zgodne." }, "dashboard": { "title": "Dashboard", "eyebrow": "strona główna / dashboard", "subtitle": "Przegląd backupów, eksportów i aktywności systemu.", "exportAll": "Exportuj wszystko", "binaryAll": "Backup binarny", "managedRouters": "Urządzenia", "managedRoutersHint": "Wszystkie zarządzane urządzenia", "inventoryTag": "Flota", "exportsCard": "Exporty", "exportsHint": "Czytelne snapshoty konfiguracji", "textTag": "Tekst", "binaryCard": "Backupy binarne", "binaryHint": "Punkty odtworzenia", "binaryTag": "Binary", "allFilesCard": "Wszystkie pliki", "allFilesHint": "Artefakty w repozytorium", "archiveTag": "Archiwum", "storageTitle": "Wykorzystanie przestrzeni", "storageSubtitle": "Bieżący podgląd zajętości repozytorium i wolnego miejsca.", "folderUsage": "Zajętość katalogu", "diskUsage": "Użycie dysku", "totalDisk": "Cały dysk", "freeSpace": "Wolne miejsce", "activityTitle": "Ostatnia aktywność", "activitySubtitle": "Najnowsze zdarzenia operacyjne z backendu.", "noActivity": "Brak ostatnich zdarzeń do wyświetlenia.", "avgBackupsPerRouter": "Śr. backupów / urządzenie", "activitySuccess": "Zadanie zakończone", "activityFailure": "Wymaga uwagi", "activityMaintenance": "Utrzymanie", "activityDelivery": "Dystrybucja", "operationsTitle": "Centrum operacji", "operationsSubtitle": "Główne akcje i szybkie wskaźniki pracy repozytorium.", "latestSnapshot": "Najnowszy snapshot", "coverageLabel": "Pokrycie floty", "coverageHint": "Urządzenia z co najmniej jednym backupem", "weeklyActivityLabel": "Aktywność 7 dni", "weeklyActivityHint": "Nowe backupy z ostatniego tygodnia", "busiestRouterLabel": "Najaktywniejsze urządzenie", "routerSnapshotsHint": "{{count}} snapshotów w repozytorium", "exportShareLabel": "Udział exportów", "activityTodayLabel": "Zdarzenia dzisiaj", "noneLabel": "Brak", "activityTodayHint": "Wpisy z bieżącego dnia", "usedSpace": "Zajęte miejsce", "storageViewCapacity": "Pojemność", "storageViewCapacityHint": "Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.", "storageViewMix": "Typy backupów", "storageViewMixHint": "Podział wszystkich kopii na exporty tekstowe i backupy binarne.", "storageViewActivity": "Aktywność 7 dni", "storageViewActivityHint": "Liczba nowych backupów z ostatnich siedmiu dni.", "storageViewRouters": "Top urządzenia", "storageViewRoutersHint": "Urządzenia z największą liczbą snapshotów w repozytorium.", "storageChartEmpty": "Brak danych do narysowania wykresu.", "storageSnapshotTitle": "Metryki repozytorium", "storageSnapshotHint": "Szybki podgląd najważniejszych wskaźników przestrzeni i backupów." }, "routers": { "title": "Urządzenia", "detailTitle": "Szczegóły urządzenia", "add": "Dodaj urządzenie", "eyebrow": "inwentaryzacja urządzeń", "subtitle": "ZLine truncated
+43 -22
View File
@@ -1,4 +1,3 @@
/* Views contain presentation only; mutations live in app.js. */
(function (M) {
'use strict';
const { escape: E, tr: T, t, icon: I, button: B, iconButton: IB, card: C, field: F, select: S } = M;
@@ -76,7 +75,7 @@
return `${M.heading('routers.title', 'routers.subtitle', B('new-device', t('ui.newDevice'), 'plus', '', 'primary') + B('refresh', t('ui.refresh'), 'refresh'), 'routers.eyebrow')}<form id="device-filter" class="filter-bar"><div class="field search-field"><span>${T('files.searchLabel')}</span><input name="search" type="search" value="${E(f.search || '')}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"></div>${S('status', 'ui.status', [['', t('ui.allStatuses')], ['online', t('ui.online')], ['offline', t('ui.offline')], ['unknown', t('ui.unknown')]], f.status || '')}${S('device_type', 'routers.deviceType', [['', t('files.allTypes')], ['routeros', 'RouterOS'], ['switchos', 'SwitchOS']], f.device_type || '')}${B('refresh-ping', t('ui.refreshPing'), 'refresh')}</form><section class="card"><div id="device-table">${M.deviceTable()}</div></section>`;
};
M.deviceForm = router => {
const editing = !!router, r = router || { name: '', host: '', port: 22, device_type: 'routeros', ssh_user: 'admin' };
const editing = !!router, r = router || { name: '', host: '', port: 22, device_type: 'routeros', ssh_user: '' };
const switchos = r.device_type === 'switchos';
const legacy = editing && switchos && r.use_global_switchos_credentials == null;
const source = legacy ? '' : r.use_global_switchos_credentials === false ? 'false' : 'true';
@@ -90,7 +89,7 @@
${F('port', 'routers.port', r.port, { type: 'number', required: true, attrs: 'min="1" max="65535"' })}
<div class="wide" id="switchos-credentials-mode"${switchos ? '' : ' hidden'}>${S('use_global_switchos_credentials', 'ui.credentialSource', sources, source, switchos ? '' : 'disabled')}<p class="credential-hint" data-switchos-mode-hint${source === 'false' ? ' hidden' : ''}>${T(legacy ? 'ui.legacyCredentialsHint' : 'ui.globalCredentialsHint')}</p></div>
<div class="form-grid wide" id="device-own-credentials"${global ? ' hidden' : ''}>
${F('ssh_user', 'routers.sshUser', r.ssh_user || '', { attrs: 'maxlength="120" autocomplete="off"' + (global ? ' disabled' : '') })}
${F('ssh_user', 'routers.sshUser', r.ssh_user || '', { attrs: 'maxlength="120" autocomplete="off"' + (global ? ' disabled' : ''), hint: switchos ? '' : t('routers.routerosDefaultFallback') })}
<div class="secret-field">${F('ssh_password', 'routers.sshPassword', '', { type: 'password', attrs: 'maxlength="255" autocomplete="new-password"' + (global ? ' disabled' : ''), placeholder: editing ? t(passwordSet ? 'ui.kept' : 'ui.notSet') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_password"${global ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div>
</div>
<div class="wide secret-field" id="device-key-field"${switchos ? ' hidden' : ''}>${F('ssh_key', 'routers.sshPrivateKey', '', { type: 'textarea', attrs: 'spellcheck="false" autocomplete="off"' + (switchos ? ' disabled' : ''), placeholder: editing && r.has_effective_ssh_key ? t('ui.kept') : t('routers.optionalPrivateKey'), hint: r.uses_global_ssh_key ? t('routers.globalKeyMode') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_key"${switchos ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div>
@@ -107,7 +106,7 @@
const status = dl([['ui.status', M.connectionBadge(r), true], ['routers.lastTestAt', M.date(r.last_connection_tested_at)], ['routers.uptime', r.last_connection_uptime], ['routers.connectionLabel', r.last_connection_transport], ['routers.serverHeader', r.last_connection_server], ['routers.authMode', r.last_connection_auth_mode], ['routers.httpStatus', r.last_connection_http_status], ['routers.backupEndpoint', r.last_connection_backup_available == null ? '—' : t(r.last_connection_backup_available ? 'routers.backupAvailable' : 'routers.backupUnavailable')]]);
const last = backups[0];
const backupSummary = dl([['ui.lastBackup', last ? M.date(last.created_at) : '—'], ['files.sizeColumn', last ? M.bytes(last.file_size) : '—'], ...(M.supportsExport(r) ? [['routers.exportsLabel', backups.filter(M.isTextExport).length]] : []), ['routers.binaryLabel', backups.filter(f => f.backup_type === 'binary').length]]);
return `<div class="grid-equal detail-grid"><div class="stack">${C('routers.connectionSectionTitle', `<div class="card-body">${summary}</div>`)}${C('ui.backups', `<div class="card-body">${backupSummary}</div><div class="card-foot actions">${M.supportsExport(r) ? B('device-export', t('files.exportType') + ' RSC', 'files', `data-id="${r.id}" ${r.disable_all_backups || r.disable_export_backups ? 'disabled' : ''}`) : ''}${B('device-binary', t('files.binaryType'), 'database', `data-id="${r.id}" ${r.disable_all_backups || r.disable_binary_backups ? 'disabled' : ''}`, 'primary')}</div>`)}</div><div class="stack">${C('routers.deviceStatusTitle', `<div class="card-body">${status}${r.last_connection_error ? `<div class="notice warning mt-18">${I('alert')}<span class="break">${E(r.last_connection_error)}</span></div>` : ''}${!r.last_connection_tested_at ? `<p class="section-hint mt-18">${T('routers.noConnection')}</p>` : ''}</div>`)}${C('routers.credentialsSectionTitle', `<div class="card-body">${dl([['routers.sshKey', r.uses_global_ssh_key ? t('routers.globalKeyMode') : t(r.has_effective_ssh_key ? 'routers.keyMode' : 'routers.noKey')], ['routers.sshPassword', t(r.has_effective_password ? 'routers.passwordMode' : 'routers.noPassword')], ['routers.defaultCredentials', t(r.uses_global_switchos_credentials ? 'common.enabled' : 'common.disabled')]])}</div>`)}</div></div>`;
return `<div class="grid-equal detail-grid"><div class="stack">${C('routers.connectionSectionTitle', `<div class="card-body">${summary}</div>`)}${C('ui.backups', `<div class="card-body">${backupSummary}</div><div class="card-foot actions">${M.supportsExport(r) ? B('device-export', t('files.exportType') + ' RSC', 'files', `data-id="${r.id}" ${r.disable_all_backups || r.disable_export_backups ? 'disabled' : ''}`) : ''}${B('device-binary', t('files.binaryType'), 'database', `data-id="${r.id}" ${r.disable_all_backups || r.disable_binary_backups ? 'disabled' : ''}`, 'primary')}</div>`)}</div><div class="stack">${C('routers.deviceStatusTitle', `<div class="card-body">${status}${r.last_connection_error ? `<div class="notice warning mt-18">${I('alert')}<span class="break">${E(r.last_connection_error)}</span></div>` : ''}${!r.last_connection_tested_at ? `<p class="section-hint mt-18">${T('routers.noConnection')}</p>` : ''}</div>`)}${C('routers.credentialsSectionTitle', `<div class="card-body">${dl([['routers.sshKey', r.uses_global_ssh_key ? t('routers.globalKeyMode') : t(r.has_effective_ssh_key ? 'routers.keyMode' : 'routers.noKey')], ['routers.sshPassword', t(r.has_effective_password ? 'routers.passwordMode' : 'routers.noPassword')], ['routers.defaultCredentials', t(r.device_type === 'switchos' ? (r.uses_global_switchos_credentials ? 'common.enabled' : 'common.disabled') : (r.uses_default_routeros_credentials ? 'common.enabled' : 'common.disabled'))]])}</div>`)}</div></div>`;
};
M.views.device = async id => {
const revision = M.state.routeId;
@@ -124,7 +123,8 @@
Object.entries(filters).forEach(([key, value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key, String(value)); });
return params.toString();
};
M.fileToolbar = () => `<div class="actions">${B('bulk-download', t('files.downloadZip'), 'download')}${B('compare-selected', t('ui.compare'), 'diff', 'hidden')}${B('bulk-delete', t('common.delete'), 'trash', '', 'danger')}<span class="select-summary">${T('ui.selection')}: <b id="selected-count">${M.state.selected.size}</b></span></div>`;
M.fileToolbar = () => `<div class="actions">${B('upload-local-file', t('files.uploadToDevice'), 'upload', '', 'primary')}${B('bulk-download', t('files.downloadZip'), 'download')}${B('compare-selected', t('ui.compare'), 'diff', 'hidden')}${B('bulk-delete', t('common.delete'), 'trash', '', 'danger')}<span class="select-summary">${T('ui.selection')}: <b id="selected-count">${M.state.selected.size}</b></span></div>`;
M.uploadFileForm = routers => `<form id="device-file-upload-form"><div class="form-grid">${S('router_id', 'files.uploadTarget', routers.map(r => [r.id, `${r.name} · ${r.host}`]), routers[0]?.id || '', 'required')}<label class="field wide" for="f-device_file"><span>${T('files.uploadFileLabel')}<span class="required"> *</span></span><input id="f-device_file" name="file" type="file" accept=".rsc,.backup" required><small>${T('files.uploadFileHint')}</small></label></div><div class="notice mt-16">${I('upload')}<span>${T('ui.uploadFile')}</span></div><div class="form-actions">${B('close-dialog', t('common.cancel'))}<button type="submit" class="btn primary">${I('upload')}${T('ui.upload')}</button></div></form>`;
M.views.files = async () => {
const revision = M.state.routeId;
const f = M.state.filters;
@@ -171,8 +171,8 @@
const pageSize = Math.max(80, Number(M.state.diffPageSize) || 200);
const pages = Math.max(1, Math.ceil(allRows.length / pageSize));
M.state.diffPage = Math.min(pages, Math.max(1, Number(M.state.diffPage) || 1));
const start = (M.state.diffPage - 1) * pageSize;
const rows = allRows.slice(start, start + pageSize);
const start = M.state.diffShowAll ? 0 : (M.state.diffPage - 1) * pageSize;
const rows = M.state.diffShowAll ? allRows : allRows.slice(start, start + pageSize);
const pane = side => `<div class="diff-pane"><div class="diff-title" title="${E(d[`${side}_file_name`] || '')}">${E(d[`${side}_file_name`] || '')}</div><div class="diff-scroll" data-diff-scroll="${side}"><div class="diff-lines">${rows.map(row => {
const text = row[`${side}_text`] || '', n = row[`${side}_number`];
let type = ['context', 'added', 'removed', 'modified'].includes(row.type) ? row.type : 'context';
@@ -188,30 +188,51 @@
else { if (row.left_number != null) lines.push(['removed', '−', row.left_number, row.left_text]); if (row.right_number != null) lines.push(['added', '+', row.right_number, row.right_text]); }
return lines.map(([type, prefix, n, text]) => { const match = search && String(text || '').toLocaleLowerCase().includes(search.toLocaleLowerCase()); return `<div class="diff-line ${type}${match ? ' match' : ''}"><span class="diff-prefix">${prefix}</span><span class="line-no">${n == null ? '' : Number(n)}</span><span class="line-code">${M.highlight(text, search) || ' '}</span></div>`; }).join('');
}).join('')}</div></div>`;
const pager = pages > 1 ? `<div class="diff-pager"><button type="button" class="btn small" data-action="diff-page-prev"${M.state.diffPage <= 1 ? ' disabled' : ''}>${T('ui.previous')}</button><span>${T('ui.page')} <strong>${M.state.diffPage}</strong> / ${pages} · ${start + 1}–${Math.min(allRows.length, start + rows.length)} / ${allRows.length}</span><button type="button" class="btn small" data-action="diff-page-next"${M.state.diffPage >= pages ? ' disabled' : ''}>${T('ui.next')}</button></div>` : `<span>${T('ui.lines')}: ${allRows.length}</span>`;
return `${M.smartDiff(d.smart)}<section class="card raw-diff"><div class="diff-toolbar"><div class="tabs" role="tablist"><button type="button" class="tab ${M.state.diffMode === 'split' ? 'active' : ''}" data-diff-mode="split" role="tab" aria-selected="${M.state.diffMode === 'split'}">${T('ui.split')}</button><button type="button" class="tab ${M.state.diffMode === 'unified' ? 'active' : ''}" data-diff-mode="unified" role="tab" aria-selected="${M.state.diffMode === 'unified'}">${T('ui.unified')}</button></div><input type="search" id="diff-search" value="${E(search)}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"><label class="checkbox-label"><input type="checkbox" id="diff-changes-only"${M.state.diffChangesOnly ? ' checked' : ''}>${T('ui.changesOnly')}</label><div class="diff-stats">${[['added', 'ui.added'], ['removed', 'ui.removed'], ['modified', 'ui.modified'], ['context', 'ui.context']].map(([key, label]) => `<span class="${key}">${T(label)} <strong>${Number(stats[key]) || 0}</strong></span>`).join('')}</div></div>${M.state.diffMode === 'split' ? `<div class="diff-split">${pane('left')}${pane('right')}</div>` : unified()}<div class="diff-footer">${pager}<div class="actions">${B('download-diff', t('common.download') + ' .diff', 'download')}${B('download-html-diff', t('common.download') + ' HTML', 'files')}</div></div></section>`;
const pager = M.state.diffShowAll ? `<span>${T('ui.lines')}: ${allRows.length}</span>` : pages > 1 ? `<div class="diff-pager"><button type="button" class="btn small" data-action="diff-page-prev"${M.state.diffPage <= 1 ? ' disabled' : ''}>${T('ui.previous')}</button><span>${T('ui.page')} <strong>${M.state.diffPage}</strong> / ${pages} · ${start + 1}–${Math.min(allRows.length, start + rows.length)} / ${allRows.length}</span><button type="button" class="btn small" data-action="diff-page-next"${M.state.diffPage >= pages ? ' disabled' : ''}>${T('ui.next')}</button></div>` : `<span>${T('ui.lines')}: ${allRows.length}</span>`;
const fullToggle = pages > 1 || M.state.diffShowAll ? B('diff-toggle-all', t(M.state.diffShowAll ? 'ui.usePagination' : 'ui.loadFullDiff'), 'files') : '';
return `${M.smartDiff(d.smart)}<section class="card raw-diff"><div class="diff-toolbar"><div class="tabs" role="tablist"><button type="button" class="tab ${M.state.diffMode === 'split' ? 'active' : ''}" data-diff-mode="split" role="tab" aria-selected="${M.state.diffMode === 'split'}">${T('ui.split')}</button><button type="button" class="tab ${M.state.diffMode === 'unified' ? 'active' : ''}" data-diff-mode="unified" role="tab" aria-selected="${M.state.diffMode === 'unified'}">${T('ui.unified')}</button></div><input type="search" id="diff-search" value="${E(search)}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"><label class="checkbox-label"><input type="checkbox" id="diff-changes-only"${M.state.diffChangesOnly ? ' checked' : ''}>${T('ui.changesOnly')}</label><div class="diff-stats">${[['added', 'ui.added'], ['removed', 'ui.removed'], ['modified', 'ui.modified'], ['context', 'ui.context']].map(([key, label]) => `<span class="${key}">${T(label)} <strong>${Number(stats[key]) || 0}</strong></span>`).join('')}</div></div>${M.state.diffMode === 'split' ? `<div class="diff-split">${pane('left')}${pane('right')}</div>` : unified()}<div class="diff-footer">${pager}<div class="actions">${fullToggle}${B('open-html-diff', t('ui.openStandaloneDiff'), 'globe')}${B('download-diff', t('common.download') + ' .diff', 'download')}${B('download-html-diff', t('common.download') + ' HTML', 'files')}</div></div></section>`;
};
M.diffControls = () => {
const files = (M.state.cache.diffFiles || []).filter(M.isTextExport), routers = (M.state.cache.routers || []).filter(M.supportsExport), s = M.state.diffSelection || {};
const options = [['', t('diffConfigs.noneSelected')], ...files.map(f => [f.id, `${f.router_name || '#' + f.router_id} · ${M.date(f.created_at)} · ${f.file_name}`])];
return `<form id="diff-form" class="diff-controls">${S('router_id', 'files.routerLabel', [['', t('files.allRouters')], ...routers.map(r => [r.id, r.name])], s.router_id || '')}${S('left', 'files.compareOlder', options, s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right', 'files.compareNewer', options, s.right || '', 'required')}<button class="btn primary" type="submit">${I('diff')}${T('ui.compare')}</button></form>`;
const mode = s.mode === 'cross' ? 'cross' : 'same';
const fileOptions = list => [['', t('diffConfigs.noneSelected')], ...list.map(f => [f.id, `${M.date(f.created_at)} · ${f.file_name}`])];
const routerOptions = [['', t('diffConfigs.chooseDevice')], ...routers.map(r => [r.id, `${r.name} · ${r.host}`])];
let selectors;
if (mode === 'cross') {
const leftFiles = s.left_router_id ? files.filter(f => String(f.router_id) === String(s.left_router_id)) : [];
const rightFiles = s.right_router_id ? files.filter(f => String(f.router_id) === String(s.right_router_id)) : [];
selectors = `${S('left_router_id', 'diffConfigs.leftDevice', routerOptions, s.left_router_id || '', 'required')}${S('left', 'files.compareOlder', fileOptions(leftFiles), s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right_router_id', 'diffConfigs.rightDevice', routerOptions, s.right_router_id || '', 'required')}${S('right', 'files.compareNewer', fileOptions(rightFiles), s.right || '', 'required')}`;
} else {
const scoped = s.router_id ? files.filter(f => String(f.router_id) === String(s.router_id)) : [];
const options = fileOptions(scoped);
selectors = `${S('router_id', 'files.routerLabel', routerOptions, s.router_id || '', 'required')}${S('left', 'files.compareOlder', options, s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right', 'files.compareNewer', options, s.right || '', 'required')}`;
}
return `<form id="diff-form" class="diff-controls ${mode === 'cross' ? 'cross-device' : ''}">${S('diff_scope', 'diffConfigs.compareMode', [['same', t('diffConfigs.sameDevice')], ['cross', t('diffConfigs.differentDevices')]], mode)}${selectors}<button class="btn primary" type="submit">${I('diff')}${T('ui.compare')}</button></form>`;
};
M.views.diff = async () => {
const revision = M.state.routeId;
const s = M.state.diffSelection || {};
const query = M.backupQuery({ backup_type: 'export', router_id: s.router_id || '', sort_by: 'created_at', order: 'desc' });
let [files, routers] = await Promise.all([M.api(`/backups?${query}`), M.api('/routers')]);
let [files, routers] = await Promise.all([M.api('/backups?backup_type=export&sort_by=created_at&order=desc'), M.api('/routers')]);
if (revision !== M.state.routeId) return "";
if (s.router_id && routers.some(router => String(router.id) === String(s.router_id) && !M.supportsExport(router))) {
M.state.diffSelection = { router_id: '', left: '', right: '' }; M.state.diff = null;
files = await M.api('/backups?backup_type=export&sort_by=created_at&order=desc');
if (revision !== M.state.routeId) return '';
history.replaceState({}, '', '/diff-configs');
}
M.state.cache.diffFiles = files.filter(M.isTextExport); M.state.cache.routers = routers;
const selection = M.state.diffSelection || s;
const allowed = new Set(M.state.cache.diffFiles.map(file => Number(file.id)));
M.state.diffSelection = { ...selection, left: allowed.has(Number(selection.left)) ? selection.left : '', right: allowed.has(Number(selection.right)) ? selection.right : '' };
const routerIds = new Set(routers.filter(M.supportsExport).map(router => String(router.id)));
const selection = { mode: s.mode === 'cross' ? 'cross' : 'same', router_id: s.router_id || '', left_router_id: s.left_router_id || '', right_router_id: s.right_router_id || '', left: s.left || '', right: s.right || '' };
if (!routerIds.has(String(selection.router_id))) selection.router_id = '';
if (!routerIds.has(String(selection.left_router_id))) selection.left_router_id = '';
if (!routerIds.has(String(selection.right_router_id))) selection.right_router_id = '';
if (!allowed.has(Number(selection.left))) selection.left = '';
if (!allowed.has(Number(selection.right))) selection.right = '';
if (selection.left && selection.right && !selection.router_id && !selection.left_router_id && !selection.right_router_id) {
const leftFile = M.state.cache.diffFiles.find(file => String(file.id) === String(selection.left));
const rightFile = M.state.cache.diffFiles.find(file => String(file.id) === String(selection.right));
if (leftFile && rightFile && String(leftFile.router_id) === String(rightFile.router_id)) {
selection.mode = 'same'; selection.router_id = leftFile.router_id;
} else if (leftFile && rightFile) {
selection.mode = 'cross'; selection.left_router_id = leftFile.router_id; selection.right_router_id = rightFile.router_id;
}
}
M.state.diffSelection = selection;
if (M.state.diff && (!allowed.has(Number(M.state.diff.left_backup_id)) || !allowed.has(Number(M.state.diff.right_backup_id)))) M.state.diff = null;
return `${M.heading('diffConfigs.title', 'diffConfigs.subtitle', B('latest-diff-pair', t('files.compareLatestPair'), 'clock', M.state.cache.diffFiles.length < 2 ? 'disabled' : '') + B('refresh', t('ui.refresh'), 'refresh'), 'diffConfigs.eyebrow')}<div id="diff-controls">${M.diffControls()}</div><div id="diff-result">${M.diffResult()}</div><div class="notice mt-16">${I('files')}<span>${T('files.binaryNoCompare')} · ${M.state.cache.diffFiles.length} ${T('files.exportPoolLabel')}</span></div>`;
};
@@ -237,7 +258,7 @@
const s = settings, hidden = key => M.state.settingsTab === key ? '' : ' hidden';
const general = `<div class="form-grid">${F('backup_retention_days', 'settings.backupRetentionDays', s.backup_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}${F('log_retention_days', 'settings.logRetentionDays', s.log_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}</div>`;
const schedules = `${M.scheduleEditor('export', 'settings.exportScheduleTitle', s.export_cron, s.enable_auto_export)}${M.scheduleEditor('binary', 'settings.binaryScheduleTitle', s.binary_cron)}${M.scheduleEditor('retention', 'settings.retentionTitle', s.retention_cron)}<div class="automation-row"><div class="automation-caption"><h3>${T('settings.connectionTestsTitle')}</h3><p class="automation-note">${T('settings.connectionTestsHint')}</p></div><div class="schedule-fields">${F('connection_test_interval_minutes', 'settings.connectionTestIntervalMinutes', s.connection_test_interval_minutes, { type: 'number', attrs: 'min="0" max="1440"' })}</div></div>`;
const credentials = `<h3 class="section-title">${T('settings.globalSshPrivateKey')}</h3><p class="section-hint">${T('settings.sshRevealHint')}</p>${M.secretField('global_ssh_key', 'settings.globalSshPrivateKey', s.has_global_ssh_key, 'textarea')}<div class="actions mt-15">${B('reveal-ssh', t('settings.revealSshKey'), 'eye', s.has_global_ssh_key ? '' : 'disabled')}${s.has_global_ssh_key ? M.badge(t('settings.sshKeyStoredTag'), 'success') : ''}</div><hr><h3 class="section-title">${T('settings.switchosDefaultsTitle')}</h3><p class="section-hint">${T('settings.switchosDefaultsHint')}</p><div class="form-grid">${F('default_switchos_username', 'settings.defaultSwitchosUsername', s.default_switchos_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_switchos_password', 'settings.defaultSwitchosPassword', s.has_default_switchos_password || s.has_default_switchos_credentials)}</div>`;
const credentials = `<h3 class="section-title">${T('settings.globalSshPrivateKey')}</h3><p class="section-hint">${T('settings.sshRevealHint')}</p>${M.secretField('global_ssh_key', 'settings.globalSshPrivateKey', s.has_global_ssh_key, 'textarea')}<div class="actions mt-15">${B('reveal-ssh', t('settings.revealSshKey'), 'eye', s.has_global_ssh_key ? '' : 'disabled')}${s.has_global_ssh_key ? M.badge(t('settings.sshKeyStoredTag'), 'success') : ''}</div><hr><h3 class="section-title">${T('settings.routerosDefaultsTitle')}</h3><p class="section-hint">${T('settings.routerosDefaultsHint')}</p><div class="form-grid">${F('default_routeros_username', 'settings.defaultRouterosUsername', s.default_routeros_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_routeros_password', 'settings.defaultRouterosPassword', s.has_default_routeros_password || s.has_default_routeros_credentials)}</div><hr><h3 class="section-title">${T('settings.switchosDefaultsTitle')}</h3><p class="section-hint">${T('settings.switchosDefaultsHint')}</p><div class="form-grid">${F('default_switchos_username', 'settings.defaultSwitchosUsername', s.default_switchos_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_switchos_password', 'settings.defaultSwitchosPassword', s.has_default_switchos_password || s.has_default_switchos_credentials)}</div>`;
const smtp = `${M.toggle('smtp_notifications_enabled', 'settings.smtpEnabled', s.smtp_notifications_enabled, 'settings.smtpEnabledHint')}${M.toggle('notify_failures_only', 'settings.failuresOnly', s.notify_failures_only, 'settings.failuresOnlyHint')}<div class="form-grid mt-22">${F('smtp_host', 'settings.smtpHost', s.smtp_host || '', { attrs: 'maxlength="255"' })}${F('smtp_port', 'settings.smtpPort', s.smtp_port || 587, { type: 'number', attrs: 'min="1" max="65535"' })}${F('smtp_login', 'settings.smtpLogin', s.smtp_login || '', { attrs: 'autocomplete="off"' })}${M.secretField('smtp_password', 'settings.smtpPassword', s.has_smtp_password)}${F('recipient_email', 'settings.recipientEmail', s.recipient_email || '', { type: 'email', wide: true })}</div><div class="actions mt-22">${B('test-email', t('settings.testEmail'), 'mail')}<small>${T('ui.saveFirst')}</small></div>`;
const pushover = `<div class="form-grid">${M.secretField('pushover_token', 'settings.pushoverToken', s.has_pushover_token)}${M.secretField('pushover_userkey', 'settings.pushoverUserKey', s.has_pushover_userkey)}</div><div class="actions mt-22">${B('test-pushover', t('settings.testPushover'), 'check')}<small>${T('ui.saveFirst')}</small></div>`;
return `<form id="settings-form" class="settings-form"><div class="settings-fields card"><div class="settings-fields-body card-body"><section data-settings-pane="general"${hidden('general')}><h3 class="section-title">${T('settings.retentionTitle')}</h3><p class="section-hint">${T('settings.retentionSubtitle')}</p>${general}</section><section data-settings-pane="automation"${hidden('automation')}>${schedules}</section><section data-settings-pane="credentials"${hidden('credentials')}>${credentials}</section><section data-settings-pane="smtp"${hidden('smtp')}>${smtp}</section><section data-settings-pane="pushover"${hidden('pushover')}>${pushover}</section></div></div><div class="settings-save">${B('refresh', t('common.reset'), 'refresh')}<button type="submit" class="btn primary">${I('check')}${T('settings.save')}</button></div></form>`;