239 lines
17 KiB
JavaScript
239 lines
17 KiB
JavaScript
(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: '<ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 4 16 4 16 0V5M4 12c0 4 16 4 16 0"/>',
|
||
home: '<path d="m3 10 9-7 9 7v10H3zM9 20v-8h6v8"/>',
|
||
device: '<rect x="3" y="8" width="18" height="13" rx="2"/><path d="M7 8V3m10 5V3M7 13h.01M7 17h.01M11 13h6m-6 4h6"/>',
|
||
files: '<rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 7h8M8 11h8M8 15h5"/>',
|
||
diff: '<path d="M7 3v12m0 6v-2m10-2V5M4 6l3-3 3 3m4 12 3 3 3-3M4 15h6m4-6h6"/>',
|
||
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||
settings: '<path d="m9 3-1 3-3 1-2 3 2 2-1 3 3 3 3-1 2 2 3-1 1-3 3-1 2-3-2-2 1-3-3-3-3 1-2-2z"/><circle cx="12" cy="11" r="3"/>',
|
||
user: '<circle cx="12" cy="7" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>',
|
||
key: '<circle cx="8" cy="8" r="5"/><path d="m12 12 9 9m-5-5 3-3m-1 5 3-3"/>',
|
||
lock: '<rect x="5" y="10" width="14" height="11" rx="2"/><path d="M8 10V6a4 4 0 0 1 8 0v4m-4 4v3"/>',
|
||
plus: '<path d="M12 4v16M4 12h16"/>',
|
||
refresh: '<path d="M20 7a8 8 0 0 0-14-2L3 8m0-5v5h5m-4 9a8 8 0 0 0 14 2l3-3m0 5v-5h-5"/>',
|
||
download: '<path d="M12 3v12m-5-5 5 5 5-5M4 16v5h16v-5"/>',
|
||
upload: '<path d="M12 16V4m-5 5 5-5 5 5M4 16v5h16v-5"/>',
|
||
play: '<path d="m8 4 12 8-12 8z"/>',
|
||
eye: '<path d="M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>',
|
||
mail: '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 6 9 7 9-7"/>',
|
||
trash: '<path d="M3 6h18M9 6V3h6v3M5 6l1 15h12l1-15M10 10v7m4-7v7"/>',
|
||
edit: '<path d="m15 3 6 6-12 12H3v-6zM12 6l6 6"/>',
|
||
check: '<path d="m4 12 5 5L20 6"/>',
|
||
close: '<path d="m5 5 14 14M5 19 19 5"/>',
|
||
chevron: '<path d="m9 5 7 7-7 7"/>',
|
||
menu: '<path d="M3 6h18M3 12h18M3 18h18"/>',
|
||
moon: '<path d="M20 15A9 9 0 0 1 9 4a9 9 0 1 0 11 11z"/>',
|
||
sun: '<circle cx="12" cy="12" r="4"/><path d="M12 1v3m0 16v3M1 12h3m16 0h3M4 4l2 2m12 12 2 2M4 20l2-2M18 6l2-2"/>',
|
||
logout: '<path d="M10 3H4v18h6m4-14 5 5-5 5m-6-5h11"/>',
|
||
search: '<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>',
|
||
alert: '<path d="m12 3 10 18H2zM12 9v5m0 3h.01"/>',
|
||
shield: '<path d="m12 2 9 4v6c0 6-9 10-9 10S3 18 3 12V6zM8 12l3 3 5-6"/>',
|
||
filter: '<path d="M3 4h18l-7 8v8l-4-2v-6z"/>',
|
||
swap: '<path d="M3 7h17l-4-4m4 4-4 4M21 17H4l4 4m-4-4 4-4"/>',
|
||
arrow: '<path d="M4 12h16m-6-6 6 6-6 6"/>',
|
||
globe: '<circle cx="12" cy="12" r="9"/><ellipse cx="12" cy="12" rx="4" ry="9"/><path d="M3 12h18"/>'
|
||
};
|
||
return `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icons[name] || icons.files}</svg>`;
|
||
};
|
||
M.button = (action, label, icon, attrs = '', tone = '') => `<button type="button" class="btn ${tone}" data-action="${action}" ${attrs}>${icon ? M.icon(icon) : ''}<span>${M.escape(label)}</span></button>`;
|
||
M.iconButton = (action, key, icon, attrs = '', tone = '') => `<button type="button" class="btn icon-btn ${tone}" data-action="${action}" title="${M.tr(key)}" aria-label="${M.tr(key)}" ${attrs}>${M.icon(icon)}</button>`;
|
||
M.badge = (value, type = '') => `<span class="badge ${type}"><span class="dot"></span>${M.escape(value)}</span>`;
|
||
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'
|
||
? `<textarea id="f-${name}" name="${name}" rows="5" ${attrs} placeholder="${M.escape(placeholder)}">${M.escape(value)}</textarea>`
|
||
: `<input id="f-${name}" name="${name}" type="${type}" value="${M.escape(value)}" ${required ? 'required' : ''} ${attrs} placeholder="${M.escape(placeholder)}">`;
|
||
return `<label class="field ${wide ? 'wide' : ''}" for="f-${name}"><span>${M.tr(key)}${required ? '<span class="required"> *</span>' : ''}</span>${control}${hint ? `<small>${M.escape(hint)}</small>` : ''}</label>`;
|
||
};
|
||
M.select = (name, key, values, selected = '', attrs = '') => `<label class="field"><span>${M.tr(key)}</span><select name="${name}" id="f-${name}" ${attrs}>${values.map(([value, label]) => `<option value="${M.escape(value)}"${String(selected) === String(value) ? ' selected' : ''}>${M.escape(label)}</option>`).join('')}</select></label>`;
|
||
M.toggle = (name, key, value, hint = '') => `<label class="toggle-row"><span><strong>${M.tr(key)}</strong>${hint ? `<small>${M.tr(hint)}</small>` : ''}</span><input type="checkbox" role="switch" name="${name}"${value ? ' checked' : ''}><span class="toggle-track" aria-hidden="true"></span></label>`;
|
||
M.empty = (key = 'ui.empty', hint = '', icon = 'files') => `<div class="empty">${M.icon(icon)}<strong>${M.tr(key)}</strong>${hint ? `<p>${M.tr(hint)}</p>` : ''}</div>`;
|
||
M.heading = (key, subtitle, actions = '', eyebrow = '') => `<header class="page-heading"><div>${eyebrow ? `<p class="eyebrow">${M.tr(eyebrow)}</p>` : ''}<h1>${M.tr(key)}</h1>${subtitle ? `<p>${M.tr(subtitle)}</p>` : ''}</div><div class="actions">${actions}</div></header>`;
|
||
M.card = (title, body, extras = '', classes = '') => `<section class="card ${classes}"><header class="card-header"><h2>${M.tr(title)}</h2>${extras}</header>${body}</section>`;
|
||
M.errorMessage = error => error && error.message ? error.message : M.t('ui.operationError');
|
||
M.api = async (path, options = {}) => {
|
||
const { method = 'GET', body, blob = false, text = false, timeout = 180000, quietAuth = false, signal } = options;
|
||
const 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')}<span>${M.escape(message)}</span><button class="btn icon-btn" aria-label="${M.tr('ui.close')}">${M.icon('close')}</button>`;
|
||
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 = `<header class="dialog-header"><h2 id="dialog-title">${M.escape(title)}</h2>${M.iconButton('close-dialog', 'ui.close', 'close')}</header><div class="dialog-body">${html}</div>`;
|
||
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'), `<p class="confirm-copy">${M.escape(message)}</p><div class="dialog-actions">${M.button('close-dialog', M.t('common.cancel'))}<button type="button" class="btn ${danger ? 'danger' : 'primary'}" id="accept-confirm">${M.escape(label)}</button></div>`, { 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 `<footer class="table-footer"><span>${count ? (safePage - 1) * M.state.pageSize + 1 : 0}–${Math.min(count, safePage * M.state.pageSize)} / ${count}</span><div class="actions">${M.iconButton('page-prev', 'ui.previous', 'chevron', safePage <= 1 ? 'disabled' : '', 'reverse')}<span>${M.tr('ui.page')} ${safePage} / ${pages}</span>${M.iconButton('page-next', 'ui.next', 'chevron', safePage >= pages ? 'disabled' : '')}</div></footer>`;
|
||
};
|
||
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);
|