(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.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 = `${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);