Files
2026-10-28 09:16:54 +01:00

427 lines
38 KiB
JavaScript

(function (M) {
'use strict';
const { escape: E, tr: T, t, icon: I, button: B, iconButton: IB, field: F, select: S } = M;
const navigable = ['/', '/devices', '/files', '/diff-configs', '/logs', '/settings'];
const currentPath = () => location.pathname.replace(/\/$/, '') || '/';
const isAuthPath = path => path === '/login' || path === '/register';
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>`;
};
M.navigate = (path, replace = false) => {
if (!path.startsWith('/') || path.startsWith('//')) return;
M.closeDialog(); M.state.mobile = false; M.state.page = 1; M.state.selected.clear();
const oldPath = currentPath();
(replace ? history.replaceState : history.pushState).call(history, {}, '', path);
if (oldPath !== currentPath()) window.scrollTo(0, 0);
return M.render();
};
M.syncUrlState = path => {
const params = new URLSearchParams(location.search);
if (path === '/diff-configs') {
M.state.diffSelection = {
router_id: params.get('router_id') || M.state.diffSelection?.router_id || '',
left: params.get('left') || M.state.diffSelection?.left || '',
right: params.get('right') || M.state.diffSelection?.right || ''
};
}
if (path === '/files' && params.has('router_id')) M.state.filters.router_id = params.get('router_id');
if (path === '/settings' && params.has('tab')) M.state.settingsTab = M.settingsTabKeys.includes(params.get('tab')) ? params.get('tab') : 'general';
};
M.render = async () => {
let path = currentPath();
if (path === '/routers' || path.startsWith('/routers/')) return M.navigate(path.replace('/routers', '/devices'), true);
if (path === '/change-password') return M.navigate('/settings?tab=account', true);
if (path === '/schedule' || path === '/settings' && new URLSearchParams(location.search).get('tab') === 'schedule') return M.navigate('/settings?tab=automation', true);
if (path === '/register' && !M.config.allowRegistration) return M.navigate(M.state.user ? '/' : '/login', true);
if (!M.state.user && !isAuthPath(path)) return M.navigate('/login', true);
if (M.state.user && isAuthPath(path)) return M.navigate('/', true);
if (!isAuthPath(path) && !navigable.includes(path) && !/^\/devices\/\d+$/.test(path)) return M.navigate('/', true);
const revision = ++M.state.routeId;
M.applyPreferences();
document.documentElement.dataset.page = isAuthPath(path) ? 'auth' : 'app';
document.title = `MikroTik Backup System · 0.0.1`;
if (isAuthPath(path)) { M.$('#app').innerHTML = M.authView(path === '/register'); return; }
M.syncUrlState(path);
M.$('#app').innerHTML = M.shell(); M.updateHealth();
try {
let html;
if (path === '/') html = await M.views.dashboard();
else if (path === '/devices') html = await M.views.devices();
else if (path.startsWith('/devices/')) html = await M.views.device(Number(path.split('/')[2]));
else if (path === '/files') html = await M.views.files();
else if (path === '/diff-configs') html = await M.views.diff();
else if (path === '/logs') html = await M.views.logs();
else if (path === '/settings') html = await M.views.settings();
if (revision !== M.state.routeId) return;
M.$('#main').innerHTML = html;
M.bindView();
if (path === '/devices') M.loadPings();
if (path === '/diff-configs') {
const { left, right } = M.state.diffSelection;
if (left && right && left !== right && (String(M.state.diff?.left_backup_id) !== String(left) || String(M.state.diff?.right_backup_id) !== String(right))) {
const el = M.$('#diff-form button[type="submit"]');
M.run(el, () => M.compare(left, right));
}
}
} catch (error) {
if (revision !== M.state.routeId) return;
const main = M.$('#main');
if (main) main.innerHTML = `<div class="error-panel" role="alert">${I('alert')}<p>${E(M.errorMessage(error))}</p>${B('refresh', t('ui.retry'), 'refresh')}</div>`;
}
};
M.bindView = () => {
M.$$('[data-bar-height]').forEach(el => { el.style.height = `${Number(el.dataset.barHeight) || 0}px`; });
M.bindDiffScroll();
M.$$('[data-schedule]').forEach(el => M.updateScheduleEditor(el));
M.updateSelection();
};
M.bindDiffScroll = () => {
const left = M.$('[data-diff-scroll="left"]'), right = M.$('[data-diff-scroll="right"]');
if (!left || !right) return;
let scheduled = false;
const sync = (from, to) => {
if (scheduled) return;
scheduled = true;
to.scrollTop = from.scrollTop; to.scrollLeft = from.scrollLeft;
requestAnimationFrame(() => { scheduled = false; });
};
left.addEventListener('scroll', () => sync(left, right), { passive: true });
right.addEventListener('scroll', () => sync(right, left), { passive: true });
};
M.redrawDiff = (restoreSearch = false) => {
const result = M.$('#diff-result'); if (!result) return;
const position = M.$('[data-diff-scroll="left"]')?.scrollTop || 0;
const selection = M.$('#diff-search')?.selectionStart;
result.innerHTML = M.diffResult(); M.bindDiffScroll();
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.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 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);
history.replaceState({}, '', `/diff-configs?${query}`);
M.redrawDiff();
};
M.loadPings = async () => {
if (M.state.pingLoading) return;
M.state.pingLoading = true;
M.$$('[data-ping]').forEach(el => { const r = M.state.cache.routers.find(r => r.id === Number(el.dataset.ping)); if (!r?.disable_ping) el.textContent = t('routers.pingChecking'); });
try {
const response = await M.api('/routers/ping-statuses');
M.state.cache.pings = response.items || [];
for (const ping of M.state.cache.pings) {
const el = M.$(`[data-ping="${Number(ping.router_id)}"]`);
if (el) {
const unavailable = !!ping.error && /unavailable|permission|not permitted|not supported|unsupported|ping_group_range|capability|denied/i.test(ping.error);
el.title = ping.error || '';
el.innerHTML = ping.disabled ? `<span class="muted">${T('routers.pingDisabled')}</span>` : unavailable ? M.badge(t('ui.unknown'), 'muted') : M.badge(ping.reachable ? `${ping.latency_ms == null ? t('common.ok') : Number(ping.latency_ms).toFixed(1) + ' ms'}` : t('routers.noPing'), ping.reachable ? 'success' : 'danger');
}
}
} catch (error) { M.$$('[data-ping]').forEach(el => { el.textContent = '—'; el.title = M.errorMessage(error); }); }
finally { M.state.pingLoading = false; }
};
M.updateHealth = () => {
const el = M.$('#health-status'); if (!el) return;
const h = M.state.health;
el.className = `api-status ${h.online === null ? 'pending' : h.online ? '' : 'offline'}`;
el.innerHTML = `<span class="dot"></span><span>${T(h.online === null ? 'footer.apiChecking' : h.online ? 'ui.systemOnline' : 'ui.systemOffline')}${h.latency == null ? '' : ` · ${h.latency} ms`}</span>`;
};
M.probeHealth = async () => {
if (M.state.healthBusy || document.hidden) return;
M.state.healthBusy = true; const start = performance.now();
try { const r = await M.api('/health', { timeout: 5000, quietAuth: true }); M.state.health = { online: r.status === 'ok', latency: Math.max(1, Math.round(performance.now() - start)) }; }
catch (_) { M.state.health = { online: false, latency: null }; }
finally { M.state.healthBusy = false; M.updateHealth(); }
};
M.visibleFiles = () => currentPath().startsWith('/devices/') ? (M.state.cache.deviceBackups || []).filter(f => f.backup_type === M.state.detailTab) : M.state.cache.files || [];
M.findFile = id => [M.state.cache.files, M.state.cache.deviceBackups, M.state.cache.backups, M.state.cache.diffFiles].filter(Boolean).flat().find(file => file.id === Number(id));
M.updateSelection = () => {
const el = M.$('#selected-count'); if (el) el.textContent = M.state.selected.size;
M.$$('[data-select-backup]').forEach(box => { box.checked = M.state.selected.has(Number(box.dataset.selectBackup)); box.closest('tr')?.classList.toggle('selected-row', box.checked); });
const visible = M.$$('[data-select-backup]'); const all = M.$('#select-page');
if (all) { all.checked = visible.length > 0 && visible.every(box => box.checked); all.indeterminate = visible.some(box => box.checked) && !all.checked; }
const selected = [...M.state.selected].map(id => M.findFile(id));
const comparable = selected.length === 2 && selected.every(M.isTextExport);
M.$$('[data-action="compare-selected"]').forEach(button => { button.hidden = !comparable; });
};
M.redrawPageTable = () => {
const path = currentPath();
if (path === '/devices') { M.$('#device-table').innerHTML = M.deviceTable(); M.loadPings(); }
if (path === '/files') M.$('#files-table').innerHTML = M.backupTable(M.state.cache.files || [], { select: true });
if (path.startsWith('/devices/')) M.$('#device-detail-body').innerHTML = M.detailBody();
if (path === '/logs') M.$('#logs-table').innerHTML = M.logsTable();
M.updateSelection();
};
M.updateScheduleEditor = editor => {
const p = editor.dataset.schedule, mode = M.$(`[name="${p}_mode"]`, editor)?.value || 'disabled';
M.$('[data-schedule-time]', editor).hidden = mode === 'disabled' || mode === 'custom';
M.$('[data-schedule-weekday]', editor).hidden = mode !== 'weekly';
M.$('[data-schedule-custom]', editor).hidden = mode !== 'custom';
const expr = M.cronFromEditor(editor);
M.$('[data-schedule-preview]', editor).textContent = expr || t('settings.scheduleDisabledHint');
};
M.cronFromEditor = editor => {
const p = editor.dataset.schedule, get = key => M.$(`[name="${p}_${key}"]`, editor)?.value || '';
const mode = get('mode');
if (mode === 'disabled') return '';
if (mode === 'custom') return get('custom').trim();
const [hour, minute] = (get('time') || '02:00').split(':');
return `${Number(minute)} ${Number(hour)} * * ${mode === 'weekly' ? get('weekday') : '*'}`;
};
M.editDevice = async id => {
const router = id ? await M.api(`/routers/${Number(id)}`) : null;
M.openDialog(t(router ? 'routers.editDialogTitle' : 'routers.createDialogTitle'), M.deviceForm(router));
M.syncDeviceCredentialMode(M.$('#device-form'));
};
M.syncDeviceCredentialMode = form => {
if (!form) return;
const switchos = form.elements.device_type.value === 'switchos';
const source = form.elements.use_global_switchos_credentials;
const global = switchos && source.value === 'true';
M.$('#switchos-credentials-mode', form).hidden = !switchos;
source.disabled = !switchos;
const own = M.$('#device-own-credentials', form);
own.hidden = global;
M.$$('input', own).forEach(input => { input.disabled = global; });
const key = M.$('#device-key-field', form);
key.hidden = switchos;
M.$$('input, textarea', key).forEach(input => { input.disabled = switchos; });
const hint = M.$('[data-switchos-mode-hint]', form);
hint.hidden = source.value === 'false';
hint.textContent = t(source.value === '' ? 'ui.legacyCredentialsHint' : 'ui.globalCredentialsHint');
};
M.openNewBackup = async () => {
const routers = await M.api('/routers'); M.state.cache.routers = routers;
const active = routers.filter(r => !r.disable_all_backups && ((!r.disable_export_backups && r.device_type !== 'switchos') || !r.disable_binary_backups));
if (!active.length) { M.openDialog(t('ui.newBackup'), M.empty('ui.empty', 'ui.emptyHint', 'device') + `<div class="dialog-actions">${B('new-device', t('ui.newDevice'), 'plus', '', 'primary')}</div>`); return; }
M.openDialog(t('ui.newBackup'), `<form id="backup-form"><div class="form-grid">${S('router_id', 'files.routerLabel', active.map(r => [r.id, `${r.name} · ${r.host}`]), active[0].id, 'required')}${S('backup_type', 'files.typeLabel', [['export', `${t('files.exportType')} (RSC)`], ['binary', t('files.binaryType')]], 'export')}</div><div class="form-actions">${B('close-dialog', t('common.cancel'))}<button type="submit" class="btn primary">${I('play')}${T('ui.newBackup')}</button></div></form>`);
M.syncBackupType();
};
M.syncBackupType = () => {
const form = M.$('#backup-form'); if (!form) return;
const router = M.state.cache.routers.find(r => r.id === Number(form.elements.router_id.value));
const select = form.elements.backup_type;
const previous = select.value;
const types = [...(M.supportsExport(router) ? [['export', `${t('files.exportType')} (RSC)`]] : []), ['binary', t('files.binaryType')]];
select.innerHTML = types.map(([type, label]) => `<option value="${type}"${router?.disable_all_backups || (type === 'export' ? router?.disable_export_backups : router?.disable_binary_backups) ? ' disabled' : ''}>${E(label)}</option>`).join('');
select.value = Array.from(select.options).find(option => !option.disabled && option.value === previous)?.value || Array.from(select.options).find(option => !option.disabled)?.value || '';
};
M.batch = async type => {
if (!await M.confirm(t('ui.batchConfirm'), { danger: false })) return;
const results = await M.api(`/backups/routers/${type}-all`, { method: 'POST', body: {}, timeout: 1800000 });
const rows = Array.isArray(results) ? results : results.results || [];
const failures = rows.some(r => r.status === 'error' || r.status === 'failed');
M.openDialog(t('ui.batchResults'), `${failures ? `<div class="notice warning">${I('alert')}${T('ui.partial')}</div>` : ''}<div class="table-wrap"><table><thead><tr><th>${T('files.routerLabel')}</th><th>${T('ui.result')}</th><th>${T('ui.description')}</th></tr></thead><tbody>${rows.map(r => `<tr><td>${E(r.router_name || r.name || r.router || '#' + r.router_id)}</td><td>${M.badge(r.status === 'ok' ? t('common.ok') : r.status === 'skipped' ? t('ui.skipped') : t('common.failed'), r.status === 'ok' ? 'success' : r.status === 'skipped' ? 'muted' : 'danger')}</td><td class="break">${E(r.error || r.reason || r.message || r.file_name || '')}</td></tr>`).join('')}</tbody></table></div><div class="dialog-actions">${B('close-dialog', t('ui.close'))}</div>`, { wide: true });
await M.render();
};
M.showPreview = async id => {
const data = await M.api(`/backups/${Number(id)}/view`), file = data.backup || M.findFile(id) || {};
M.openDialog(file.file_name || t('files.previewDialogTitle'), `<div class="preview-meta"><span>${E(M.date(file.created_at))}</span><span>${E(M.bytes(file.file_size))}</span>${file.checksum ? `<code>SHA-256 ${E(file.checksum)}</code>` : ''}</div><pre class="mono-pre">${E(data.content)}</pre><div class="dialog-actions">${B('download', t('common.download'), 'download', `data-id="${Number(id)}"`)}${B('close-dialog', t('ui.close'))}</div>`, { wide: true });
};
M.runDeviceBackup = async (id, type) => {
await M.api(`/backups/router/${Number(id)}/${type}`, { method: 'POST', body: {}, timeout: 900000 });
M.toast(t(type === 'export' ? 'toast.exportCreated' : 'toast.binaryCreated')); await M.render();
};
M.testDevice = async id => {
const result = await M.api(`/routers/${Number(id)}/test-connection`);
M.toast(t(result.success ? 'toast.connectionSuccessful' : 'toast.connectionFailed'), result.success ? 'success' : 'danger');
await M.render();
if (!result.success && result.error) M.openDialog(t('ui.connectionDetails'), `<div class="notice warning">${I('alert')}<span class="break">${E(result.error)}</span></div><div class="dialog-actions">${B('close-dialog', t('ui.close'))}</div>`);
};
const actions = {
'close-dialog': () => M.closeDialog(),
refresh: () => M.render(),
health: () => M.probeHealth(),
theme: () => { M.state.theme = M.state.theme === 'dark' ? 'light' : 'dark'; M.applyPreferences(); M.$$('[data-action="theme"]').forEach(el => { if (el.closest('.auth-layout')) el.textContent = t('ui.changeTheme'); else el.innerHTML = I(M.state.theme === 'dark' ? 'moon' : 'sun'); }); },
sidebar: () => { if (window.innerWidth <= 760) M.state.mobile = !M.state.mobile; else { M.state.collapsed = !M.state.collapsed; M.store(localStorage, 'mbs_sidebar', M.state.collapsed ? 'collapsed' : 'expanded'); } M.$('.layout')?.classList.toggle('collapsed', M.state.collapsed); M.$('.layout')?.classList.toggle('mobile-open', M.state.mobile); },
'sidebar-close': () => { M.state.mobile = false; M.$('.layout')?.classList.remove('mobile-open'); },
logout: () => { M.setToken(''); M.state.user = null; M.state.cache = {}; M.state.diff = null; M.state.selected.clear(); M.closeDialog(); try { localStorage.removeItem('routeros_token'); } catch (_) { /* no-op */ } M.navigate('/login'); },
'new-device': () => M.editDevice(),
'edit-device': el => M.editDevice(el.dataset.id),
'test-device': el => M.testDevice(el.dataset.id),
'delete-device': async el => { const id = Number(el.dataset.id); if (!await M.confirm(t('confirm.deleteRouterWithFiles'), { label: t('common.delete') })) return; await M.api(`/routers/${id}`, { method: 'DELETE' }); M.toast(t('toast.routerDeleted')); await M.navigate('/devices'); },
'refresh-ping': () => M.loadPings(),
'new-backup': () => M.openNewBackup(),
'device-export': el => M.runDeviceBackup(el.dataset.id, 'export'),
'device-binary': el => M.runDeviceBackup(el.dataset.id, 'binary'),
'batch-export': () => M.batch('export'),
'batch-binary': () => M.batch('binary'),
preview: el => M.showPreview(el.dataset.id),
'diff-latest': async el => {
const selected = M.findFile(el.dataset.id);
if (!M.isTextExport(selected)) throw new Error(t('ui.selectPair'));
const files = (await M.api(`/backups?backup_type=export&router_id=${selected.router_id}&sort_by=created_at&order=desc`)).filter(M.isTextExport);
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)}`);
},
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)}`); },
'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; },
'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')); },
'test-pushover': async () => { await M.api('/settings/test-pushover', { method: 'POST', body: {} }); M.toast(t('toast.testPushoverSent')); },
'reveal-ssh': () => M.openDialog(t('settings.globalSshPrivateKey'), `<form id="reveal-form">${F('password', 'settings.revealSshPassword', '', { type: 'password', required: true, attrs: 'autocomplete="current-password"' })}<div class="form-actions">${B('close-dialog', t('common.cancel'))}<button type="submit" class="btn primary">${I('eye')}${T('settings.revealSshKey')}</button></div></form>`),
'refresh-scheduler': async () => { const status = await M.api('/settings/scheduler-status'); M.state.cache.scheduler = status; const table = M.$('#scheduler-status-body'); if (table) table.innerHTML = M.schedulerTable(status); }
};
document.addEventListener('click', event => {
const nav = event.target.closest('a[data-nav]');
if (nav && event.button === 0 && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) { event.preventDefault(); M.navigate(nav.getAttribute('href')); return; }
const detailTab = event.target.closest('[data-detail-tab]');
if (detailTab) { M.state.detailTab = detailTab.dataset.detailTab; M.state.page = 1; M.state.selected.clear(); M.$('#device-tabs').innerHTML = M.detailTabs(M.state.cache.device.id); M.$('#device-detail-body').innerHTML = M.detailBody(); M.updateSelection(); return; }
const settingsTab = event.target.closest('[data-settings-tab]');
if (settingsTab) {
const tab = settingsTab.dataset.settingsTab;
if (!M.settingsTabKeys.includes(tab)) return;
M.state.settingsTab = tab;
if (currentPath() === '/settings') history.replaceState({}, '', `/settings?tab=${tab}`);
M.$$('[data-settings-tab]').forEach(el => { const active = el.dataset.settingsTab === tab; el.classList.toggle('active', active); el.setAttribute('aria-selected', String(active)); });
M.$$('[data-settings-pane]').forEach(el => { el.hidden = el.dataset.settingsPane !== tab; });
M.$$('[data-settings-system]').forEach(el => { el.hidden = tab === 'account'; });
M.$$('[data-settings-automation]').forEach(el => { el.hidden = tab !== 'automation'; });
return;
}
const diffMode = event.target.closest('[data-diff-mode]');
if (diffMode) { M.state.diffMode = diffMode.dataset.diffMode; M.redrawDiff(); return; }
const el = event.target.closest('[data-action]');
if (el && !el.disabled && actions[el.dataset.action]) { event.preventDefault(); M.run(el, () => actions[el.dataset.action](el)); }
});
document.addEventListener('change', event => {
const el = event.target;
if (el.matches('[data-language]')) {
const lang = el.value;
M.run(el, async () => { if (M.state.user) { const user = await M.api('/auth/preferences', { method: 'PUT', body: { preferred_language: lang, preferred_font: M.state.font } }); M.applyPreferences(user); } else { M.state.lang = lang; M.applyPreferences(); } await M.render(); });
}
if (el.matches('[data-select-backup]')) { const id = Number(el.dataset.selectBackup); el.checked ? M.state.selected.add(id) : M.state.selected.delete(id); M.updateSelection(); }
if (el.id === 'select-page') { M.$$('[data-select-backup]').forEach(box => { const id = Number(box.dataset.selectBackup); el.checked ? M.state.selected.add(id) : M.state.selected.delete(id); }); M.updateSelection(); }
if (el.closest('#device-filter')) { M.state.deviceFilters = M.formData(M.$('#device-filter')); M.state.page = 1; M.redrawPageTable(); }
if (el.name === 'device_type' && el.closest('#device-form')) { const form = el.form; form.elements.port.value = el.value === 'switchos' ? 80 : 22; if (el.value === 'routeros' && !form.elements.ssh_user.value) form.elements.ssh_user.value = 'admin'; M.syncDeviceCredentialMode(form); }
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 === '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]'));
});
let deviceSearchTimer, logsSearchTimer, diffSearchTimer;
document.addEventListener('input', event => {
const el = event.target;
if (el.name === 'search' && el.form?.id === 'device-filter') { clearTimeout(deviceSearchTimer); deviceSearchTimer = setTimeout(() => { M.state.deviceFilters = M.formData(el.form); M.state.page = 1; M.$('#device-table').innerHTML = M.deviceTable(); M.loadPings(); }, 180); }
if (el.id === 'logs-search') { clearTimeout(logsSearchTimer); logsSearchTimer = setTimeout(() => { M.state.logSearch = el.value; M.state.page = 1; M.$('#logs-table').innerHTML = M.logsTable(); }, 150); }
if (el.id === 'diff-search') { clearTimeout(diffSearchTimer); diffSearchTimer = setTimeout(() => { M.state.diffSearch = el.value; M.state.diffPage = 1; M.redrawDiff(true); }, 280); }
if (el.closest('[data-schedule]')) M.updateScheduleEditor(el.closest('[data-schedule]'));
if (el.form?.id === 'password-form') { const form = el.form, pass = form.elements.new_password.value, confirm = form.elements.confirm_password.value; const hints = { length: pass.length >= 8, digit: /\d/.test(pass), match: pass.length > 0 && pass === confirm }; Object.entries(hints).forEach(([key, ok]) => M.$(`[data-password-hint="${key}"]`)?.classList.toggle('success-text', ok)); }
});
document.addEventListener('submit', event => {
const form = event.target;
if (!form.id) return;
event.preventDefault();
if (!form.reportValidity()) return;
const button = event.submitter || M.$('button[type="submit"]', form);
if (button?.dataset.busy) return;
M.formError(form, '');
M.run(button, async () => {
try { await M.submitForm(form); }
catch (error) { if (form.isConnected) M.formError(form, M.errorMessage(error)); throw error; }
});
});
M.submitForm = async form => {
const data = M.formData(form), id = Number(form.dataset.id);
if (form.id === 'login-form') {
const result = await M.api('/auth/login', { method: 'POST', body: { username: data.username, password: data.password }, quietAuth: true });
M.setToken(result.access_token); M.applyPreferences(result.user); form.reset(); await M.navigate('/'); return;
}
if (form.id === 'register-form') {
if (!M.config.allowRegistration) { await M.navigate('/login', true); return; }
if (data.password !== data.confirm_password) throw new Error(t('auth.passwordsMismatch'));
await M.api('/auth/register', { method: 'POST', body: { username: data.username, password: data.password }, quietAuth: true });
form.reset(); M.toast(t('auth.accountCreated')); await M.navigate('/login'); return;
}
if (form.id === 'password-form') {
if (data.new_password !== data.confirm_password) throw new Error(t('auth.passwordsMismatch'));
await M.api('/auth/change-password', { method: 'POST', body: { current_password: data.current_password, new_password: data.new_password } });
form.reset(); M.toast(t('toast.passwordChanged')); return;
}
if (form.id === 'device-form') {
const payload = { name: data.name.trim(), host: data.host.trim(), port: Number(data.port), device_type: data.device_type };
const global = data.device_type === 'switchos' && data.use_global_switchos_credentials === 'true';
if (data.device_type === 'switchos' && ['true', 'false'].includes(data.use_global_switchos_credentials)) payload.use_global_switchos_credentials = global;
if (!global) {
payload.ssh_user = (data.ssh_user || '').trim();
if (data.ssh_password) payload.ssh_password = data.ssh_password;
if (data.clear_ssh_password) payload.clear_ssh_password = true;
if (data.device_type !== 'switchos') {
if (data.ssh_key?.trim()) payload.ssh_key = data.ssh_key.trim();
if (data.clear_ssh_key) payload.clear_ssh_key = true;
}
}
await M.api(id ? `/routers/${id}` : '/routers', { method: id ? 'PUT' : 'POST', body: payload });
M.closeDialog(); M.toast(t(id ? 'toast.routerUpdated' : 'toast.routerCreated')); await M.render(); return;
}
if (form.id === 'device-policy-form') {
const payload = Object.fromEntries(['disable_all_backups', 'disable_export_backups', 'disable_binary_backups', 'disable_ping'].filter(key => form.elements[key]).map(key => [key, form.elements[key].checked]));
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 === '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; }
if (form.id === 'logs-cleanup') { const days = Number(data.days); if (!await M.confirm(t('confirm.deleteLogsOlderThan', { days }), { label: t('common.delete') })) return; await M.api(`/logs/older-than/${days}`, { method: 'DELETE' }); M.toast(t('toast.logsDeletedOlderThan', { days })); await M.render(); return; }
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 ['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']) {
if (data[key]) payload[key] = key === 'global_ssh_key' ? data[key].trim() : data[key];
if (data[`clear_${key}`]) payload[`clear_${key}`] = true;
}
for (const key of ['export', 'binary', 'retention']) payload[`${key}_cron`] = M.cronFromEditor(M.$(`[data-schedule="${key}"]`, form));
payload.enable_auto_export = form.elements.export_mode.value !== 'disabled';
await M.api('/settings', { method: 'PUT', body: payload }); M.toast(t('toast.settingsSaved')); await M.render(); return;
}
if (form.id === 'preferences-form') { const user = await M.api('/auth/preferences', { method: 'PUT', body: { preferred_language: data.preferred_language, preferred_font: data.preferred_font } }); M.applyPreferences(user); M.toast(t('toast.settingsSaved')); await M.render(); return; }
if (form.id === 'reveal-form') { const result = await M.api('/settings/reveal-ssh-key', { method: 'POST', body: { password: data.password } }); form.reset(); M.openDialog(t('settings.globalSshPrivateKey'), `<pre class="mono-pre secret-preview">${E(result.global_ssh_key || t('ui.notSet'))}</pre><div class="dialog-actions">${B('close-dialog', t('settings.hideSshKey'))}</div>`); }
};
window.addEventListener('popstate', () => { M.state.page = 1; M.state.selected.clear(); M.closeDialog(); M.render(); });
window.addEventListener('online', () => M.probeHealth());
window.addEventListener('offline', () => { M.state.health = { online: false, latency: null }; M.updateHealth(); });
document.addEventListener('visibilitychange', () => { if (!document.hidden) M.probeHealth(); });
M.boot = async () => {
M.applyPreferences();
if (!M.state.token) { try { const legacy = localStorage.getItem('routeros_token'); if (legacy) M.setToken(legacy); } catch (_) { /* Storage may be unavailable. */ } }
if (M.state.token) {
try { const user = await M.api('/auth/me', { quietAuth: true, timeout: 15000 }); M.applyPreferences(user); try { localStorage.removeItem('routeros_token'); } catch (_) { /* no-op */ } }
catch (_) { M.setToken(''); M.state.user = null; }
}
await M.render(); M.probeHealth();
setInterval(() => M.probeHealth(), 15000);
};
M.boot();
})(window.MBS);