(function (M) { 'use strict'; const read = (store, key) => { try { return store.getItem(key); } catch (_) { return null; } }; const write = (store, key, value) => { try { value === null ? store.removeItem(key) : store.setItem(key, value); } catch (_) { /* Storage may be disabled. In-memory state still works. */ } }; M.config = { allowRegistration: document.querySelector('meta[name="allow-registration"]')?.content === 'true' }; M.state = { token: read(sessionStorage, 'mbs_token') || '', user: null, lang: read(localStorage, 'routeros_lang') || 'en', font: read(localStorage, 'routeros_font') || 'default', 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, filters: { search: '', backup_type: '', router_id: '', created_on: '', sort_by: 'created_at', order: 'desc' } }; M.$ = (selector, root = document) => root.querySelector(selector); M.$$ = (selector, root = document) => Array.from(root.querySelectorAll(selector)); M.escape = (value) => String(value == null ? '' : value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); M.t = (key, params = {}) => { const get = lang => key.split('.').reduce((v, k) => v && v[k], M.translations[lang]); const value = get(M.state.lang) || get('en') || key; return String(value).replace(/{{\s*([^} ]+)\s*}}/g, (_, k) => String(params[k] == null ? '' : params[k])); }; M.tr = (key, params) => M.escape(M.t(key, params)); M.store = write; M.setToken = token => { M.state.token = token || ''; write(sessionStorage, 'mbs_token', token || null); }; M.applyPreferences = user => { if (user) { M.state.user = user; M.state.lang = ['pl', 'en', 'es', 'no'].includes(user.preferred_language) ? user.preferred_language : 'en'; M.state.font = ['default', 'adwaita_mono', 'hack'].includes(user.preferred_font) ? user.preferred_font : 'default'; } if (!M.translations[M.state.lang]) M.state.lang = 'en'; document.documentElement.lang = M.state.lang; document.documentElement.dataset.theme = M.state.theme; document.documentElement.dataset.font = M.state.font; document.documentElement.style.colorScheme = M.state.theme; write(localStorage, 'routeros_lang', M.state.lang); write(localStorage, 'routeros_font', M.state.font); write(localStorage, 'routeros_theme', M.state.theme); }; M.parseDate = value => { if (!value) return null; let text = String(value).replace(' ', 'T'); if (!/[zZ]$|[+-]\d{2}:?\d{2}$/.test(text)) text += 'Z'; const date = new Date(text); return Number.isNaN(date.getTime()) ? null : date; }; M.date = value => { const date = M.parseDate(value); return date ? date.toLocaleString({ pl: 'pl-PL', en: 'en-GB', es: 'es-ES', no: 'nb-NO' }[M.state.lang], { dateStyle: 'short', timeStyle: 'short' }) : '—'; }; M.age = value => { const date = M.parseDate(value); if (!date) return '—'; const delta = Math.max(0, Date.now() - date.getTime()); if (delta < 3600000) return M.t('files.minutesAgo', { value: Math.max(1, Math.floor(delta / 60000)) }); if (delta < 86400000) return M.t('files.hoursAgo', { value: Math.floor(delta / 3600000) }); return M.t('files.daysAgo', { value: Math.floor(delta / 86400000) }); }; M.bytes = value => { if (value === null || value === undefined || !Number.isFinite(Number(value))) return '—'; let n = Math.max(0, Number(value)), i = 0; while (n >= 1024 && i < 4) { n /= 1024; i++; } return `${n.toLocaleString(M.state.lang, { maximumFractionDigits: i ? 1 : 0 })} ${['B', 'KiB', 'MiB', 'GiB', 'TiB'][i]}`; }; M.icon = name => { const icons = { database: '', home: '', device: '', files: '', diff: '', clock: '', settings: '', user: '', key: '', lock: '', plus: '', refresh: '', download: '', upload: '', play: '', eye: '', mail: '', trash: '', edit: '', check: '', close: '', chevron: '', menu: '', moon: '', sun: '', logout: '', search: '', alert: '', shield: '', filter: '', swap: '', arrow: '', globe: '' }; return ``; }; M.button = (action, label, icon, attrs = '', tone = '') => ``; M.iconButton = (action, key, icon, attrs = '', tone = '') => ``; M.badge = (value, type = '') => `${M.escape(value)}`; M.connectionBadge = router => router.last_connection_status === true || router.last_connection_status === 1 ? M.badge(M.t('ui.online'), 'success') : router.last_connection_status === false || router.last_connection_status === 0 ? M.badge(M.t('ui.offline'), 'danger') : M.badge(M.t('ui.unknown'), 'muted'); M.field = (name, key, value = '', options = {}) => { const { type = 'text', required = false, attrs = '', placeholder = '', hint = '', wide = false } = options; const control = type === 'textarea' ? `` : ``; return ``; }; M.select = (name, key, values, selected = '', attrs = '') => ``; M.toggle = (name, key, value, hint = '') => ``; M.empty = (key = 'ui.empty', hint = '', icon = 'files') => `
${M.icon(icon)}${M.tr(key)}${hint ? `

${M.tr(hint)}

` : ''}
`; M.heading = (key, subtitle, actions = '', eyebrow = '') => `
${eyebrow ? `

${M.tr(eyebrow)}

` : ''}

${M.tr(key)}

${subtitle ? `

${M.tr(subtitle)}

` : ''}
${actions}
`; M.card = (title, body, extras = '', classes = '') => `

${M.tr(title)}

${extras}
${body}
`; 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 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'; 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, cache: 'no-store', credentials: 'same-origin', redirect: 'error' }); if (!response.ok) { let payload; try { payload = await response.json(); } catch (_) { payload = {}; } let detail = payload.detail || payload.error || response.statusText; if (Array.isArray(detail)) detail = detail.map(item => `${(item.loc || []).slice(1).join('.')}: ${item.msg || ''}`).join('\n'); if (typeof detail !== 'string') detail = M.t('ui.operationError'); if (response.status === 401 && !quietAuth && M.state.user) { M.setToken(''); M.state.user = null; M.navigate('/login'); detail = M.t('ui.logoutExpired'); } const error = new Error(detail || M.t('ui.operationError')); error.status = response.status; throw error; } if (blob) return { blob: await response.blob(), disposition: response.headers.get('Content-Disposition') || '' }; if (text) return response.text(); if (response.status === 204) return null; return await response.json(); } catch (error) { if (error.name === 'AbortError') throw new Error(M.t('ui.networkError')); if (error instanceof TypeError) throw new Error(M.t('ui.networkError')); throw error; } finally { clearTimeout(timer); if (signal) signal.removeEventListener('abort', forwardAbort); } }; M.toast = (message, tone = 'success') => { const el = document.createElement('div'); el.className = `toast ${tone}`; el.setAttribute('role', tone === 'danger' ? 'alert' : 'status'); el.innerHTML = `${M.icon(tone === 'danger' ? 'alert' : 'check')}${M.escape(message)}`; el.querySelector('button').addEventListener('click', () => el.remove()); M.$('#toast-region').append(el); while (M.$('#toast-region').children.length > 4) M.$('#toast-region').firstElementChild.remove(); setTimeout(() => el.remove(), tone === 'danger' ? 12000 : 6500); }; M.run = async (el, task, successKey) => { if (el?.dataset.busy) return; if (el) { el.dataset.busy = '1'; el.disabled = true; el.classList.add('is-busy'); el.setAttribute('aria-busy', 'true'); } try { const result = await task(); if (successKey) M.toast(M.t(successKey)); return result; } catch (error) { M.toast(M.errorMessage(error), 'danger'); return undefined; } finally { if (el) { delete el.dataset.busy; el.disabled = false; el.classList.remove('is-busy'); el.removeAttribute('aria-busy'); } } }; M.download = async (path, options = {}, fallback = 'backup') => { const result = await M.api(path, { ...options, blob: true }); const encoded = result.disposition.match(/filename\*=UTF-8''([^;]+)/i); const quoted = result.disposition.match(/filename="([^"\r\n]+)"|filename=([^;\r\n]+)/i); let name = fallback; try { name = encoded ? decodeURIComponent(encoded[1]) : (quoted && (quoted[1] || quoted[2])) || fallback; } catch (_) { /* Use a safe fallback. */ } M.saveBlob(result.blob, name.replace(/[\\/\x00-\x1f]/g, '_')); }; M.saveBlob = (blob, filename) => { const url = URL.createObjectURL(blob), link = document.createElement('a'); link.href = url; link.download = filename; link.hidden = true; document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 60000); }; M.openDialog = (title, html, options = {}) => { M.closeDialog(); const previous = document.activeElement; const dialog = document.createElement('dialog'); dialog.className = `dialog ${options.wide ? 'dialog-wide' : ''}`; dialog.innerHTML = `

${M.escape(title)}

${M.iconButton('close-dialog', 'ui.close', 'close')}
${html}
`; dialog.setAttribute('aria-labelledby', 'dialog-title'); M.$('#dialog-root').append(dialog); dialog.addEventListener('close', () => { if (options.onClose) options.onClose(); dialog.remove(); previous?.focus(); }); dialog.addEventListener('click', event => { if (event.target === dialog && event.clientX !== 0) { const b = dialog.getBoundingClientRect(); if (event.clientX < b.left || event.clientX > b.right || event.clientY < b.top || event.clientY > b.bottom) dialog.close(); } }); dialog.showModal(); return dialog; }; M.closeDialog = () => { const dialog = M.$('dialog[open]'); if (dialog) dialog.close(); }; M.confirm = (message, { label = M.t('common.confirm'), danger = true } = {}) => new Promise(resolve => { let result = false; const dialog = M.openDialog(M.t('confirm.header'), `

${M.escape(message)}

${M.button('close-dialog', M.t('common.cancel'))}
`, { onClose: () => resolve(result) }); M.$('#accept-confirm', dialog).addEventListener('click', () => { result = true; dialog.close(); }); }); M.pagination = (count, page = M.state.page) => { const pages = Math.max(1, Math.ceil(count / M.state.pageSize)); const safePage = Math.min(page, pages); return ``; }; M.paged = rows => { M.state.page = Math.min(Math.max(1, M.state.page), Math.max(1, Math.ceil(rows.length / M.state.pageSize))); return rows.slice((M.state.page - 1) * M.state.pageSize, M.state.page * M.state.pageSize); }; M.formData = form => Object.fromEntries(new FormData(form).entries()); M.formError = (form, message) => { let el = M.$('.form-error', form); if (form.id === 'diff-form') { el?.remove(); return; } if (!el) { el = document.createElement('p'); el.className = 'form-error'; el.setAttribute('role', 'alert'); form.prepend(el); } el.textContent = message; el.hidden = !message; }; })(window.MBS);