Files
mikrotik-backup/web/js/core.js
T
2026-10-28 09:16:54 +01:00

239 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);