(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 `
${IB('sidebar', 'ui.menu', 'menu')}MikroTik Backup System
${I('user')}${E(M.state.user.username)}
${M.languageSelect()}${IB('theme', 'ui.changeTheme', M.state.theme === 'dark' ? 'moon' : 'sun')}${IB('logout', 'nav.logout', 'logout')}
${T('ui.loading')}
`; }; 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 = ``; } }; 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 ? `${T('routers.pingDisabled')}` : 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 = `${T(h.online === null ? 'footer.apiChecking' : h.online ? 'ui.systemOnline' : 'ui.systemOffline')}${h.latency == null ? '' : ` · ${h.latency} ms`}`; }; 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') + `
${B('new-device', t('ui.newDevice'), 'plus', '', 'primary')}
`); return; } M.openDialog(t('ui.newBackup'), `
${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')}
${B('close-dialog', t('common.cancel'))}
`); 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]) => ``).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 ? `
${I('alert')}${T('ui.partial')}
` : ''}
${rows.map(r => ``).join('')}
${T('files.routerLabel')}${T('ui.result')}${T('ui.description')}
${E(r.router_name || r.name || r.router || '#' + r.router_id)}${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')}${E(r.error || r.reason || r.message || r.file_name || '')}
${B('close-dialog', t('ui.close'))}
`, { 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'), `
${E(M.date(file.created_at))}${E(M.bytes(file.file_size))}${file.checksum ? `SHA-256 ${E(file.checksum)}` : ''}
${E(data.content)}
${B('download', t('common.download'), 'download', `data-id="${Number(id)}"`)}${B('close-dialog', t('ui.close'))}
`, { 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'), `
${I('alert')}${E(result.error)}
${B('close-dialog', t('ui.close'))}
`); }; 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'), `
${F('password', 'settings.revealSshPassword', '', { type: 'password', required: true, attrs: 'autocomplete="current-password"' })}
${B('close-dialog', t('common.cancel'))}
`), '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'), `
${E(result.global_ssh_key || t('ui.notSet'))}
${B('close-dialog', t('settings.hideSshKey'))}
`); } }; 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);