first commit
This commit is contained in:
commit
32a7ba8e8f
168 files changed
+123856
No files matched your search
@@ -0,0 +1,5 @@
|
||||
js/i18n.js
|
||||
js/core.js
|
||||
js/views.js
|
||||
js/app.js
|
||||
css/app.css
|
||||
+3218
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="dark">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="api-prefix" content="/api">
|
||||
<meta name="allow-registration" content="false">
|
||||
<meta name="theme-color" content="#0b0d10">
|
||||
<meta name="description" content="MikroTik Backup System — devices, backups, and configuration history.">
|
||||
<title>MikroTik Backup System</title>
|
||||
<link rel="stylesheet" href="/assets/app.css">
|
||||
<script src="/assets/app.js" defer></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<a class="skip-link" href="#main">Skip to content</a>
|
||||
<div id="app">
|
||||
<main class="boot" id="main" aria-live="polite"><span class="spinner"></span> MikroTik Backup System</main>
|
||||
</div>
|
||||
<div id="toast-region" class="toasts" aria-live="polite" aria-atomic="false"></div>
|
||||
<div id="dialog-root"></div>
|
||||
<noscript>Enable JavaScript to use the dashboard.</noscript>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+426
@@ -0,0 +1,426 @@
|
||||
(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 `<div class="layout ${M.state.collapsed ? 'collapsed' : ''} ${M.state.mobile ? 'mobile-open' : ''}"><header class="topbar">${IB('sidebar', 'ui.menu', 'menu')}<a class="brand" href="/" data-nav><span>MikroTik Backup System</span></a><span class="topbar-spacer"></span><div class="user-chip">${I('user')}<span>${E(M.state.user.username)}</span></div><span class="topbar-divider"></span><div class="actions">${M.languageSelect()}${IB('theme', 'ui.changeTheme', M.state.theme === 'dark' ? 'moon' : 'sun')}${IB('logout', 'nav.logout', 'logout')}</div></header><aside class="sidebar" aria-label="${T('app.menu')}"><p class="nav-section">MikroTik / control</p><nav class="nav">${navItems.map(([url, key, icon]) => `<a href="${url}" data-nav class="${path === url || url === '/devices' && path.startsWith('/devices/') ? 'active' : ''}" ${path === url ? 'aria-current="page"' : ''} title="${T(key)}">${I(icon)}<span class="nav-label">${T(key)}</span></a>`).join('')}</nav><div class="sidebar-footer"><button type="button" class="api-status pending" data-action="health" id="health-status" title="${T('footer.apiLatencyLabel')}"><span class="dot"></span><span>${T('footer.apiChecking')}</span></button><div class="sidebar-meta"><a class="api-docs-link" href="/docs">${T('ui.apiDocumentation')}</a><span class="sidebar-meta-separator" aria-hidden="true">|</span><span class="version">v0.0.1</span></div></div></aside><button type="button" class="mobile-overlay" data-action="sidebar-close" aria-label="${T('ui.close')}"></button><main class="main" id="main" tabindex="-1"><div class="loading" aria-live="polite"><span class="spinner"></span>${T('ui.loading')}</div></main></div>`;
|
||||
};
|
||||
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 = `<div class="error-panel" role="alert">${I('alert')}<p>${E(M.errorMessage(error))}</p>${B('refresh', t('ui.retry'), 'refresh')}</div>`;
|
||||
}
|
||||
};
|
||||
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 ? `<span class="muted">${T('routers.pingDisabled')}</span>` : 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 = `<span class="dot"></span><span>${T(h.online === null ? 'footer.apiChecking' : h.online ? 'ui.systemOnline' : 'ui.systemOffline')}${h.latency == null ? '' : ` · ${h.latency} ms`}</span>`;
|
||||
};
|
||||
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') + `<div class="dialog-actions">${B('new-device', t('ui.newDevice'), 'plus', '', 'primary')}</div>`); return; }
|
||||
M.openDialog(t('ui.newBackup'), `<form id="backup-form"><div class="form-grid">${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')}</div><div class="form-actions">${B('close-dialog', t('common.cancel'))}<button type="submit" class="btn primary">${I('play')}${T('ui.newBackup')}</button></div></form>`);
|
||||
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]) => `<option value="${type}"${router?.disable_all_backups || (type === 'export' ? router?.disable_export_backups : router?.disable_binary_backups) ? ' disabled' : ''}>${E(label)}</option>`).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 ? `<div class="notice warning">${I('alert')}${T('ui.partial')}</div>` : ''}<div class="table-wrap"><table><thead><tr><th>${T('files.routerLabel')}</th><th>${T('ui.result')}</th><th>${T('ui.description')}</th></tr></thead><tbody>${rows.map(r => `<tr><td>${E(r.router_name || r.name || r.router || '#' + r.router_id)}</td><td>${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')}</td><td class="break">${E(r.error || r.reason || r.message || r.file_name || '')}</td></tr>`).join('')}</tbody></table></div><div class="dialog-actions">${B('close-dialog', t('ui.close'))}</div>`, { 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'), `<div class="preview-meta"><span>${E(M.date(file.created_at))}</span><span>${E(M.bytes(file.file_size))}</span>${file.checksum ? `<code>SHA-256 ${E(file.checksum)}</code>` : ''}</div><pre class="mono-pre">${E(data.content)}</pre><div class="dialog-actions">${B('download', t('common.download'), 'download', `data-id="${Number(id)}"`)}${B('close-dialog', t('ui.close'))}</div>`, { 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'), `<div class="notice warning">${I('alert')}<span class="break">${E(result.error)}</span></div><div class="dialog-actions">${B('close-dialog', t('ui.close'))}</div>`);
|
||||
};
|
||||
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'), `<form id="reveal-form">${F('password', 'settings.revealSshPassword', '', { type: 'password', required: true, attrs: 'autocomplete="current-password"' })}<div class="form-actions">${B('close-dialog', t('common.cancel'))}<button type="submit" class="btn primary">${I('eye')}${T('settings.revealSshKey')}</button></div></form>`),
|
||||
'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'), `<pre class="mono-pre secret-preview">${E(result.global_ssh_key || t('ui.notSet'))}</pre><div class="dialog-actions">${B('close-dialog', t('settings.hideSshKey'))}</div>`); }
|
||||
};
|
||||
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);
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
(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);
|
||||
@@ -0,0 +1,3 @@
|
||||
/* Source language dictionaries preserved from the original application. */
|
||||
window.MBS = window.MBS || {};
|
||||
window.MBS.translations = {"pl":{"app":{"menu":"Menu"},"sidebar":{"title":"Mikrotik Backup System","subtitle":"RouterOS/SwitchOS"},"topbar":{"caption":"mikrotik / control center","role":"administrator","languageSelector":"Wybór języka"},"common":{"apply":"Zastosuj","reset":"Resetuj","delete":"Usuń","confirm":"Potwierdź","cancel":"Anuluj","download":"Pobierz","email":"Wyślij e-mail","preview":"Podgląd","restore":"Przywróć","actions":"Akcje","open":"Otwórz","edit":"Edytuj","diff":"Diff","ok":"OK","idle":"Brak","asc":"Rosnąco","desc":"Malejąco","enabled":"Włączone","disabled":"Wyłączone","failed":"Błąd","save":"Zapisz"},"nav":{"dashboard":"Dashboard","routers":"Urządzenia","files":"Repozytorium","settings":"Ustawienia","logs":"Logi","logout":"Wyloguj","theme":"Motyw","changePassword":"Zmień hasło","diffConfigs":"Diff konfiguracji","switchosBeta":"SwitchOS beta"},"auth":{"username":"Użytkownik","password":"Hasło","login":"Zaloguj","register":"Rejestracja","confirmPassword":"Potwierdź hasło","changePassword":"Zmień hasło","currentPassword":"Obecne hasło","newPassword":"Nowe hasło","backToLogin":"Powrót do logowania","loginSubtitle":"Zaloguj się, aby kontynuować.","loginFailed":"Logowanie nie powiodło się","accountCreated":"Konto zostało utworzone","registrationFailed":"Rejestracja nie powiodła się","passwordsMismatch":"Hasła nie są takie same","changePasswordFailed":"Zmiana hasła nie powiodła się","securityEyebrow":"konto / bezpieczeństwo","changePasswordSubtitle":"Zaktualizuj hasło administratora bez zbędnych ustawień dodatkowych.","changePasswordCardSubtitle":"Podaj obecne hasło i ustaw nowe dane logowania.","passwordPanelSubtitle":"Szybki podgląd siły hasła i zgodności pól przed zapisem.","passwordStrength":"Siła hasła","passwordWeak":"Słabe","passwordMedium":"Średnie","passwordStrong":"Mocne","ruleLength":"Minimum 8 znaków","ruleDigit":"Przynajmniej jedna cyfra","ruleMatch":"Oba pola są zgodne","passwordsMatchHint":"Nowe hasło i potwierdzenie są zgodne."},"dashboard":{"title":"Dashboard","eyebrow":"strona główna / dashboard","subtitle":"Przegląd backupów, eksportów i aktywności systemu.","exportAll":"Exportuj wszystko","binaryAll":"Backup binarny","managedRouters":"Urządzenia","managedRoutersHint":"Wszystkie zarządzane urządzenia","inventoryTag":"Flota","exportsCard":"Exporty","exportsHint":"Czytelne snapshoty konfiguracji","textTag":"Tekst","binaryCard":"Backupy binarne","binaryHint":"Punkty odtworzenia","binaryTag":"Binary","allFilesCard":"Wszystkie pliki","allFilesHint":"Artefakty w repozytorium","archiveTag":"Archiwum","storageTitle":"Wykorzystanie przestrzeni","storageSubtitle":"Bieżący podgląd zajętości repozytorium i wolnego miejsca.","folderUsage":"Zajętość katalogu","diskUsage":"Użycie dysku","totalDisk":"Cały dysk","freeSpace":"Wolne miejsce","activityTitle":"Ostatnia aktywność","activitySubtitle":"Najnowsze zdarzenia operacyjne z backendu.","noActivity":"Brak ostatnich zdarzeń do wyświetlenia.","avgBackupsPerRouter":"Śr. backupów / urządzenie","activitySuccess":"Zadanie zakończone","activityFailure":"Wymaga uwagi","activityMaintenance":"Utrzymanie","activityDelivery":"Dystrybucja","operationsTitle":"Centrum operacji","operationsSubtitle":"Główne akcje i szybkie wskaźniki pracy repozytorium.","latestSnapshot":"Najnowszy snapshot","coverageLabel":"Pokrycie floty","coverageHint":"Urządzenia z co najmniej jednym backupem","weeklyActivityLabel":"Aktywność 7 dni","weeklyActivityHint":"Nowe backupy z ostatniego tygodnia","busiestRouterLabel":"Najaktywniejsze urządzenie","routerSnapshotsHint":"{{count}} snapshotów w repozytorium","exportShareLabel":"Udział exportów","activityTodayLabel":"Zdarzenia dzisiaj","noneLabel":"Brak","activityTodayHint":"Wpisy z bieżącego dnia","usedSpace":"Zajęte miejsce","storageViewCapacity":"Pojemność","storageViewCapacityHint":"Widok dysku, zajętości repozytorium i wolnego miejsca w jednej skali.","storageViewMix":"Typy backupów","storageViewMixHint":"Podział wszystkich kopii na exporty tekstowe i backupy binarne.","storageViewActivity":"Aktywność 7 dni","storageViewActivityHint":"Liczba nowych backupów z ostatnich siedmiu dni.","storageViewRouters":"Top urządzenia","storageViewRoutersHint":"Urządzenia z największą liczbą snapshotów w repozytorium.","storageChartEmpty":"Brak danych do narysowania wykresu.","storageSnapshotTitle":"Metryki repozytorium","storageSnapshotHint":"Szybki podgląd najważniejszych wskaźników przestrzeni i backupów."},"routers":{"title":"Urządzenia","detailTitle":"Szczegóły urządzenia","add":"Dodaj urządzenie","eyebrow":"inwentaryzacja urządzeń","subtitle":"Zarządzaj urządzeniami RouterOS i SwitchOS oraz ich kopiami.","registeredDevices":"Zarejestrowane urządzenia","fleetTag":"Flota","sshPassword":"Hasło","passwordHint":"Dostęp hasłem","credsTag":"Dostęp","sshKey":"Klucz SSH","keyHint":"Dostęp kluczem","securityTag":"BezLine truncated
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/* Views contain presentation only; mutations live in app.js. */
|
||||
(function (M) {
|
||||
'use strict';
|
||||
const { escape: E, tr: T, t, icon: I, button: B, iconButton: IB, card: C, field: F, select: S } = M;
|
||||
M.views = {};
|
||||
M.supportsExport = router => router?.device_type !== 'switchos' && router?.supports_export !== false;
|
||||
M.isTextExport = file => !!file && file.backup_type === 'export' && M.supportsExport(file);
|
||||
M.deviceUsername = router => Object.hasOwn(router, 'effective_username') ? router.effective_username || '—' : router.ssh_user || '—';
|
||||
const typeTag = type => `<span class="tag ${type === 'export' ? 'export' : 'binary'}">${T(type === 'export' ? 'files.exportType' : 'files.binaryType')}</span>`;
|
||||
const metric = (key, value, note, small = false) => `<article class="metric"><p class="metric-label">${T(key)}</p><div class="metric-main"><div class="metric-value ${small ? 'small-value' : ''}">${E(value)}</div><div class="metric-note">${note}</div></div></article>`;
|
||||
const dl = rows => `<dl class="data-list">${rows.map(([label, value, html]) => `<dt>${T(label)}</dt><dd>${html ? value : E(value == null || value === '' ? '—' : value)}</dd>`).join('')}</dl>`;
|
||||
const backupActions = file => `${M.isTextExport(file) ? IB('preview', 'common.preview', 'eye', `data-id="${file.id}"`) + IB('diff-latest', 'files.latestForRouter', 'diff', `data-id="${file.id}"`) : ''}${IB('download', 'common.download', 'download', `data-id="${file.id}"`)}${IB('email', 'common.email', 'mail', `data-id="${file.id}"`)}${file.device_type !== 'switchos' && file.backup_type === 'binary' ? IB('upload', 'ui.upload', 'upload', `data-id="${file.id}" data-router="${file.router_id}"`) : ''}${IB('delete-backup', 'common.delete', 'trash', `data-id="${file.id}"`, 'danger')}`;
|
||||
M.backupTable = (files, options = {}) => {
|
||||
const { compact = false, select = false, paginate = true } = options;
|
||||
if (!files.length) return M.empty('ui.noBackups', '', 'files');
|
||||
const visible = paginate ? M.paged(files) : files;
|
||||
return `<div class="table-wrap"><table><thead><tr>${select ? `<th class="checkbox-cell"><input type="checkbox" id="select-page" aria-label="${T('ui.selectAll')}"${visible.length && visible.every(f => M.state.selected.has(f.id)) ? ' checked' : ''}></th>` : ''}<th>${T(compact ? 'files.routerColumn' : 'files.fileColumn')}</th><th>${T('files.typeColumn')}</th>${compact ? '' : `<th>${T('files.routerColumn')}</th>`}<th>${T('files.sizeColumn')}</th><th>${T('files.createdColumn')}</th><th><span class="sr-only">${T('common.actions')}</span></th></tr></thead><tbody>${visible.map(f => `<tr class="${M.state.selected.has(f.id) ? 'selected-row' : ''}">${select ? `<td class="checkbox-cell"><input type="checkbox" data-select-backup="${f.id}" aria-label="${E(f.file_name)}"${M.state.selected.has(f.id) ? ' checked' : ''}></td>` : ''}<td>${compact ? `<a data-nav href="/devices/${f.router_id}">${E(f.router_name || `#${f.router_id}`)}</a>` : `<strong class="filename" title="${E(f.file_name)}">${E(f.file_name)}</strong><span class="subline mono" title="SHA-256: ${E(f.checksum || '')}">${E(f.checksum ? f.checksum.slice(0, 12) + '…' : '—')}</span>`}</td><td>${typeTag(f.backup_type)}</td>${compact ? '' : `<td><a data-nav href="/devices/${f.router_id}">${E(f.router_name || `#${f.router_id}`)}</a><span class="subline">${E(f.device_type === 'switchos' ? 'SwitchOS' : 'RouterOS')}</span></td>`}<td class="nowrap mono">${E(M.bytes(f.file_size))}</td><td class="nowrap" title="${E(M.date(f.created_at))}">${E(M.age(f.created_at))}${compact ? '' : `<span class="subline">${E(M.date(f.created_at))}</span>`}</td><td><div class="row-actions">${compact ? IB('download', 'common.download', 'download', `data-id="${f.id}"`) : backupActions(f)}</div></td></tr>`).join('')}</tbody></table></div>${paginate ? M.pagination(files.length) : ''}`;
|
||||
};
|
||||
M.views.dashboard = async () => {
|
||||
const revision = M.state.routeId;
|
||||
const [d, backups, routers] = await Promise.all([M.api('/dashboard'), M.api('/backups'), M.api('/routers')]);
|
||||
if (revision !== M.state.routeId) return "";
|
||||
M.state.cache.dashboard = d; M.state.cache.backups = backups; M.state.cache.routers = routers;
|
||||
const sorted = backups.slice().sort((a, b) => (M.parseDate(b.created_at)?.getTime() || 0) - (M.parseDate(a.created_at)?.getTime() || 0));
|
||||
const last = sorted[0], online = routers.filter(r => r.last_connection_status === true || r.last_connection_status === 1).length;
|
||||
const offline = routers.filter(r => r.last_connection_status === false || r.last_connection_status === 0).length;
|
||||
const unknown = routers.length - online - offline, total = routers.length || 1, circle = 326.73;
|
||||
const storage = d.storage || {}, totalBytes = Number(storage.total) || 0;
|
||||
const capacity = (key, value, tone = '') => `<div class="capacity-row"><div class="capacity-label"><span>${T(key)}</span><strong>${E(M.bytes(value))}</strong></div><progress max="100" value="${totalBytes ? Math.min(100, Math.max(0, Number(value) / totalBytes * 100)) : 0}" class="${tone}" aria-label="${T(key)}"></progress></div>`;
|
||||
const days = Array.from({ length: 7 }, (_, i) => {
|
||||
const date = new Date(); date.setHours(0, 0, 0, 0); date.setDate(date.getDate() - 6 + i);
|
||||
const next = new Date(date); next.setDate(next.getDate() + 1);
|
||||
return { label: date.toLocaleDateString(M.state.lang, { day: '2-digit', month: '2-digit' }), count: backups.filter(f => { const dt = M.parseDate(f.created_at); return dt && dt >= date && dt < next; }).length };
|
||||
});
|
||||
const maxDay = Math.max(1, ...days.map(day => day.count));
|
||||
const activity = `<div class="card-body"><div class="bar-chart" role="img" aria-label="${T('ui.activity')}">${days.map(day => `<div class="bar-column"><b>${day.count}</b><div class="bar" data-bar-height="${Math.max(2, day.count / maxDay * 93)}" title="${E(day.label)}: ${day.count}"></div><small>${E(day.label)}</small></div>`).join('')}</div></div>`;
|
||||
const status = `<div class="status-layout"><div class="donut"><svg viewBox="0 0 130 130" aria-hidden="true"><circle class="track" cx="65" cy="65" r="52"/><circle class="segment-online" cx="65" cy="65" r="52" stroke-dasharray="${online / total * circle} ${circle}"/><circle class="segment-offline" cx="65" cy="65" r="52" stroke-dasharray="${offline / total * circle} ${circle}" stroke-dashoffset="${-online / total * circle}"/><circle class="segment-unknown" cx="65" cy="65" r="52" stroke-dasharray="${unknown / total * circle} ${circle}" stroke-dashoffset="${-(online + offline) / total * circle}"/></svg><div class="donut-label">${routers.length}<small>${T('nav.routers')}</small></div></div><div class="legend">${[['ui.online', online, 'success'], ['ui.offline', offline, 'danger'], ['ui.unknown', unknown, 'muted']].map(([key, n, tone]) => `<div class="legend-row">${M.badge(t(key), tone)}<strong>${n}</strong></div>`).join('')}</div></div><div class="card-foot"><small>${T('ui.lastKnown')}</small></div>`;
|
||||
const logs = d.recent_logs || [];
|
||||
const logList = logs.length ? `<div class="activity-list">${logs.slice(0, 5).map(log => `<div class="activity-item">${I('clock')}<div><p>${E(log.message)}</p><small>${E(M.date(log.timestamp))}</small></div></div>`).join('')}</div>` : M.empty('ui.noLogs', '', 'clock');
|
||||
const counters = new Map();
|
||||
for (const file of backups) { const row = counters.get(file.router_id) || { name: file.router_name || `#${file.router_id}`, count: 0 }; row.count++; counters.set(file.router_id, row); }
|
||||
const topDevices = [...counters.values()].sort((a, b) => b.count - a.count).slice(0, 5);
|
||||
const maxBackups = Math.max(1, ...topDevices.map(row => row.count));
|
||||
const today = new Date().toDateString();
|
||||
const summary = dl([
|
||||
['dashboard.avgBackupsPerRouter', routers.length ? (backups.length / routers.length).toFixed(1) : '0'],
|
||||
['dashboard.coverageLabel', `${routers.length ? Math.round(counters.size / routers.length * 100) : 0}%`],
|
||||
['dashboard.weeklyActivityLabel', backups.filter(file => (M.parseDate(file.created_at)?.getTime() || 0) >= Date.now() - 7 * 86400000).length],
|
||||
['dashboard.exportShareLabel', `${backups.length ? Math.round(Number(d.export_count) / backups.length * 100) : 0}%`],
|
||||
['dashboard.activityTodayLabel', logs.filter(log => M.parseDate(log.timestamp)?.toDateString() === today).length],
|
||||
['dashboard.busiestRouterLabel', topDevices[0] ? `${topDevices[0].name} (${topDevices[0].count})` : '—']
|
||||
]);
|
||||
const topChart = topDevices.length ? `<div class="card-body">${topDevices.map(row => `<div class="capacity-row"><div class="capacity-label"><span>${E(row.name)}</span><strong>${row.count}</strong></div><progress max="${maxBackups}" value="${row.count}" aria-label="${E(row.name)}"></progress></div>`).join('')}</div>` : M.empty('ui.noBackups');
|
||||
const actions = B('new-backup', t('ui.newBackup'), 'play', '', 'primary') + B('new-device', t('ui.newDevice'), 'plus');
|
||||
return `${M.heading('dashboard.title', 'ui.loginHint', actions, 'dashboard.eyebrow')}<div class="cards-grid">${metric('nav.routers', d.routers_count, `<span class="success-text">${online} ${T('ui.online')}</span><span class="danger-text">${offline} ${T('ui.offline')}</span>`)}${metric('ui.lastBackup', last ? M.age(last.created_at) : '—', E(last?.router_name || '—'), true)}${metric('dashboard.folderUsage', M.bytes(storage.folder_used), `${T('dashboard.freeSpace')}: ${E(M.bytes(storage.free))}`, true)}${metric('ui.backups', d.total_backups, `${Number(d.export_count) || 0} RSC / ${Number(d.binary_count) || 0} binary`)}</div><div class="grid-2"><div class="stack">${C('ui.recentBackups', M.backupTable(sorted.slice(0, 6), { compact: true, paginate: false }), `<a href="/files" data-nav class="link-btn">${T('ui.viewAll')} →</a>`)}${C('ui.activity', activity)}${C('dashboard.activityTitle', logList, `<a href="/logs" data-nav class="link-btn">${T('ui.viewAll')} →</a>`)}</div><div class="stack">${C('ui.devicesStatus', status)}${C('ui.quickActions', `<div class="quick-actions">${routers.some(M.supportsExport) ? B('batch-export', t('ui.exportAll'), 'files') : ''}${B('batch-binary', t('ui.binaryAll'), 'database')}</div>`)}${C('dashboard.storageTitle', `<div class="card-body">${capacity('dashboard.totalDisk', storage.total)}${capacity('dashboard.usedSpace', storage.used, 'warning')}${capacity('dashboard.freeSpace', storage.free, 'success')}${capacity('dashboard.folderUsage', storage.folder_used)}</div>`)}${C('dashboard.storageViewMix', `<div class="card-body"><div class="capacity-row"><div class="capacity-label"><span>${T('files.exportType')}</span><strong>${d.export_count}</strong></div><progress max="${Math.max(1, d.total_backups)}" value="${d.export_count}" aria-label="${T('files.exportType')}"></progress></div><div class="capacity-row"><div class="capacity-label"><span>${T('files.binaryType')}</span><strong>${d.binary_count}</strong></div><progress max="${Math.max(1, d.total_backups)}" value="${d.binary_count}" class="warning" aria-label="${T('files.binaryType')}"></progress></div></div>`)}</div></div><div class="grid-equal dashboard-extra">${C('dashboard.storageSnapshotTitle', `<div class="card-body">${summary}</div>`)}${C('dashboard.storageViewRouters', topChart)}</div>`;
|
||||
};
|
||||
M.filteredDevices = () => {
|
||||
const f = M.state.deviceFilters || {};
|
||||
return (M.state.cache.routers || []).filter(r => {
|
||||
const match = `${r.name} ${r.host} ${r.last_connection_model || ''}`.toLocaleLowerCase().includes((f.search || '').toLocaleLowerCase());
|
||||
const status = r.last_connection_status == null ? 'unknown' : r.last_connection_status ? 'online' : 'offline';
|
||||
return match && (!f.device_type || f.device_type === r.device_type) && (!f.status || f.status === status);
|
||||
});
|
||||
};
|
||||
M.deviceTable = () => {
|
||||
const rows = M.filteredDevices();
|
||||
if (!rows.length) return M.empty((M.state.cache.routers || []).length ? 'ui.noMatches' : 'ui.empty', (M.state.cache.routers || []).length ? '' : 'ui.emptyHint', 'device');
|
||||
return `<div class="table-wrap"><table><thead><tr><th>${T('routers.name')}</th><th>${T('ui.address')}</th><th>${T('routers.model')}</th><th>${T('ui.status')}</th><th>${T('routers.ping')}</th><th>${T('routers.lastTestAt')}</th><th>${T('routers.backupPolicy')}</th><th></th></tr></thead><tbody>${M.paged(rows).map(r => `<tr><td><a data-nav href="/devices/${r.id}"><strong>${E(r.name)}</strong></a><span class="subline">${E(r.device_type === 'switchos' ? 'SwitchOS' : 'RouterOS')}</span></td><td class="mono nowrap">${E(r.host)}<span class="subline">${r.port} · ${E(M.deviceUsername(r))}</span></td><td>${E(r.last_connection_model || '—')}<span class="subline">${E(r.last_connection_version || '')}</span></td><td>${M.connectionBadge(r)}</td><td data-ping="${r.id}">${r.disable_ping ? `<span class="muted">${T('routers.pingDisabled')}</span>` : `<span class="muted">—</span>`}</td><td class="nowrap" title="${E(M.date(r.last_connection_tested_at))}">${E(r.last_connection_tested_at ? M.age(r.last_connection_tested_at) : '—')}</td><td>${r.disable_all_backups ? M.badge(t('common.disabled'), 'warning') : `<span class="tag">${!r.disable_export_backups && r.device_type !== 'switchos' ? 'RSC' : '—'} / ${!r.disable_binary_backups ? 'BIN' : '—'}</span>`}</td><td><div class="row-actions">${IB('test-device', 'routers.testConnection', 'refresh', `data-id="${r.id}"`)}${IB('edit-device', 'common.edit', 'edit', `data-id="${r.id}"`)}<a data-nav href="/devices/${r.id}" class="btn icon-btn" aria-label="${T('common.open')}">${I('chevron')}</a></div></td></tr>`).join('')}</tbody></table></div>${M.pagination(rows.length)}`;
|
||||
};
|
||||
M.views.devices = async () => {
|
||||
const revision = M.state.routeId;
|
||||
const routers = await M.api('/routers');
|
||||
if (revision !== M.state.routeId) return '';
|
||||
M.state.cache.routers = routers;
|
||||
const f = M.state.deviceFilters || {};
|
||||
return `${M.heading('routers.title', 'routers.subtitle', B('new-device', t('ui.newDevice'), 'plus', '', 'primary') + B('refresh', t('ui.refresh'), 'refresh'), 'routers.eyebrow')}<form id="device-filter" class="filter-bar"><div class="field search-field"><span>${T('files.searchLabel')}</span><input name="search" type="search" value="${E(f.search || '')}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"></div>${S('status', 'ui.status', [['', t('ui.allStatuses')], ['online', t('ui.online')], ['offline', t('ui.offline')], ['unknown', t('ui.unknown')]], f.status || '')}${S('device_type', 'routers.deviceType', [['', t('files.allTypes')], ['routeros', 'RouterOS'], ['switchos', 'SwitchOS']], f.device_type || '')}${B('refresh-ping', t('ui.refreshPing'), 'refresh')}</form><section class="card"><div id="device-table">${M.deviceTable()}</div></section>`;
|
||||
};
|
||||
M.deviceForm = router => {
|
||||
const editing = !!router, r = router || { name: '', host: '', port: 22, device_type: 'routeros', ssh_user: 'admin' };
|
||||
const switchos = r.device_type === 'switchos';
|
||||
const legacy = editing && switchos && r.use_global_switchos_credentials == null;
|
||||
const source = legacy ? '' : r.use_global_switchos_credentials === false ? 'false' : 'true';
|
||||
const global = switchos && source === 'true';
|
||||
const passwordSet = r.has_own_password ?? (!r.uses_global_switchos_credentials && r.has_effective_password);
|
||||
const sources = [...(legacy ? [['', t('ui.legacyCredentialSource')]] : []), ['true', t('ui.globalCredentials')], ['false', t('ui.deviceCredentials')]];
|
||||
return `<form id="device-form" data-id="${editing ? r.id : ''}"><div class="form-grid">
|
||||
${F('name', 'routers.name', r.name, { required: true, attrs: 'pattern="[A-Za-z0-9_-]+" maxlength="120" autocomplete="off"', hint: t('routers.nameValidationHint') })}
|
||||
${S('device_type', 'routers.deviceType', [['routeros', 'RouterOS'], ['switchos', 'SwitchOS']], r.device_type)}
|
||||
${F('host', 'routers.host', r.host, { required: true, attrs: 'maxlength="255" autocomplete="off"', placeholder: '192.168.88.1' })}
|
||||
${F('port', 'routers.port', r.port, { type: 'number', required: true, attrs: 'min="1" max="65535"' })}
|
||||
<div class="wide" id="switchos-credentials-mode"${switchos ? '' : ' hidden'}>${S('use_global_switchos_credentials', 'ui.credentialSource', sources, source, switchos ? '' : 'disabled')}<p class="credential-hint" data-switchos-mode-hint${source === 'false' ? ' hidden' : ''}>${T(legacy ? 'ui.legacyCredentialsHint' : 'ui.globalCredentialsHint')}</p></div>
|
||||
<div class="form-grid wide" id="device-own-credentials"${global ? ' hidden' : ''}>
|
||||
${F('ssh_user', 'routers.sshUser', r.ssh_user || '', { attrs: 'maxlength="120" autocomplete="off"' + (global ? ' disabled' : '') })}
|
||||
<div class="secret-field">${F('ssh_password', 'routers.sshPassword', '', { type: 'password', attrs: 'maxlength="255" autocomplete="new-password"' + (global ? ' disabled' : ''), placeholder: editing ? t(passwordSet ? 'ui.kept' : 'ui.notSet') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_password"${global ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div>
|
||||
</div>
|
||||
<div class="wide secret-field" id="device-key-field"${switchos ? ' hidden' : ''}>${F('ssh_key', 'routers.sshPrivateKey', '', { type: 'textarea', attrs: 'spellcheck="false" autocomplete="off"' + (switchos ? ' disabled' : ''), placeholder: editing && r.has_effective_ssh_key ? t('ui.kept') : t('routers.optionalPrivateKey'), hint: r.uses_global_ssh_key ? t('routers.globalKeyMode') : '' })}${editing ? `<label class="checkbox-label"><input type="checkbox" name="clear_ssh_key"${switchos ? ' disabled' : ''}>${T('ui.clearSecret')}</label>` : ''}</div>
|
||||
</div><div class="form-actions">${B('close-dialog', t('common.cancel'))}<button class="btn primary" type="submit">${I('check')}${T('routers.saveRouter')}</button></div></form>`;
|
||||
};
|
||||
M.detailTabs = () => `<div class="tabs" role="tablist">${[['overview', 'ui.overview'], ['export', 'routers.exportsLabel'], ['binary', 'routers.binaryLabel'], ['policy', 'routers.backupSettingsTitle']].filter(([key]) => key !== 'export' || M.supportsExport(M.state.cache.device)).map(([key, label]) => `<button type="button" class="tab ${M.state.detailTab === key ? 'active' : ''}" data-detail-tab="${key}" role="tab" aria-selected="${M.state.detailTab === key}">${T(label)}</button>`).join('')}</div>`;
|
||||
M.detailBody = () => {
|
||||
const r = M.state.cache.device, backups = M.state.cache.deviceBackups || [];
|
||||
if (!r) return '';
|
||||
if (M.state.detailTab === 'export' && !M.supportsExport(r)) M.state.detailTab = 'overview';
|
||||
if (M.state.detailTab === 'export' || M.state.detailTab === 'binary') return C(M.state.detailTab === 'export' ? 'routers.exportsTableTitle' : 'routers.binaryTableTitle', M.backupTable(backups.filter(f => f.backup_type === M.state.detailTab), { select: true }), `<div class="actions">${B('bulk-download', t('files.downloadZip'), 'download')}${M.state.detailTab === 'export' ? B('compare-selected', t('ui.compare'), 'diff', 'hidden') : ''}</div>`);
|
||||
if (M.state.detailTab === 'policy') return C('routers.backupSettingsTitle', `<form id="device-policy-form" data-id="${r.id}" class="card-body"><p class="section-hint">${T('routers.backupSettingsHint')}</p>${M.toggle('disable_all_backups', 'routers.disableAllBackups', r.disable_all_backups, 'routers.disableAllBackupsHint')}${M.supportsExport(r) ? M.toggle('disable_export_backups', 'routers.disableExports', r.disable_export_backups, 'routers.disableExportsHint') : ''}${M.toggle('disable_binary_backups', 'routers.disableBinaryBackups', r.disable_binary_backups, 'routers.disableBinaryBackupsHint')}${M.toggle('disable_ping', 'routers.disablePing', r.disable_ping, 'routers.disablePingHint')}<div class="form-actions"><button type="submit" class="btn primary">${I('check')}${T('common.save')}</button></div></form>`, '', 'policy-card');
|
||||
const summary = dl([['routers.name', r.name], ['routers.host', `${r.host}:${r.port}`], ['routers.deviceType', r.device_type === 'switchos' ? 'SwitchOS' : 'RouterOS'], ['routers.sshUser', M.deviceUsername(r)], ['routers.hostname', r.last_connection_hostname], ['routers.model', r.last_connection_model], ['routers.version', r.last_connection_version], ['ui.created', M.date(r.created_at)]]);
|
||||
const status = dl([['ui.status', M.connectionBadge(r), true], ['routers.lastTestAt', M.date(r.last_connection_tested_at)], ['routers.uptime', r.last_connection_uptime], ['routers.connectionLabel', r.last_connection_transport], ['routers.serverHeader', r.last_connection_server], ['routers.authMode', r.last_connection_auth_mode], ['routers.httpStatus', r.last_connection_http_status], ['routers.backupEndpoint', r.last_connection_backup_available == null ? '—' : t(r.last_connection_backup_available ? 'routers.backupAvailable' : 'routers.backupUnavailable')]]);
|
||||
const last = backups[0];
|
||||
const backupSummary = dl([['ui.lastBackup', last ? M.date(last.created_at) : '—'], ['files.sizeColumn', last ? M.bytes(last.file_size) : '—'], ...(M.supportsExport(r) ? [['routers.exportsLabel', backups.filter(M.isTextExport).length]] : []), ['routers.binaryLabel', backups.filter(f => f.backup_type === 'binary').length]]);
|
||||
return `<div class="grid-equal detail-grid"><div class="stack">${C('routers.connectionSectionTitle', `<div class="card-body">${summary}</div>`)}${C('ui.backups', `<div class="card-body">${backupSummary}</div><div class="card-foot actions">${M.supportsExport(r) ? B('device-export', t('files.exportType') + ' RSC', 'files', `data-id="${r.id}" ${r.disable_all_backups || r.disable_export_backups ? 'disabled' : ''}`) : ''}${B('device-binary', t('files.binaryType'), 'database', `data-id="${r.id}" ${r.disable_all_backups || r.disable_binary_backups ? 'disabled' : ''}`, 'primary')}</div>`)}</div><div class="stack">${C('routers.deviceStatusTitle', `<div class="card-body">${status}${r.last_connection_error ? `<div class="notice warning mt-18">${I('alert')}<span class="break">${E(r.last_connection_error)}</span></div>` : ''}${!r.last_connection_tested_at ? `<p class="section-hint mt-18">${T('routers.noConnection')}</p>` : ''}</div>`)}${C('routers.credentialsSectionTitle', `<div class="card-body">${dl([['routers.sshKey', r.uses_global_ssh_key ? t('routers.globalKeyMode') : t(r.has_effective_ssh_key ? 'routers.keyMode' : 'routers.noKey')], ['routers.sshPassword', t(r.has_effective_password ? 'routers.passwordMode' : 'routers.noPassword')], ['routers.defaultCredentials', t(r.uses_global_switchos_credentials ? 'common.enabled' : 'common.disabled')]])}</div>`)}</div></div>`;
|
||||
};
|
||||
M.views.device = async id => {
|
||||
const revision = M.state.routeId;
|
||||
const [r, backups] = await Promise.all([M.api(`/routers/${id}`), M.api(`/backups/router/${id}`)]);
|
||||
if (revision !== M.state.routeId) return "";
|
||||
if (M.state.cache.device?.id !== r.id) M.state.detailTab = 'overview';
|
||||
M.state.detailTab ||= 'overview'; M.state.cache.device = r; M.state.cache.deviceBackups = backups;
|
||||
if (M.state.detailTab === 'export' && !M.supportsExport(r)) M.state.detailTab = 'overview';
|
||||
const actions = B('test-device', t('routers.testConnection'), 'refresh', `data-id="${r.id}"`) + B('edit-device', t('common.edit'), 'edit', `data-id="${r.id}"`) + IB('delete-device', 'routers.deleteRouter', 'trash', `data-id="${r.id}"`, 'danger');
|
||||
return `<div class="page-heading"><div class="device-heading"><div><p class="eyebrow"><a href="/devices" data-nav>${T('nav.routers')}</a> / ${T('routers.detailTitle')}</p><h1>${E(r.name)}</h1><p class="mono">${E(r.host)} · ${E(r.last_connection_model || (r.device_type === 'switchos' ? 'SwitchOS' : 'RouterOS'))}</p>${M.connectionBadge(r)}</div></div><div class="actions">${actions}</div></div><div id="device-tabs">${M.detailTabs(r.id)}</div><div id="device-detail-body">${M.detailBody()}</div>`;
|
||||
};
|
||||
M.backupQuery = filters => {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => { if (value !== '' && value !== null && value !== undefined) params.set(key, String(value)); });
|
||||
return params.toString();
|
||||
};
|
||||
M.fileToolbar = () => `<div class="actions">${B('bulk-download', t('files.downloadZip'), 'download')}${B('compare-selected', t('ui.compare'), 'diff', 'hidden')}${B('bulk-delete', t('common.delete'), 'trash', '', 'danger')}<span class="select-summary">${T('ui.selection')}: <b id="selected-count">${M.state.selected.size}</b></span></div>`;
|
||||
M.views.files = async () => {
|
||||
const revision = M.state.routeId;
|
||||
const f = M.state.filters;
|
||||
const [files, routers] = await Promise.all([M.api(`/backups?${M.backupQuery(f)}`), M.api('/routers')]);
|
||||
if (revision !== M.state.routeId) return "";
|
||||
M.state.cache.files = files; M.state.cache.routers = routers;
|
||||
M.state.selected = new Set([...M.state.selected].filter(id => files.some(file => file.id === id)));
|
||||
return `${M.heading('ui.backups', 'files.subtitle', B('new-backup', t('ui.newBackup'), 'plus', '', 'primary') + B('refresh', t('ui.refresh'), 'refresh'), 'files.eyebrow')}<form id="files-filter" class="filter-bar"><div class="field search-field"><span>${T('files.searchLabel')}</span><input type="search" name="search" value="${E(f.search)}" placeholder="${T('files.searchPlaceholder')}" aria-label="${T('files.searchLabel')}"></div>${S('router_id', 'files.routerLabel', [['', t('files.allRouters')], ...routers.map(r => [r.id, r.name])], f.router_id)}${S('backup_type', 'files.typeLabel', [['', t('files.allTypes')], ['export', t('files.exportType')], ['binary', t('files.binaryType')]], f.backup_type)}${F('created_on', 'files.dateLabel', f.created_on, { type: 'date' })}${S('sort_by', 'files.sortLabel', [['created_at', t('files.sortNewest')], ['file_name', t('files.sortName')], ['router_name', t('files.sortRouter')], ['backup_type', t('files.sortType')]], f.sort_by)}${S('order', 'files.orderLabel', [['desc', t('common.desc')], ['asc', t('common.asc')]], f.order)}<button class="btn" type="submit">${I('filter')}${T('common.apply')}</button>${IB('reset-filters', 'common.reset', 'close')}</form><section class="card"><header class="card-header">${M.fileToolbar()}</header><div id="files-table">${M.backupTable(files, { select: true })}</div></section>`;
|
||||
};
|
||||
M.highlight = (text, term) => {
|
||||
text = String(text || ''); term = String(term || '');
|
||||
if (!term) return E(text);
|
||||
let result = '', start = 0, at;
|
||||
const lower = text.toLocaleLowerCase(), query = term.toLocaleLowerCase();
|
||||
while ((at = lower.indexOf(query, start)) >= 0) {
|
||||
result += E(text.slice(start, at)) + '<mark>' + E(text.slice(at, at + term.length)) + '</mark>';
|
||||
start = at + term.length;
|
||||
}
|
||||
return result + E(text.slice(start));
|
||||
};
|
||||
M.smartDiff = smart => {
|
||||
smart = smart || {};
|
||||
const sections = Array.isArray(smart.sections) ? smart.sections : [];
|
||||
const total = Number(smart.semantic_changes) || 0;
|
||||
const attention = Number(smart.attention_sections) || 0;
|
||||
const detail = change => {
|
||||
const fields = Array.isArray(change.fields) ? change.fields : [];
|
||||
const fieldText = fields.length ? `<div class="smart-fields">${fields.map(field => `<span><b>${E(field.name)}</b>: <code>${E(field.before == null ? '∅' : field.before)}</code> → <code>${E(field.after == null ? '∅' : field.after)}</code></span>`).join('')}</div>` : '';
|
||||
const text = change.kind === 'modified'
|
||||
? `${E(change.identity || change.verb || '')}${fieldText}`
|
||||
: `<code>${E(change.kind === 'added' ? change.after : change.before || '')}</code>`;
|
||||
return `<li class="smart-change ${E(change.kind || '')}"><span class="smart-kind">${T(`diffSmart.${change.kind || 'modified'}`)}</span><div>${text}</div></li>`;
|
||||
};
|
||||
const body = sections.length ? sections.map(section => `<details class="smart-section"${section.attention === 'high' ? ' open' : ''}><summary><code>${E(section.name)}</code><span class="smart-counts"><b class="added">+${Number(section.added) || 0}</b><b class="removed">−${Number(section.removed) || 0}</b><b class="modified">~${Number(section.modified) || 0}</b>${section.attention !== 'low' ? `<span class="tag ${section.attention === 'high' ? 'danger' : 'warning'}">${T(`diffSmart.${section.attention}Attention`)}</span>` : ''}</span></summary><ul>${(section.details || []).map(detail).join('')}${section.details_truncated ? `<li class="muted">${T('diffSmart.moreChanges')}</li>` : ''}</ul></details>`).join('') : `<div class="smart-empty">${T('diffSmart.noSemanticChanges')}</div>`;
|
||||
return `<section class="card smart-diff"><header class="card-header"><div><h2>${T('diffSmart.title')}</h2><small>${T('diffSmart.subtitle')}</small></div><div class="smart-summary"><span>${T('diffSmart.semanticChanges')} <strong>${total}</strong></span><span>${T('diffSmart.sections')} <strong>${Number(smart.sections_changed) || 0}</strong></span><span>${T('diffSmart.attention')} <strong>${attention}</strong></span></div></header><div class="card-body"><div class="smart-note">${I('shield')}<span>${T('diffSmart.note')}</span></div>${body}</div></section>`;
|
||||
};
|
||||
M.diffResult = () => {
|
||||
const d = M.state.diff;
|
||||
if (!d) return C('diffConfigs.workspaceTitle', M.empty('ui.selectPair', '', 'diff'));
|
||||
const stats = d.stats || {}, search = M.state.diffSearch || '';
|
||||
const baseRows = (d.lines || []).filter(line => !M.state.diffChangesOnly || line.type !== 'context');
|
||||
const query = search.toLocaleLowerCase();
|
||||
const allRows = query ? baseRows.filter(line => String(line.left_text || '').toLocaleLowerCase().includes(query) || String(line.right_text || '').toLocaleLowerCase().includes(query)) : baseRows;
|
||||
const pageSize = Math.max(80, Number(M.state.diffPageSize) || 200);
|
||||
const pages = Math.max(1, Math.ceil(allRows.length / pageSize));
|
||||
M.state.diffPage = Math.min(pages, Math.max(1, Number(M.state.diffPage) || 1));
|
||||
const start = (M.state.diffPage - 1) * pageSize;
|
||||
const rows = allRows.slice(start, start + pageSize);
|
||||
const pane = side => `<div class="diff-pane"><div class="diff-title" title="${E(d[`${side}_file_name`] || '')}">${E(d[`${side}_file_name`] || '')}</div><div class="diff-scroll" data-diff-scroll="${side}"><div class="diff-lines">${rows.map(row => {
|
||||
const text = row[`${side}_text`] || '', n = row[`${side}_number`];
|
||||
let type = ['context', 'added', 'removed', 'modified'].includes(row.type) ? row.type : 'context';
|
||||
if (n == null) type = 'blank';
|
||||
const match = search && text.toLocaleLowerCase().includes(search.toLocaleLowerCase());
|
||||
return `<div class="diff-line ${type}${match ? ' match' : ''}"><span class="line-no">${n == null ? '' : Number(n)}</span><span class="line-code">${M.highlight(text, search) || ' '}</span></div>`;
|
||||
}).join('')}</div></div></div>`;
|
||||
const unified = () => `<div class="diff-scroll diff-unified" data-diff-scroll="unified"><div class="diff-lines">${rows.map(row => {
|
||||
const lines = [];
|
||||
if (row.type === 'context') lines.push(['context', ' ', row.left_number, row.left_text]);
|
||||
else if (row.type === 'removed') lines.push(['removed', '−', row.left_number, row.left_text]);
|
||||
else if (row.type === 'added') lines.push(['added', '+', row.right_number, row.right_text]);
|
||||
else { if (row.left_number != null) lines.push(['removed', '−', row.left_number, row.left_text]); if (row.right_number != null) lines.push(['added', '+', row.right_number, row.right_text]); }
|
||||
return lines.map(([type, prefix, n, text]) => { const match = search && String(text || '').toLocaleLowerCase().includes(search.toLocaleLowerCase()); return `<div class="diff-line ${type}${match ? ' match' : ''}"><span class="diff-prefix">${prefix}</span><span class="line-no">${n == null ? '' : Number(n)}</span><span class="line-code">${M.highlight(text, search) || ' '}</span></div>`; }).join('');
|
||||
}).join('')}</div></div>`;
|
||||
const pager = pages > 1 ? `<div class="diff-pager"><button type="button" class="btn small" data-action="diff-page-prev"${M.state.diffPage <= 1 ? ' disabled' : ''}>${T('ui.previous')}</button><span>${T('ui.page')} <strong>${M.state.diffPage}</strong> / ${pages} · ${start + 1}–${Math.min(allRows.length, start + rows.length)} / ${allRows.length}</span><button type="button" class="btn small" data-action="diff-page-next"${M.state.diffPage >= pages ? ' disabled' : ''}>${T('ui.next')}</button></div>` : `<span>${T('ui.lines')}: ${allRows.length}</span>`;
|
||||
return `${M.smartDiff(d.smart)}<section class="card raw-diff"><div class="diff-toolbar"><div class="tabs" role="tablist"><button type="button" class="tab ${M.state.diffMode === 'split' ? 'active' : ''}" data-diff-mode="split" role="tab" aria-selected="${M.state.diffMode === 'split'}">${T('ui.split')}</button><button type="button" class="tab ${M.state.diffMode === 'unified' ? 'active' : ''}" data-diff-mode="unified" role="tab" aria-selected="${M.state.diffMode === 'unified'}">${T('ui.unified')}</button></div><input type="search" id="diff-search" value="${E(search)}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"><label class="checkbox-label"><input type="checkbox" id="diff-changes-only"${M.state.diffChangesOnly ? ' checked' : ''}>${T('ui.changesOnly')}</label><div class="diff-stats">${[['added', 'ui.added'], ['removed', 'ui.removed'], ['modified', 'ui.modified'], ['context', 'ui.context']].map(([key, label]) => `<span class="${key}">${T(label)} <strong>${Number(stats[key]) || 0}</strong></span>`).join('')}</div></div>${M.state.diffMode === 'split' ? `<div class="diff-split">${pane('left')}${pane('right')}</div>` : unified()}<div class="diff-footer">${pager}<div class="actions">${B('download-diff', t('common.download') + ' .diff', 'download')}${B('download-html-diff', t('common.download') + ' HTML', 'files')}</div></div></section>`;
|
||||
};
|
||||
M.diffControls = () => {
|
||||
const files = (M.state.cache.diffFiles || []).filter(M.isTextExport), routers = (M.state.cache.routers || []).filter(M.supportsExport), s = M.state.diffSelection || {};
|
||||
const options = [['', t('diffConfigs.noneSelected')], ...files.map(f => [f.id, `${f.router_name || '#' + f.router_id} · ${M.date(f.created_at)} · ${f.file_name}`])];
|
||||
return `<form id="diff-form" class="diff-controls">${S('router_id', 'files.routerLabel', [['', t('files.allRouters')], ...routers.map(r => [r.id, r.name])], s.router_id || '')}${S('left', 'files.compareOlder', options, s.left || '', 'required')}${IB('swap-diff', 'ui.swap', 'swap')}${S('right', 'files.compareNewer', options, s.right || '', 'required')}<button class="btn primary" type="submit">${I('diff')}${T('ui.compare')}</button></form>`;
|
||||
};
|
||||
M.views.diff = async () => {
|
||||
const revision = M.state.routeId;
|
||||
const s = M.state.diffSelection || {};
|
||||
const query = M.backupQuery({ backup_type: 'export', router_id: s.router_id || '', sort_by: 'created_at', order: 'desc' });
|
||||
let [files, routers] = await Promise.all([M.api(`/backups?${query}`), M.api('/routers')]);
|
||||
if (revision !== M.state.routeId) return "";
|
||||
if (s.router_id && routers.some(router => String(router.id) === String(s.router_id) && !M.supportsExport(router))) {
|
||||
M.state.diffSelection = { router_id: '', left: '', right: '' }; M.state.diff = null;
|
||||
files = await M.api('/backups?backup_type=export&sort_by=created_at&order=desc');
|
||||
if (revision !== M.state.routeId) return '';
|
||||
history.replaceState({}, '', '/diff-configs');
|
||||
}
|
||||
M.state.cache.diffFiles = files.filter(M.isTextExport); M.state.cache.routers = routers;
|
||||
const selection = M.state.diffSelection || s;
|
||||
const allowed = new Set(M.state.cache.diffFiles.map(file => Number(file.id)));
|
||||
M.state.diffSelection = { ...selection, left: allowed.has(Number(selection.left)) ? selection.left : '', right: allowed.has(Number(selection.right)) ? selection.right : '' };
|
||||
if (M.state.diff && (!allowed.has(Number(M.state.diff.left_backup_id)) || !allowed.has(Number(M.state.diff.right_backup_id)))) M.state.diff = null;
|
||||
return `${M.heading('diffConfigs.title', 'diffConfigs.subtitle', B('latest-diff-pair', t('files.compareLatestPair'), 'clock', M.state.cache.diffFiles.length < 2 ? 'disabled' : '') + B('refresh', t('ui.refresh'), 'refresh'), 'diffConfigs.eyebrow')}<div id="diff-controls">${M.diffControls()}</div><div id="diff-result">${M.diffResult()}</div><div class="notice mt-16">${I('files')}<span>${T('files.binaryNoCompare')} · ${M.state.cache.diffFiles.length} ${T('files.exportPoolLabel')}</span></div>`;
|
||||
};
|
||||
M.schedulerTable = status => {
|
||||
const jobs = status.jobs || [];
|
||||
return `<div class="scheduler-summary actions"><span class="muted">${T('ui.timezone')}: <strong class="mono">${E(status.timezone)}</strong></span>${M.badge(t(status.running ? 'settings.statusEnabled' : 'settings.statusDisabled'), status.running ? 'success' : 'muted')}</div><div class="scheduler-jobs">${jobs.map(job => `<div class="scheduler-status-row"><div><strong>${E(t(job.label || job.key))}</strong><small>${E(t(job.description || '', job.description_params || {}))}</small></div><div>${M.badge(t(!job.valid ? 'ui.invalid' : job.enabled ? 'settings.statusEnabled' : 'settings.statusDisabled'), !job.valid ? 'danger' : job.enabled ? 'success' : 'muted')}<small class="mono">${E(job.cron || '—')}</small>${job.error ? `<small class="danger-text">${E(job.error)}</small>` : ''}</div><div class="next-runs"><small>${T('ui.nextRuns')}</small>${(job.next_runs || []).length ? job.next_runs.map(date => `<time datetime="${E(date)}">${E(M.date(date))}</time>`).join('') : '<span class="muted">—</span>'}</div></div>`).join('')}</div>`;
|
||||
};
|
||||
M.scheduleEditor = (prefix, title, expr, enabled = true) => {
|
||||
const parts = String(expr || '').trim().split(/\s+/);
|
||||
let mode = !expr || !enabled ? 'disabled' : 'custom', time = '02:00', weekday = 'mon';
|
||||
const names = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
|
||||
if (expr && enabled && parts.length === 5 && /^\d+$/.test(parts[0]) && /^\d+$/.test(parts[1]) && parts[2] === '*' && parts[3] === '*') {
|
||||
time = `${parts[1].padStart(2, '0')}:${parts[0].padStart(2, '0')}`;
|
||||
if (parts[4] === '*') mode = 'daily';
|
||||
else if (/^[0-6]$/.test(parts[4]) || names.includes(parts[4].toLowerCase())) { mode = 'weekly'; weekday = /^\d$/.test(parts[4]) ? names[Number(parts[4])] : parts[4].toLowerCase(); }
|
||||
}
|
||||
return `<div class="automation-row" data-schedule="${prefix}"><div class="automation-caption"><h3>${T(title)}</h3><p class="schedule-cron-preview" data-schedule-preview>${E(expr || t('settings.scheduleDisabledHint'))}</p></div><div class="schedule-fields">${S(`${prefix}_mode`, 'settings.scheduleMode', [['disabled', t('settings.scheduleDisabled')], ['daily', t('settings.scheduleDaily')], ['weekly', t('settings.scheduleWeekly')], ['custom', t('settings.scheduleCustom')]], mode)}<div data-schedule-time${mode === 'disabled' || mode === 'custom' ? ' hidden' : ''}>${F(`${prefix}_time`, 'settings.scheduleTime', time, { type: 'time' })}</div><div data-schedule-weekday${mode !== 'weekly' ? ' hidden' : ''}>${S(`${prefix}_weekday`, 'settings.scheduleWeekday', names.map((day, i) => [day, t('settings.weekday' + ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'][i])]), weekday)}</div><div class="wide" data-schedule-custom${mode !== 'custom' ? ' hidden' : ''}>${F(`${prefix}_custom`, prefix === 'export' ? 'settings.exportCron' : prefix === 'binary' ? 'settings.binaryCron' : 'settings.retentionCron', expr || '', { attrs: 'spellcheck="false" autocomplete="off"', placeholder: '0 2 * * *' })}</div></div></div>`;
|
||||
};
|
||||
M.settingsTabKeys = ['general', 'automation', 'credentials', 'smtp', 'pushover', 'account'];
|
||||
M.settingsTabs = () => `<div class="tabs" role="tablist">${[['general', 'settings.retentionTitle'], ['automation', 'ui.automation'], ['credentials', 'settings.sshDefaultsTitle'], ['smtp', 'settings.notificationsTitle'], ['pushover', 'Pushover'], ['account', 'ui.account']].map(([key, label]) => `<button type="button" class="tab ${M.state.settingsTab === key ? 'active' : ''}" data-settings-tab="${key}" role="tab" aria-selected="${M.state.settingsTab === key}">${T(label)}</button>`).join('')}</div>`;
|
||||
M.secretField = (name, key, hasValue, type = 'password') => `<div class="secret-field">${F(name, key, '', { type, placeholder: t(hasValue ? 'ui.kept' : 'ui.notSet'), attrs: 'autocomplete="new-password" spellcheck="false"' })}<label class="checkbox-label"><input type="checkbox" name="clear_${name}">${T('ui.clearSecret')}</label></div>`;
|
||||
M.settingsForm = settings => {
|
||||
const s = settings, hidden = key => M.state.settingsTab === key ? '' : ' hidden';
|
||||
const general = `<div class="form-grid">${F('backup_retention_days', 'settings.backupRetentionDays', s.backup_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}${F('log_retention_days', 'settings.logRetentionDays', s.log_retention_days, { type: 'number', required: true, attrs: 'min="0" max="36500"' })}</div>`;
|
||||
const schedules = `${M.scheduleEditor('export', 'settings.exportScheduleTitle', s.export_cron, s.enable_auto_export)}${M.scheduleEditor('binary', 'settings.binaryScheduleTitle', s.binary_cron)}${M.scheduleEditor('retention', 'settings.retentionTitle', s.retention_cron)}<div class="automation-row"><div class="automation-caption"><h3>${T('settings.connectionTestsTitle')}</h3><p class="automation-note">${T('settings.connectionTestsHint')}</p></div><div class="schedule-fields">${F('connection_test_interval_minutes', 'settings.connectionTestIntervalMinutes', s.connection_test_interval_minutes, { type: 'number', attrs: 'min="0" max="1440"' })}</div></div>`;
|
||||
const credentials = `<h3 class="section-title">${T('settings.globalSshPrivateKey')}</h3><p class="section-hint">${T('settings.sshRevealHint')}</p>${M.secretField('global_ssh_key', 'settings.globalSshPrivateKey', s.has_global_ssh_key, 'textarea')}<div class="actions mt-15">${B('reveal-ssh', t('settings.revealSshKey'), 'eye', s.has_global_ssh_key ? '' : 'disabled')}${s.has_global_ssh_key ? M.badge(t('settings.sshKeyStoredTag'), 'success') : ''}</div><hr><h3 class="section-title">${T('settings.switchosDefaultsTitle')}</h3><p class="section-hint">${T('settings.switchosDefaultsHint')}</p><div class="form-grid">${F('default_switchos_username', 'settings.defaultSwitchosUsername', s.default_switchos_username || '', { attrs: 'autocomplete="off" maxlength="120"' })}${M.secretField('default_switchos_password', 'settings.defaultSwitchosPassword', s.has_default_switchos_password || s.has_default_switchos_credentials)}</div>`;
|
||||
const smtp = `${M.toggle('smtp_notifications_enabled', 'settings.smtpEnabled', s.smtp_notifications_enabled, 'settings.smtpEnabledHint')}${M.toggle('notify_failures_only', 'settings.failuresOnly', s.notify_failures_only, 'settings.failuresOnlyHint')}<div class="form-grid mt-22">${F('smtp_host', 'settings.smtpHost', s.smtp_host || '', { attrs: 'maxlength="255"' })}${F('smtp_port', 'settings.smtpPort', s.smtp_port || 587, { type: 'number', attrs: 'min="1" max="65535"' })}${F('smtp_login', 'settings.smtpLogin', s.smtp_login || '', { attrs: 'autocomplete="off"' })}${M.secretField('smtp_password', 'settings.smtpPassword', s.has_smtp_password)}${F('recipient_email', 'settings.recipientEmail', s.recipient_email || '', { type: 'email', wide: true })}</div><div class="actions mt-22">${B('test-email', t('settings.testEmail'), 'mail')}<small>${T('ui.saveFirst')}</small></div>`;
|
||||
const pushover = `<div class="form-grid">${M.secretField('pushover_token', 'settings.pushoverToken', s.has_pushover_token)}${M.secretField('pushover_userkey', 'settings.pushoverUserKey', s.has_pushover_userkey)}</div><div class="actions mt-22">${B('test-pushover', t('settings.testPushover'), 'check')}<small>${T('ui.saveFirst')}</small></div>`;
|
||||
return `<form id="settings-form" class="settings-form"><div class="settings-fields card"><div class="settings-fields-body card-body"><section data-settings-pane="general"${hidden('general')}><h3 class="section-title">${T('settings.retentionTitle')}</h3><p class="section-hint">${T('settings.retentionSubtitle')}</p>${general}</section><section data-settings-pane="automation"${hidden('automation')}>${schedules}</section><section data-settings-pane="credentials"${hidden('credentials')}>${credentials}</section><section data-settings-pane="smtp"${hidden('smtp')}>${smtp}</section><section data-settings-pane="pushover"${hidden('pushover')}>${pushover}</section></div></div><div class="settings-save">${B('refresh', t('common.reset'), 'refresh')}<button type="submit" class="btn primary">${I('check')}${T('settings.save')}</button></div></form>`;
|
||||
};
|
||||
M.preferencesForm = () => `<form id="preferences-form" class="card-body"><p class="section-hint">${T('settings.interfacePreferencesHint')}</p><div class="form-grid">${S('preferred_language', 'ui.language', [['pl', 'Polski'], ['en', 'English'], ['es', 'Español'], ['no', 'Norsk']], M.state.lang)}${S('preferred_font', 'settings.fontFamily', [['default', t('settings.fontDefault')], ['adwaita_mono', 'Adwaita Mono'], ['hack', 'Hack']], M.state.font)}</div><div class="form-actions"><button class="btn" type="submit">${I('check')}${T('common.save')}</button></div></form>`;
|
||||
M.views.settings = async () => {
|
||||
const revision = M.state.routeId;
|
||||
const [settings, status] = await Promise.all([M.api('/settings'), M.api('/settings/scheduler-status')]);
|
||||
if (revision !== M.state.routeId) return "";
|
||||
M.state.cache.settings = settings; M.state.cache.scheduler = status;
|
||||
M.state.settingsTab ||= 'general';
|
||||
return `${M.heading('settings.title', 'settings.subtitle', B('refresh', t('ui.refresh'), 'refresh'), 'settings.eyebrow')}<div id="settings-tabs">${M.settingsTabs()}</div><div class="settings-layout"><div class="settings-main-column"><div data-settings-system${M.state.settingsTab === 'account' ? ' hidden' : ''}>${M.settingsForm(settings)}</div><section data-settings-pane="account"${M.state.settingsTab === 'account' ? '' : ' hidden'}>${C('auth.changePassword', M.passwordForm(), '', 'password-card')}</section><section class="card scheduler-status" data-settings-automation${M.state.settingsTab === 'automation' ? '' : ' hidden'}><header class="card-header"><h2>${T('ui.systemTasks')}</h2>${IB('refresh-scheduler', 'ui.refresh', 'refresh')}</header><div class="card-body" id="scheduler-status-body">${M.schedulerTable(status)}</div></section></div>${C('ui.preferences', M.preferencesForm(), '', 'preference-card')}</div>`;
|
||||
};
|
||||
M.logsTable = () => {
|
||||
const term = (M.state.logSearch || '').toLocaleLowerCase();
|
||||
const logs = (M.state.cache.logs || []).filter(log => String(log.message).toLocaleLowerCase().includes(term));
|
||||
if (!logs.length) return M.empty(term ? 'ui.noMatches' : 'ui.noLogs', '', 'clock');
|
||||
return `<div class="table-wrap"><table><thead><tr><th>${T('logs.timestampColumn')}</th><th>${T('logs.messageColumn')}</th></tr></thead><tbody>${M.paged(logs).map(log => `<tr><td class="mono nowrap">${E(M.date(log.timestamp))}</td><td class="break">${E(log.message)}</td></tr>`).join('')}</tbody></table></div>${M.pagination(logs.length)}`;
|
||||
};
|
||||
M.views.logs = async () => {
|
||||
const revision = M.state.routeId;
|
||||
const [logs, settings] = await Promise.all([M.api('/logs'), M.api('/settings')]);
|
||||
if (revision !== M.state.routeId) return "";
|
||||
M.state.cache.logs = logs;
|
||||
return `${M.heading('logs.title', 'logs.tableSubtitle', B('refresh', t('ui.refresh'), 'refresh'), 'logs.eyebrow')}<div class="filter-bar"><div class="field search-field"><span>${T('files.searchLabel')}</span><input type="search" id="logs-search" value="${E(M.state.logSearch || '')}" placeholder="${T('ui.search')}" aria-label="${T('files.searchLabel')}"></div><form id="logs-cleanup" class="actions">${F('days', 'logs.deleteOlderThan', settings.log_retention_days ?? 7, { type: 'number', attrs: 'min="1" max="36500" required' })}<button type="submit" class="btn danger">${I('trash')}${T('common.delete')}</button></form></div><div class="card" id="logs-table">${M.logsTable()}</div>`;
|
||||
};
|
||||
M.passwordForm = () => `<form class="card-body" id="password-form"><p class="section-hint">${T('auth.changePasswordCardSubtitle')}</p><div class="form-grid">${F('current_password', 'auth.currentPassword', '', { type: 'password', required: true, wide: true, attrs: 'autocomplete="current-password"' })}${F('new_password', 'auth.newPassword', '', { type: 'password', required: true, attrs: 'autocomplete="new-password" minlength="4" maxlength="128"' })}${F('confirm_password', 'ui.confirmNewPassword', '', { type: 'password', required: true, attrs: 'autocomplete="new-password" minlength="4" maxlength="128"' })}</div><div class="password-hints"><span data-password-hint="length">${I('check')}${T('auth.ruleLength')}</span><span data-password-hint="digit">${I('check')}${T('auth.ruleDigit')}</span><span data-password-hint="match">${I('check')}${T('auth.ruleMatch')}</span></div><div class="form-actions"><button type="submit" class="btn primary">${I('lock')}${T('auth.changePassword')}</button></div></form>`;
|
||||
M.languageSelect = () => `<select class="language-select" data-language aria-label="${T('topbar.languageSelector')}">${[['pl', 'PL'], ['en', 'EN'], ['es', 'ES'], ['no', 'NO']].map(([code, label]) => `<option value="${code}"${M.state.lang === code ? ' selected' : ''}>${label}</option>`).join('')}</select>`;
|
||||
M.authView = register => {
|
||||
register = !!register && M.config.allowRegistration;
|
||||
return `<div class="auth-layout"><main class="auth-center" id="main"><div class="auth-hero"><p class="eyebrow">MIKROTIK / BACKUP SYSTEM</p><h1>${T('ui.protected')}</h1><p>${T('ui.loginHint')}</p></div><section class="auth-card"><h2>${T(register ? 'auth.register' : 'auth.login')}</h2><p>${T('auth.loginSubtitle')}</p><form id="${register ? 'register' : 'login'}-form">${F('username', 'auth.username', '', { required: true, attrs: 'autocomplete="username" minlength="3" maxlength="120"' })}${F('password', 'auth.password', '', { type: 'password', required: true, attrs: `autocomplete="${register ? 'new-password' : 'current-password'}" minlength="4" maxlength="128"` })}${register ? F('confirm_password', 'auth.confirmPassword', '', { type: 'password', required: true, attrs: 'autocomplete="new-password" minlength="4" maxlength="128"' }) : ''}<button type="submit" class="btn primary">${T(register ? 'auth.register' : 'auth.login')}</button></form>${register || M.config.allowRegistration ? `<a data-nav href="/${register ? 'login' : 'register'}" class="auth-link">${T(register ? 'auth.backToLogin' : 'auth.register')}</a>` : ''}</section></main><footer class="auth-footer"><div class="auth-controls">${M.languageSelect()}<button type="button" class="btn ghost" data-action="theme">${T('ui.changeTheme')}</button></div><span>${T('ui.about')}</span></footer></div>`;
|
||||
};
|
||||
})(window.MBS);
|
||||
@@ -0,0 +1,211 @@
|
||||
/* Run with node --test web/tests/core.test.cjs. No packages required. */
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function application(options = {}) {
|
||||
const storage = () => ({ getItem: () => null, setItem() {}, removeItem() {} });
|
||||
const context = {
|
||||
window: {}, document: { querySelector: selector => selector === 'meta[name="allow-registration"]' && options.registration !== undefined ? { content: options.registration } : null }, sessionStorage: storage(), localStorage: storage(),
|
||||
AbortController, URLSearchParams, Blob, URL, Response, fetch, setTimeout, clearTimeout,
|
||||
requestAnimationFrame: fn => fn(), performance, console
|
||||
};
|
||||
context.window.window = context.window;
|
||||
vm.createContext(context);
|
||||
for (const name of ['i18n.js', 'core.js', 'views.js']) vm.runInContext(fs.readFileSync(path.join(__dirname, '../js', name), 'utf8'), context);
|
||||
return context.window.MBS;
|
||||
}
|
||||
|
||||
test('all four languages include every statically used translation key', () => {
|
||||
const app = application();
|
||||
const code = ['core.js', 'views.js', 'app.js'].map(file => fs.readFileSync(path.join(__dirname, '../js', file), 'utf8')).join('\n');
|
||||
const keys = [...code.matchAll(/'((?:app|auth|common|confirm|dashboard|diffConfigs|files|footer|logs|nav|routers|settings|sidebar|toast|topbar|ui)\.[A-Za-z]+)'/g)].map(match => match[1]);
|
||||
for (const lang of ['pl', 'en', 'es', 'no']) {
|
||||
for (const key of keys.filter(key => key.includes('.'))) {
|
||||
// Dynamic weekday suffix is validated by the schedule editor test.
|
||||
if (key === 'settings.weekday') continue;
|
||||
const value = key.split('.').reduce((node, part) => node && node[part], app.translations[lang]);
|
||||
assert.equal(typeof value, 'string', `${lang}: ${key}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('untrusted text and diff search results are HTML escaped', () => {
|
||||
const app = application();
|
||||
assert.equal(app.escape('<script a="1">&\''), '<script a="1">&'');
|
||||
assert.equal(app.highlight('<img src=x onerror=alert(1)>', 'img'), '<<mark>img</mark> src=x onerror=alert(1)>');
|
||||
app.state.diff = {
|
||||
left_file_name: '<script>alert(1)</script>', right_file_name: '<img src=x>', diff_text: '<script>x</script>',
|
||||
lines: [{ type: 'modified', left_number: 1, right_number: 1, left_text: '<img onerror=x>', right_text: '&password' }]
|
||||
};
|
||||
const html = app.diffResult();
|
||||
assert.ok(!html.includes('<script>'));
|
||||
assert.ok(!html.includes('<img'));
|
||||
assert.ok(html.includes('<img onerror=x>'));
|
||||
});
|
||||
|
||||
test('settings and device forms never prefill secret values returned by a legacy server', () => {
|
||||
const app = application();
|
||||
app.state.settingsTab = 'credentials';
|
||||
const secret = 'do-not-render-secret-654321';
|
||||
const settings = app.settingsForm({ global_ssh_key: secret, default_switchos_password: secret, smtp_password: secret, pushover_token: secret, pushover_userkey: secret });
|
||||
const device = app.deviceForm({ id: 1, name: 'R1', device_type: 'routeros', host: '127.0.0.1', ssh_password: secret, ssh_key: secret });
|
||||
assert.ok(!settings.includes(secret));
|
||||
assert.ok(!device.includes(secret));
|
||||
assert.ok(settings.includes('clear_global_ssh_key'));
|
||||
assert.ok(device.includes('clear_ssh_password'));
|
||||
});
|
||||
|
||||
test('registration is hidden unless the public page explicitly enables it', () => {
|
||||
for (const registration of [undefined, 'false', 'TRUE', '1']) {
|
||||
const app = application({ registration });
|
||||
assert.equal(app.config.allowRegistration, false);
|
||||
assert.ok(!app.authView(false).includes('href="/register"'));
|
||||
assert.ok(!app.authView(true).includes('id="register-form"'));
|
||||
}
|
||||
const app = application({ registration: 'true' });
|
||||
assert.equal(app.config.allowRegistration, true);
|
||||
assert.ok(app.authView(false).includes('href="/register"'));
|
||||
assert.ok(app.authView(true).includes('id="register-form"'));
|
||||
});
|
||||
|
||||
test('login has no header or decorative SVGs and retains language and theme controls', () => {
|
||||
const html = application().authView(false);
|
||||
assert.ok(!html.includes('<header'));
|
||||
assert.ok(!html.includes('<svg'));
|
||||
assert.ok(html.includes('data-language'));
|
||||
assert.ok(html.includes('data-action="theme"'));
|
||||
});
|
||||
|
||||
test('account settings contain the existing password form outside the system settings form', async () => {
|
||||
const app = application();
|
||||
app.state.settingsTab = 'account';
|
||||
app.api = async endpoint => endpoint.endsWith('scheduler-status') ? { jobs: [] } : {};
|
||||
const html = await app.views.settings(false);
|
||||
assert.ok(html.includes('data-settings-tab="account" role="tab" aria-selected="true"'));
|
||||
assert.ok(html.includes('<section data-settings-pane="account">'));
|
||||
assert.ok(html.includes('id="password-form"'));
|
||||
assert.ok(html.indexOf('</form>') < html.indexOf('id="password-form"'));
|
||||
assert.ok(html.includes('data-settings-system hidden'));
|
||||
});
|
||||
|
||||
test('binary and SwitchOS backups never offer comparison actions or export tabs', () => {
|
||||
const app = application();
|
||||
const routeros = { id: 1, device_type: 'routeros', name: 'R1' };
|
||||
const switchos = { id: 2, device_type: 'switchos', name: 'SW1', supports_export: false };
|
||||
for (const file of [
|
||||
{ id: 3, router_id: 1, backup_type: 'binary', device_type: 'routeros', file_name: 'test.backup' },
|
||||
{ id: 4, router_id: 2, backup_type: 'binary', device_type: 'switchos', file_name: 'test.swb' },
|
||||
{ id: 5, router_id: 2, backup_type: 'export', device_type: 'switchos', file_name: 'legacy.rsc' }
|
||||
]) assert.ok(!app.backupTable([file]).includes('data-action="diff-latest"'));
|
||||
app.state.cache.device = routeros;
|
||||
app.state.detailTab = 'binary';
|
||||
assert.ok(!app.detailBody().includes('data-action="compare-selected"'));
|
||||
app.state.cache.device = switchos;
|
||||
app.state.detailTab = 'overview';
|
||||
assert.ok(!app.detailTabs().includes('data-detail-tab="export"'));
|
||||
assert.ok(!app.detailBody().includes('data-action="device-export"'));
|
||||
app.state.detailTab = 'policy';
|
||||
assert.ok(!app.detailBody().includes('name="disable_export_backups"'));
|
||||
app.state.cache.routers = [routeros, switchos];
|
||||
app.state.cache.diffFiles = [
|
||||
{ id: 1, backup_type: 'export', file_name: 'allowed.rsc', device_type: 'routeros' },
|
||||
{ id: 2, backup_type: 'binary', file_name: 'forbidden.backup' },
|
||||
{ id: 3, backup_type: 'export', file_name: 'forbidden.swb', device_type: 'switchos' }
|
||||
];
|
||||
const diff = app.diffControls();
|
||||
assert.ok(diff.includes('allowed.rsc'));
|
||||
assert.ok(!diff.includes('forbidden.'));
|
||||
assert.ok(!diff.includes('SW1'));
|
||||
});
|
||||
|
||||
test('SwitchOS credential forms preserve legacy choice and never prefill passwords', () => {
|
||||
const app = application();
|
||||
const base = { id: 2, device_type: 'switchos', name: 'SW1', ssh_user: 'own-admin', ssh_password: 'secret-not-to-render', has_own_password: true };
|
||||
for (const mode of [null, true, false]) {
|
||||
const html = app.deviceForm({ ...base, use_global_switchos_credentials: mode });
|
||||
assert.ok(html.includes(`value="${mode === null ? '' : mode}" selected`));
|
||||
assert.ok(!html.includes('secret-not-to-render'));
|
||||
assert.equal(html.includes('id="device-own-credentials" hidden'), mode === true);
|
||||
}
|
||||
assert.equal(app.deviceUsername({ ssh_user: 'stored-own-admin', effective_username: null, use_global_switchos_credentials: true }), '—');
|
||||
});
|
||||
|
||||
test('diff form errors never create an inline error element', () => {
|
||||
const app = application();
|
||||
let removed = false;
|
||||
const form = { id: 'diff-form', querySelector: () => ({ remove() { removed = true; } }) };
|
||||
app.formError(form, 'Choose two exports');
|
||||
assert.equal(removed, true);
|
||||
});
|
||||
|
||||
test('automation editors and active settings form contain no nested cards', () => {
|
||||
const app = application();
|
||||
app.state.settingsTab = 'automation';
|
||||
const html = app.settingsForm({ export_cron: '0 2 * * mon', enable_auto_export: true });
|
||||
assert.ok(html.includes('data-settings-pane="automation"'));
|
||||
assert.ok(html.includes('class="automation-row"'));
|
||||
assert.ok(!html.includes('class="card"'));
|
||||
assert.ok(!html.includes('class="card-body"'));
|
||||
assert.ok(!html.includes('schedule-editor'));
|
||||
const status = app.schedulerTable({ jobs: [], timezone: 'Europe/Warsaw', running: true });
|
||||
assert.ok(!status.includes('class="card'));
|
||||
});
|
||||
|
||||
test('translated UI branding does not expose implementation terminology', () => {
|
||||
const app = application();
|
||||
for (const lang of ['pl', 'en', 'es', 'no']) assert.ok(!/rust|monolit/i.test(app.translations[lang].ui.about));
|
||||
});
|
||||
|
||||
test('legacy naive UTC timestamps are parsed as UTC', () => {
|
||||
const app = application();
|
||||
assert.equal(app.parseDate('2026-01-02 03:04:05').toISOString(), '2026-01-02T03:04:05.000Z');
|
||||
assert.equal(app.parseDate('2026-01-02T04:04:05+01:00').toISOString(), '2026-01-02T03:04:05.000Z');
|
||||
assert.equal(app.parseDate('not a date'), null);
|
||||
});
|
||||
|
||||
test('weekly schedules preserve actual APScheduler weekday meaning', () => {
|
||||
const app = application();
|
||||
const monday = app.scheduleEditor('export', 'settings.exportCron', '0 2 * * 0');
|
||||
const tuesday = app.scheduleEditor('export', 'settings.exportCron', '0 2 * * 1');
|
||||
const sunday = app.scheduleEditor('export', 'settings.exportCron', '0 2 * * sun');
|
||||
assert.ok(monday.includes('value="mon" selected'));
|
||||
assert.ok(tuesday.includes('value="tue" selected'));
|
||||
assert.ok(sunday.includes('value="sun" selected'));
|
||||
const custom = app.scheduleEditor('export', 'settings.exportCron', '*/15 * * * mon-fri');
|
||||
assert.ok(custom.includes('value="custom" selected'));
|
||||
});
|
||||
|
||||
test('dashboard renders only real input counts and escapes stored labels/logs', async () => {
|
||||
const app = application();
|
||||
app.api = async endpoint => endpoint === '/dashboard'
|
||||
? { routers_count: 1, export_count: 0, binary_count: 0, total_backups: 0, storage: { total: 100, used: 30, free: 70, folder_used: 0 }, recent_logs: [{ message: '<script>bad</script>', timestamp: '2026-01-01' }] }
|
||||
: endpoint === '/routers' ? [{ id: 1, name: 'one', last_connection_status: null }] : [];
|
||||
const html = await app.views.dashboard();
|
||||
assert.ok(html.includes('<script>bad</script>'));
|
||||
assert.ok(!html.includes('<script>bad'));
|
||||
assert.ok(html.includes('stroke-dasharray="326.73 326.73"'));
|
||||
const cards = html.split('<div class="cards-grid">')[1].split('<div class="grid-2">')[0];
|
||||
assert.equal((cards.match(/class="metric"/g) || []).length, 4);
|
||||
assert.ok(!cards.includes('<svg'));
|
||||
assert.ok(!cards.includes('metric-icon'));
|
||||
});
|
||||
|
||||
test('query parameters use URL encoding and skip empty values', () => {
|
||||
const app = application();
|
||||
const params = new URLSearchParams(app.backupQuery({ search: 'r1&router_id=2', router_id: '', order: 'asc' }));
|
||||
assert.equal(params.get('search'), 'r1&router_id=2');
|
||||
assert.equal(params.has('router_id'), false);
|
||||
});
|
||||
|
||||
test('a late device response cannot overwrite the state after navigation', async () => {
|
||||
const app = application(), pending = [];
|
||||
app.api = endpoint => new Promise(resolve => pending.push({ endpoint, resolve }));
|
||||
const load = app.views.device(1);
|
||||
app.state.routeId += 1;
|
||||
pending.forEach(({ endpoint, resolve }) => resolve(endpoint.startsWith('/routers') ? { id: 1, name: 'stale' } : []));
|
||||
assert.equal(await load, '');
|
||||
assert.equal(app.state.cache.device, undefined);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/* Read-only browser smoke test of a running Rust server.
|
||||
* Set MBS_BASE_URL, MBS_TEST_USERNAME and MBS_TEST_PASSWORD explicitly.
|
||||
* Optional MBS_CHROMIUM points to an existing browser executable.
|
||||
*/
|
||||
const { chromium } = require('playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const base = process.env.MBS_BASE_URL;
|
||||
if (!base || !process.env.MBS_TEST_USERNAME || !process.env.MBS_TEST_PASSWORD) throw new Error('Set MBS_BASE_URL, MBS_TEST_USERNAME and MBS_TEST_PASSWORD');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'], ...(process.env.MBS_CHROMIUM ? { executablePath: process.env.MBS_CHROMIUM } : {}) });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const errors = [], failed = [], security = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('console', message => { if (message.type() === 'error' && /Content Security|Refused to|CSP/i.test(message.text())) security.push(message.text()); });
|
||||
page.on('response', response => { if (response.url().includes('/api/') && response.status() >= 400) failed.push(`${response.status()} ${new URL(response.url()).pathname}`); });
|
||||
try {
|
||||
await page.goto(base);
|
||||
await page.locator('#login-form').waitFor();
|
||||
assert.equal(await page.locator('.auth-layout header, .auth-layout svg').count(), 0);
|
||||
if (!await page.evaluate(() => window.MBS.config.allowRegistration)) assert.equal(await page.locator('a[href="/register"]').count(), 0);
|
||||
await page.fill('#login-form [name=username]', process.env.MBS_TEST_USERNAME);
|
||||
await page.fill('#login-form [name=password]', process.env.MBS_TEST_PASSWORD);
|
||||
await page.click('#login-form button[type=submit]');
|
||||
await page.locator('.cards-grid').waitFor();
|
||||
assert.equal(await page.locator('.metric svg, .topbar .brand svg, .topbar .brand-sub, .nav a[href="/change-password"]').count(), 0);
|
||||
assert.equal(await page.locator('.nav a[href="/schedule"]').count(), 0);
|
||||
assert.equal(await page.locator('.api-docs-link').getAttribute('href'), '/docs');
|
||||
for (const route of ['/devices', '/files', '/diff-configs', '/logs', '/settings', '/schedule', '/change-password', '/']) {
|
||||
await page.evaluate(route => window.MBS.navigate(route), route);
|
||||
await page.locator('.loading').waitFor({ state: 'detached' });
|
||||
assert.equal(await page.locator('.error-panel').count(), 0, `Failed route ${route}`);
|
||||
assert.equal(await page.locator('main h1').count(), 1, `Missing heading ${route}`);
|
||||
assert.ok(!/\b(?:routers|settings|dashboard|files|auth|ui)\.[a-zA-Z]+\b/.test(await page.locator('main').textContent()), `Untranslated key on ${route}`);
|
||||
if (route === '/change-password') {
|
||||
assert.equal(new URL(page.url()).pathname, '/settings');
|
||||
assert.equal(new URL(page.url()).searchParams.get('tab'), 'account');
|
||||
assert.equal(await page.locator('#password-form').isVisible(), true);
|
||||
}
|
||||
if (route === '/schedule') {
|
||||
assert.equal(new URL(page.url()).pathname, '/settings');
|
||||
assert.equal(new URL(page.url()).searchParams.get('tab'), 'automation');
|
||||
assert.equal(await page.locator('.settings-layout .card:visible').count(), 0);
|
||||
}
|
||||
console.log(`PASS live route ${route}`);
|
||||
}
|
||||
const routers = await page.evaluate(() => window.MBS.state.cache.routers || []);
|
||||
if (routers.length) {
|
||||
await page.evaluate(id => window.MBS.navigate(`/devices/${id}`), routers[0].id);
|
||||
await page.locator('#device-detail-body').waitFor();
|
||||
const supported = routers[0].device_type !== 'switchos' && routers[0].supports_export !== false;
|
||||
assert.equal(await page.locator('.device-heading .metric-icon').count(), 0);
|
||||
if (!supported) assert.equal(await page.locator('[data-detail-tab=export], [data-action=device-export]').count(), 0);
|
||||
for (const tab of [...(supported ? ['export'] : []), 'binary', 'policy', 'overview']) await page.click(`[data-detail-tab=${tab}]`);
|
||||
console.log('PASS live device detail and tabs');
|
||||
}
|
||||
const exports = await page.evaluate(() => (window.MBS.state.cache.backups || []).filter(file => file.backup_type === 'export'));
|
||||
if (exports.length >= 2) {
|
||||
await page.evaluate(ids => window.MBS.navigate(`/diff-configs?left=${ids[0]}&right=${ids[1]}`), exports.slice(0, 2).map(file => file.id));
|
||||
await page.locator('.diff-split').waitFor();
|
||||
assert.equal(await page.locator('.diff-pane').count(), 2);
|
||||
await page.click('[data-diff-mode=unified]'); await page.locator('.diff-unified').waitFor();
|
||||
await page.click('[data-diff-mode=split]');
|
||||
if (process.env.MBS_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-diff.png'), fullPage: true, animations: 'disabled' });
|
||||
}
|
||||
console.log('PASS live RSC diff split and unified');
|
||||
}
|
||||
await page.evaluate(() => window.MBS.navigate('/'));
|
||||
if (process.env.MBS_SCREENSHOT_DIR) {
|
||||
fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true });
|
||||
await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-dashboard.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.evaluate(() => window.MBS.navigate('/settings'));
|
||||
await page.click('[data-settings-tab=automation]');
|
||||
await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-settings.png'), fullPage: true, animations: 'disabled' });
|
||||
}
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.evaluate(() => window.MBS.navigate('/'));
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), 'Mobile horizontal overflow');
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'live-mobile.png'), fullPage: true, animations: 'disabled' });
|
||||
assert.deepEqual(errors, []); assert.deepEqual(failed, []); assert.deepEqual(security, []);
|
||||
await page.click('[data-action=logout]'); await page.locator('#login-form').waitFor();
|
||||
console.log('PASS live responsive layout, CSP, no API errors, no JS errors and logout');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,358 @@
|
||||
/* Browser contract smoke test against isolated local HTTP fixtures.
|
||||
* Requires Playwright: node web/tests/smoke.cjs
|
||||
* It never contacts a MikroTik or sends an email/push notification.
|
||||
*/
|
||||
const assert = require('node:assert/strict');
|
||||
const http = require('node:http');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const web = path.resolve(__dirname, '..');
|
||||
const assets = fs.readFileSync(path.join(web, 'assets.list'), 'utf8').trim().split(/\r?\n/).filter(line => line && !line.startsWith('#'));
|
||||
const script = assets.filter(file => file.endsWith('.js')).map(file => fs.readFileSync(path.join(web, file), 'utf8')).join('\n');
|
||||
const css = assets.filter(file => file.endsWith('.css')).map(file => fs.readFileSync(path.join(web, file), 'utf8')).join('\n');
|
||||
const now = new Date().toISOString();
|
||||
const state = {
|
||||
allowRegistration: false,
|
||||
failNextDiff: false,
|
||||
calls: [], user: { id: 1, username: 'qa_user', preferred_language: 'en', preferred_font: 'default' },
|
||||
routers: [{ id: 1, owner_id: 1, name: 'R1-main', device_type: 'routeros', host: '127.0.0.1', port: 22, ssh_user: 'admin', effective_username: 'admin', has_effective_password: true, has_effective_ssh_key: false, supports_export: true, supports_restore_upload: true, disable_all_backups: false, disable_export_backups: false, disable_binary_backups: false, disable_ping: false, last_connection_status: true, last_connection_model: 'RB5009', last_connection_version: '7.20', last_connection_tested_at: now, last_connection_uptime: '1d2h', created_at: now }],
|
||||
files: [1, 2, 3].map(id => ({ id, router_id: 1, router_name: 'R1-main', device_type: 'routeros', file_name: `R1-${id}.${id === 3 ? 'backup' : 'rsc'}`, backup_type: id === 3 ? 'binary' : 'export', file_size: 1024 * id, checksum: 'a'.repeat(64), created_at: new Date(Date.now() - (4 - id) * 60000).toISOString() })),
|
||||
settings: { id: 1, backup_retention_days: 7, log_retention_days: 7, export_cron: '0 2 * * 1', binary_cron: '', retention_cron: '', enable_auto_export: true, connection_test_interval_minutes: 0, global_ssh_key: null, has_global_ssh_key: true, default_switchos_username: 'admin', default_switchos_password: null, has_default_switchos_credentials: true, has_default_switchos_password: true, pushover_token: null, pushover_userkey: null, has_pushover_token: true, has_pushover_userkey: true, notify_failures_only: true, smtp_host: 'localhost', smtp_port: 587, smtp_login: '', smtp_password: null, has_smtp_password: true, smtp_notifications_enabled: false, recipient_email: 'qa@example.invalid' }
|
||||
};
|
||||
const publicRouter = router => {
|
||||
const { _ownPassword, _ownKey, ssh_password, ssh_key, ...result } = router;
|
||||
const switchos = router.device_type === 'switchos';
|
||||
const source = router.use_global_switchos_credentials;
|
||||
const ownPassword = !!_ownPassword || !!router.has_own_password || !switchos && !!router.has_effective_password;
|
||||
const global = switchos && source === true;
|
||||
const legacy = switchos && source == null;
|
||||
return { ...result, ssh_password: null, ssh_key: null, has_own_password: ownPassword,
|
||||
use_global_switchos_credentials: source ?? null, supports_export: !switchos,
|
||||
effective_username: global ? state.settings.default_switchos_username || null : router.ssh_user || (legacy ? state.settings.default_switchos_username : null),
|
||||
uses_global_switchos_credentials: global || legacy && (!router.ssh_user || !ownPassword),
|
||||
has_effective_password: global ? !!state.settings.has_default_switchos_password : ownPassword || legacy && !!state.settings.has_default_switchos_password
|
||||
};
|
||||
};
|
||||
const json = (response, status, data) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(data)); };
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://localhost'), pathname = url.pathname;
|
||||
if (pathname === '/assets/app.js') { res.writeHead(200, { 'Content-Type': 'text/javascript' }); res.end(script); return; }
|
||||
if (pathname === '/assets/app.css') { res.writeHead(200, { 'Content-Type': 'text/css' }); res.end(css); return; }
|
||||
if (!pathname.startsWith('/api/')) { res.writeHead(200, { 'Content-Type': 'text/html', 'Content-Security-Policy': "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; object-src 'none'" }); res.end(fs.readFileSync(path.join(web, 'index.html'), 'utf8').replace('<meta name="allow-registration" content="false">', `<meta name="allow-registration" content="${state.allowRegistration}">`)); return; }
|
||||
let raw = ''; for await (const chunk of req) raw += chunk;
|
||||
const body = raw ? JSON.parse(raw) : {};
|
||||
state.calls.push({ method: req.method, path: pathname, body, query: Object.fromEntries(url.searchParams) });
|
||||
if (pathname === '/api/health') return json(res, 200, { status: 'ok' });
|
||||
if (pathname === '/api/auth/register') return state.allowRegistration ? json(res, 200, { id: 2, username: body.username, preferred_language: 'en', preferred_font: 'default' }) : json(res, 403, { detail: 'Registration is disabled' });
|
||||
if (pathname === '/api/auth/login') return body.username === 'qa_user' && body.password === 'test-password' ? json(res, 200, { access_token: 'fixture-only-token', token_type: 'bearer', user: state.user }) : json(res, 401, { detail: 'Invalid credentials' });
|
||||
if (!req.headers.authorization) return json(res, 401, { detail: 'Unauthorized' });
|
||||
if (pathname === '/api/auth/me') return json(res, 200, state.user);
|
||||
if (pathname === '/api/auth/preferences') { Object.assign(state.user, body); return json(res, 200, state.user); }
|
||||
if (pathname === '/api/auth/change-password') return json(res, 200, { message: 'Password changed' });
|
||||
if (pathname === '/api/dashboard') return json(res, 200, { routers_count: state.routers.length, export_count: state.files.filter(f => f.backup_type === 'export').length, binary_count: state.files.filter(f => f.backup_type === 'binary').length, total_backups: state.files.length, storage: { total: 1000000, used: 600000, free: 400000, folder_used: 10240 }, recent_logs: [{ id: 1, message: '<img src=x onerror=alert(1)> safely escaped', timestamp: now }] });
|
||||
if (pathname === '/api/routers/ping-statuses') return json(res, 200, { items: state.routers.map(r => ({ router_id: r.id, reachable: true, latency_ms: 0.5, disabled: r.disable_ping })) });
|
||||
if (pathname === '/api/routers') {
|
||||
if (req.method === 'POST') { const r = { id: Math.max(0, ...state.routers.map(router => router.id)) + 1, owner_id: 1, created_at: now, has_effective_password: false, ...body, _ownPassword: body.ssh_password }; state.routers.push(r); return json(res, 200, publicRouter(r)); }
|
||||
return json(res, 200, state.routers.map(publicRouter));
|
||||
}
|
||||
let match;
|
||||
if ((match = pathname.match(/^\/api\/routers\/(\d+)\/test-connection$/))) return json(res, 200, { success: true, tested_at: now, hostname: 'R1-main', model: 'RB5009', uptime: '1d2h' });
|
||||
if ((match = pathname.match(/^\/api\/routers\/(\d+)$/))) {
|
||||
const id = Number(match[1]), router = state.routers.find(r => r.id === id);
|
||||
if (req.method === 'PUT') { Object.assign(router, body); if (body.ssh_password) router._ownPassword = body.ssh_password; if (body.clear_ssh_password) { delete router._ownPassword; router.has_own_password = false; } }
|
||||
if (req.method === 'DELETE') state.routers = state.routers.filter(r => r.id !== id);
|
||||
return json(res, 200, router ? publicRouter(router) : { message: 'Deleted' });
|
||||
}
|
||||
if (pathname === '/api/settings') { if (req.method === 'PUT') Object.assign(state.settings, body); return json(res, 200, state.settings); }
|
||||
if (pathname === '/api/settings/scheduler-status') return json(res, 200, { timezone: 'Europe/Warsaw', running: true, jobs: [{ key: 'auto_export', label: 'settings.schedulerAutoExportLabel', enabled: true, valid: true, cron: state.settings.export_cron, description: 'settings.schedulerCronDescription', description_params: { description: state.settings.export_cron }, next_runs: [now] }] });
|
||||
if (pathname === '/api/settings/reveal-ssh-key') return body.password === 'test-password' ? json(res, 200, { global_ssh_key: 'FAKE-TEST-KEY-NOT-REAL' }) : json(res, 400, { detail: 'Invalid password' });
|
||||
if (pathname === '/api/settings/test-email' || pathname === '/api/settings/test-pushover') return json(res, 200, { message: 'Test notification accepted by fixture' });
|
||||
if (pathname === '/api/logs') return json(res, 200, [{ id: 1, message: 'Backup completed', timestamp: now }, { id: 2, message: '<script>window.hacked=true</script>', timestamp: now }]);
|
||||
if (pathname.startsWith('/api/logs/older-than/')) return json(res, 200, { message: 'Deleted' });
|
||||
if (pathname === '/api/backups') return json(res, 200, state.files.filter(f => (!url.searchParams.get('backup_type') || f.backup_type === url.searchParams.get('backup_type')) && (!url.searchParams.get('router_id') || f.router_id === Number(url.searchParams.get('router_id')))));
|
||||
if ((match = pathname.match(/^\/api\/backups\/router\/(\d+)$/))) return json(res, 200, state.files.filter(f => f.router_id === Number(match[1])));
|
||||
if ((match = pathname.match(/^\/api\/backups\/router\/(\d+)\/(export|binary)$/))) { const id = state.files.length + 1; const file = { id, router_id: Number(match[1]), router_name: 'R1-main', device_type: 'routeros', file_name: `new-${id}.rsc`, backup_type: match[2], checksum: 'b'.repeat(64), file_size: 100, created_at: now }; state.files.push(file); return json(res, 200, file); }
|
||||
if (/^\/api\/backups\/router\/\d+\/upload\/\d+$/.test(pathname)) return json(res, 200, { message: 'Uploaded to isolated fixture only' });
|
||||
if (pathname === '/api/backups/routers/export-all' || pathname === '/api/backups/routers/binary-all') return json(res, 200, [{ router_id: 1, router_name: 'R1-main', status: 'ok' }, { router_id: 2, router_name: 'R2', status: 'error', error: 'Fixture test of partial failure' }]);
|
||||
if ((match = pathname.match(/^\/api\/backups\/(\d+)\/view$/))) return json(res, 200, { content: '/system identity\nset name="<img src=x onerror=alert(1)>"\n', backup: state.files.find(f => f.id === Number(match[1])) });
|
||||
if ((match = pathname.match(/^\/api\/backups\/(\d+)\/diff\/(\d+)(\/html)?$/))) {
|
||||
if (state.failNextDiff) { state.failNextDiff = false; return json(res, 502, { detail: 'Fixture diff failure' }); }
|
||||
if (match[3]) { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end('<pre>escaped fixture</pre>'); return; }
|
||||
return json(res, 200, { left_backup_id: Number(match[1]), right_backup_id: Number(match[2]), left_file_name: 'old.rsc', right_file_name: 'new.rsc', diff_text: '--- old\n+++ new\n-old\n+new', stats: { added: 1, removed: 0, modified: 1, context: 1 }, lines: [{ type: 'context', left_number: 1, right_number: 1, left_text: '/system identity', right_text: '/system identity' }, { type: 'modified', left_number: 2, right_number: 2, left_text: 'set name="old"', right_text: 'set name="new"' }, { type: 'added', left_number: null, right_number: 3, left_text: '', right_text: '<img src=x>' }] });
|
||||
}
|
||||
if (pathname.endsWith('/download') || pathname === '/api/backups/bulk' && body.action === 'download') { res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="fixture-backup.rsc"' }); res.end('fixture backup'); return; }
|
||||
if (pathname === '/api/backups/bulk' && body.action === 'delete') { state.files = state.files.filter(f => !body.backup_ids.includes(f.id)); return json(res, 200, { message: 'Deleted' }); }
|
||||
if ((match = pathname.match(/^\/api\/backups\/(\d+)$/)) && req.method === 'DELETE') { state.files = state.files.filter(f => f.id !== Number(match[1])); return json(res, 200, { message: 'Deleted' }); }
|
||||
return json(res, 404, { detail: `Unknown fixture endpoint ${req.method} ${pathname}` });
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
||||
const origin = `http://127.0.0.1:${server.address().port}`;
|
||||
const browser = await chromium.launch({ headless: true, args: ['--no-sandbox'], ...(process.env.MBS_CHROMIUM ? { executablePath: process.env.MBS_CHROMIUM } : {}) });
|
||||
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, acceptDownloads: true });
|
||||
const page = await context.newPage();
|
||||
const errors = []; page.on('pageerror', error => errors.push(error.message));
|
||||
page.on('dialog', dialog => { errors.push(`Unexpected native dialog: ${dialog.message()}`); dialog.dismiss(); });
|
||||
const go = async route => { await page.evaluate(route => window.MBS.navigate(route), route); await page.locator('.loading').waitFor({ state: 'detached' }); };
|
||||
try {
|
||||
await page.goto(`${origin}/register`);
|
||||
await page.locator('#login-form').waitFor();
|
||||
assert.equal(new URL(page.url()).pathname, '/login');
|
||||
assert.equal(await page.evaluate(() => window.MBS.config.allowRegistration), false);
|
||||
assert.equal(await page.locator('a[href="/register"]').count(), 0);
|
||||
assert.equal(await page.locator('.auth-layout header, .auth-layout svg').count(), 0);
|
||||
const assertFlatLogin = async () => {
|
||||
const gradients = await page.evaluate(() => [document.body, ...document.querySelectorAll('.auth-layout, .auth-layout *')].filter(el => getComputedStyle(el).backgroundImage.includes('gradient')).map(el => el.className));
|
||||
assert.deepEqual(gradients, [], 'Login must use solid backgrounds, including the submit button');
|
||||
assert.equal(await page.locator('.auth-layout header, .auth-layout svg').count(), 0);
|
||||
};
|
||||
await assertFlatLogin();
|
||||
if (process.env.MBS_SCREENSHOT_DIR) { fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'login.png'), fullPage: true, animations: 'disabled' }); }
|
||||
await page.click('[data-action=theme]'); assert.equal(await page.getAttribute('html', 'data-theme'), 'light'); await assertFlatLogin();
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'login-light.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.click('[data-action=theme]');
|
||||
await page.selectOption('[data-language]', 'en'); assert.equal(await page.getAttribute('html', 'lang'), 'en'); await page.selectOption('[data-language]', 'pl');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'login-mobile.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
assert.equal(state.calls.filter(call => call.path === '/api/auth/register').length, 0);
|
||||
state.allowRegistration = true;
|
||||
await page.goto(`${origin}/login`); await page.locator('#login-form').waitFor();
|
||||
assert.equal(await page.evaluate(() => window.MBS.config.allowRegistration), true);
|
||||
await page.click('a[href="/register"]'); await page.locator('#register-form').waitFor();
|
||||
await page.fill('#f-username', 'qa_registered'); await page.fill('#f-password', 'register-test'); await page.fill('#f-confirm_password', 'mismatch');
|
||||
await page.click('#register-form button[type=submit]'); await page.locator('#register-form .form-error').waitFor();
|
||||
assert.equal(state.calls.filter(call => call.path === '/api/auth/register').length, 0);
|
||||
await page.fill('#f-confirm_password', 'register-test'); await page.click('#register-form button[type=submit]'); await page.locator('#login-form').waitFor();
|
||||
assert.equal(state.calls.filter(call => call.path === '/api/auth/register').length, 1);
|
||||
await page.fill('#f-username', 'qa_user'); await page.fill('#f-password', 'incorrect'); await page.click('#login-form button[type=submit]');
|
||||
await page.locator('#login-form .form-error').waitFor(); assert.match(await page.locator('#login-form .form-error').textContent(), /Invalid credentials/);
|
||||
await page.fill('#f-password', 'test-password'); await page.click('#login-form button[type=submit]');
|
||||
await page.locator('h1').filter({ hasText: 'Dashboard' }).waitFor();
|
||||
assert.equal(await page.locator('.metric').count(), 4);
|
||||
assert.equal(await page.locator('.metric svg, .metric-icon').count(), 0);
|
||||
assert.equal(await page.locator('.topbar .brand svg, .topbar .brand-sub').count(), 0);
|
||||
assert.equal(await page.locator('.topbar .brand').textContent(), 'MikroTik Backup System');
|
||||
assert.equal(await page.locator('.nav a[href="/change-password"]').count(), 0);
|
||||
assert.equal(await page.locator('.nav a[href="/schedule"]').count(), 0);
|
||||
assert.ok(await page.locator('.nav svg').count() > 0, 'Icons outside the four metric cards stay available');
|
||||
assert.equal(await page.locator('.api-docs-link').getAttribute('href'), '/docs');
|
||||
assert.equal(await page.locator('.api-docs-link').getAttribute('data-nav'), null);
|
||||
assert.ok(!/monolit|\brust\b/i.test(await page.locator('.sidebar-footer').textContent()));
|
||||
const languageColors = await page.evaluate(() => {
|
||||
const select = document.querySelector('.topbar .language-select'), option = select.options[0];
|
||||
return { select: getComputedStyle(select).backgroundColor, option: getComputedStyle(option).backgroundColor, bar: getComputedStyle(document.querySelector('.topbar')).backgroundColor, scheme: getComputedStyle(select).colorScheme };
|
||||
});
|
||||
assert.equal(languageColors.select, languageColors.bar);
|
||||
assert.equal(languageColors.option, languageColors.bar);
|
||||
assert.match(languageColors.scheme, /dark/);
|
||||
assert.equal(await page.locator('main img').count(), 0);
|
||||
assert.match(await page.locator('.activity-list').textContent(), /<img src=x/);
|
||||
console.log('PASS registration disabled/enabled, flat login, login errors, session, minimal brand/cards and stored XSS escaping');
|
||||
|
||||
await go('/devices'); await page.click('[data-action="new-device"]');
|
||||
await page.fill('dialog #f-name', 'R2-office'); await page.fill('dialog #f-host', '127.0.0.2');
|
||||
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
await page.getByRole('link', { name: 'R2-office', exact: true }).waitFor();
|
||||
await page.click('[data-action="edit-device"][data-id="2"]'); await page.fill('dialog #f-name', 'R2-renamed');
|
||||
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
const update = state.calls.find(c => c.method === 'PUT' && c.path === '/api/routers/2');
|
||||
assert.equal(Object.hasOwn(update.body, 'ssh_password'), false);
|
||||
assert.equal(Object.hasOwn(update.body, 'ssh_key'), false);
|
||||
await go('/devices/1');
|
||||
assert.equal(await page.locator('.device-heading svg, .device-heading .metric-icon').count(), 0);
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'device-detail.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.click('[data-detail-tab=binary]');
|
||||
assert.equal(await page.locator('[data-action=compare-selected], [data-action=diff-latest]').count(), 0);
|
||||
await page.click('[data-detail-tab="policy"]');
|
||||
await page.check('[name=disable_all_backups]'); await page.click('#device-policy-form button[type=submit]');
|
||||
await page.waitForFunction(() => window.MBS.state.cache.device.disable_all_backups === true);
|
||||
assert.equal(state.routers[0].disable_binary_backups, true);
|
||||
await page.uncheck('[name=disable_all_backups]'); await page.uncheck('[name=disable_export_backups]'); await page.uncheck('[name=disable_binary_backups]');
|
||||
await page.click('#device-policy-form button[type=submit]');
|
||||
await page.waitForFunction(() => window.MBS.state.cache.device.disable_all_backups === false);
|
||||
console.log('PASS device creation, edit, secret preservation and policy');
|
||||
|
||||
await go('/files');
|
||||
assert.equal(await page.locator('[data-action=diff-latest][data-id="3"]').count(), 0);
|
||||
await page.check('[data-select-backup="3"]'); assert.equal(await page.locator('[data-action=compare-selected]').isVisible(), false);
|
||||
await page.check('[data-select-backup="1"]'); assert.equal(await page.locator('[data-action=compare-selected]').isVisible(), false);
|
||||
await page.uncheck('[data-select-backup="3"]'); await page.uncheck('[data-select-backup="1"]');
|
||||
await page.click('[data-action=preview][data-id="1"]');
|
||||
await page.locator('dialog pre').waitFor(); assert.match(await page.locator('dialog pre').textContent(), /<img src=x/);
|
||||
assert.equal(await page.locator('dialog img').count(), 0); await page.click('dialog [data-action=close-dialog]');
|
||||
await page.click('[data-action=diff-latest][data-id="1"]'); await page.locator('.diff-split').waitFor(); await go('/files');
|
||||
await page.check('[data-select-backup="1"]'); await page.check('[data-select-backup="2"]'); await page.click('[data-action=compare-selected]');
|
||||
await page.locator('.diff-line').first().waitFor();
|
||||
await page.fill('#diff-search', 'new'); await page.locator('.diff-line mark').waitFor();
|
||||
await page.click('[data-diff-mode=unified]'); await page.locator('.diff-unified').waitFor();
|
||||
assert.match(await page.locator('.diff-unified').textContent(), /\+new/);
|
||||
await page.click('[data-diff-mode=split]'); assert.equal(await page.locator('#diff-result img').count(), 0);
|
||||
const invalidPairMessage = await page.evaluate(() => window.MBS.t('ui.selectPair'));
|
||||
await page.selectOption('#diff-form [name=left]', '2'); await page.selectOption('#diff-form [name=right]', '2');
|
||||
await page.click('#diff-form button[type=submit]'); await page.locator('#toast-region').getByText(invalidPairMessage, { exact: true }).waitFor();
|
||||
assert.equal(await page.locator('#diff-form .form-error').count(), 0);
|
||||
await page.selectOption('#diff-form [name=left]', '1');
|
||||
state.failNextDiff = true;
|
||||
await page.click('#diff-form button[type=submit]'); await page.locator('#toast-region').getByText('Fixture diff failure', { exact: true }).waitFor();
|
||||
assert.equal(await page.locator('#diff-form .form-error').count(), 0);
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'diff-toast.png'), fullPage: true, animations: 'disabled' });
|
||||
console.log('PASS RSC-only actions, binary bulk/detail exclusions, diff search/views and toast-only validation/API errors');
|
||||
|
||||
await go('/files'); const download = page.waitForEvent('download'); await page.click('[data-action=download][data-id="1"]'); assert.equal((await download).suggestedFilename(), 'fixture-backup.rsc');
|
||||
await page.check('[data-select-backup="1"]'); await page.check('[data-select-backup="2"]');
|
||||
await page.click('[data-action=bulk-delete]'); await page.locator('#accept-confirm').waitFor();
|
||||
assert.equal(state.files.length, 3); await page.click('#accept-confirm');
|
||||
await page.waitForFunction(() => window.MBS.state.cache.files.length === 1);
|
||||
await page.click('[data-action=upload][data-id="3"]');
|
||||
const uploadResponse = page.waitForResponse(response => response.url().endsWith('/api/backups/router/1/upload/3'));
|
||||
await page.click('#accept-confirm'); await uploadResponse;
|
||||
assert.ok(state.calls.some(c => c.path === '/api/backups/router/1/upload/3'));
|
||||
await page.click('[data-action=new-backup]'); await page.locator('#backup-form').waitFor();
|
||||
await page.selectOption('#backup-form [name=router_id]', '1'); await page.selectOption('#backup-form [name=backup_type]', 'export');
|
||||
await page.click('#backup-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
await page.waitForFunction(() => window.MBS.state.cache.files.length === 2);
|
||||
console.log('PASS authenticated download, confirmed bulk deletion and binary upload');
|
||||
|
||||
await go('/devices'); await page.click('[data-action=new-device]');
|
||||
await page.fill('#device-form [name=name]', 'SW1-global'); await page.fill('#device-form [name=host]', '192.0.2.10');
|
||||
await page.selectOption('#device-form [name=device_type]', 'switchos');
|
||||
assert.equal(await page.inputValue('[name=use_global_switchos_credentials]'), 'true');
|
||||
assert.equal(await page.locator('#device-own-credentials').isVisible(), false);
|
||||
assert.equal(await page.locator('#device-form [name=ssh_password]').isDisabled(), true);
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'switchos-global-form.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
const switchCreate = state.calls.filter(call => call.method === 'POST' && call.path === '/api/routers').at(-1).body;
|
||||
assert.equal(switchCreate.use_global_switchos_credentials, true);
|
||||
for (const field of ['ssh_user', 'ssh_password', 'ssh_key', 'clear_ssh_password', 'clear_ssh_key']) assert.equal(Object.hasOwn(switchCreate, field), false);
|
||||
const sw = state.routers.find(router => router.name === 'SW1-global');
|
||||
state.files.push({ id: 40, router_id: sw.id, router_name: sw.name, device_type: 'switchos', file_name: 'SW1.swb', backup_type: 'binary', file_size: 88, created_at: now });
|
||||
await go(`/devices/${sw.id}`);
|
||||
assert.equal(await page.locator('[data-detail-tab=export], [data-action=device-export]').count(), 0);
|
||||
await page.click('[data-detail-tab=policy]'); assert.equal(await page.locator('[name=disable_export_backups]').count(), 0);
|
||||
await page.click('[data-detail-tab=binary]');
|
||||
assert.equal(await page.locator('[data-action=compare-selected], [data-action=diff-latest], [data-action=upload]').count(), 0);
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'switchos-binary.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.click(`[data-action=edit-device][data-id="${sw.id}"]`);
|
||||
await page.selectOption('[name=use_global_switchos_credentials]', 'false');
|
||||
await page.fill('#device-form [name=ssh_user]', 'own-admin'); await page.fill('#device-form [name=ssh_password]', 'fixture-own-password');
|
||||
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
let switchUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${sw.id}`).at(-1).body;
|
||||
assert.equal(switchUpdate.use_global_switchos_credentials, false); assert.equal(switchUpdate.ssh_password, 'fixture-own-password');
|
||||
await page.click(`[data-action=edit-device][data-id="${sw.id}"]`);
|
||||
assert.equal(await page.inputValue('#device-form [name=ssh_password]'), '');
|
||||
await page.selectOption('[name=use_global_switchos_credentials]', 'true');
|
||||
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
switchUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${sw.id}`).at(-1).body;
|
||||
assert.equal(switchUpdate.use_global_switchos_credentials, true);
|
||||
for (const field of ['ssh_user', 'ssh_password', 'clear_ssh_password', 'clear_ssh_key']) assert.equal(Object.hasOwn(switchUpdate, field), false);
|
||||
await page.click(`[data-action=edit-device][data-id="${sw.id}"]`); await page.selectOption('[name=use_global_switchos_credentials]', 'false');
|
||||
assert.equal(await page.inputValue('#device-form [name=ssh_user]'), 'own-admin');
|
||||
assert.equal(await page.inputValue('#device-form [name=ssh_password]'), '');
|
||||
assert.equal(await page.getAttribute('#device-form [name=ssh_password]', 'placeholder'), await page.evaluate(() => window.MBS.t('ui.kept')));
|
||||
await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
switchUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${sw.id}`).at(-1).body;
|
||||
assert.equal(switchUpdate.use_global_switchos_credentials, false); assert.equal(Object.hasOwn(switchUpdate, 'ssh_password'), false);
|
||||
assert.equal(state.routers.find(router => router.id === sw.id)._ownPassword, 'fixture-own-password');
|
||||
const legacyId = Math.max(...state.routers.map(router => router.id)) + 1;
|
||||
state.routers.push({ id: legacyId, name: 'SW-legacy', device_type: 'switchos', host: '192.0.2.11', port: 80, ssh_user: 'legacy-admin', _ownPassword: 'fixture-legacy-password', use_global_switchos_credentials: null, created_at: now });
|
||||
await go(`/devices/${legacyId}`); await page.click(`[data-action=edit-device][data-id="${legacyId}"]`);
|
||||
assert.equal(await page.inputValue('[name=use_global_switchos_credentials]'), '');
|
||||
await page.fill('#device-form [name=name]', 'SW-legacy-renamed'); await page.click('#device-form button[type=submit]'); await page.locator('dialog').waitFor({ state: 'detached' });
|
||||
const legacyUpdate = state.calls.filter(call => call.method === 'PUT' && call.path === `/api/routers/${legacyId}`).at(-1).body;
|
||||
assert.equal(Object.hasOwn(legacyUpdate, 'use_global_switchos_credentials'), false);
|
||||
assert.equal(Object.hasOwn(legacyUpdate, 'ssh_password'), false);
|
||||
await go('/files'); await page.click('[data-action=new-backup]'); await page.selectOption('#backup-form [name=router_id]', String(sw.id));
|
||||
assert.equal(await page.locator('#backup-form [name=backup_type] option[value=export]').count(), 0);
|
||||
assert.equal(await page.inputValue('#backup-form [name=backup_type]'), 'binary');
|
||||
await page.click('dialog [data-action=close-dialog]');
|
||||
await go('/diff-configs');
|
||||
assert.equal(await page.locator(`#diff-form [name=router_id] option[value="${sw.id}"]`).count(), 0);
|
||||
assert.equal(await page.locator('#diff-form [name=left] option[value="40"], #diff-form [name=right] option[value="40"]').count(), 0);
|
||||
console.log('PASS SwitchOS global/own/legacy credential payloads, preserved own secrets and hidden unsupported actions');
|
||||
|
||||
await go('/schedule');
|
||||
assert.equal(new URL(page.url()).pathname, '/settings');
|
||||
assert.equal(new URL(page.url()).searchParams.get('tab'), 'automation');
|
||||
assert.equal(await page.locator('.settings-layout .card:visible').count(), 0);
|
||||
assert.equal(await page.locator('.scheduler-status .card').count(), 0);
|
||||
assert.equal(await page.locator('[data-schedule]').count(), 3);
|
||||
assert.ok(await page.evaluate(() => [...document.querySelectorAll('[data-schedule]')].every(el => {
|
||||
const css = getComputedStyle(el); return css.backgroundColor === 'rgba(0, 0, 0, 0)' && css.borderLeftWidth === '0px' && css.borderRightWidth === '0px' && css.borderTopWidth === '0px';
|
||||
})));
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'automation.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'automation-mobile.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.setViewportSize({ width: 1440, height: 1000 });
|
||||
assert.equal(await page.inputValue('[name=export_weekday]'), 'tue');
|
||||
await page.selectOption('[name=export_weekday]', 'mon'); await page.fill('[name=export_time]', '04:30');
|
||||
await page.selectOption('[name=binary_mode]', 'daily'); await page.fill('[name=binary_time]', '05:15');
|
||||
await page.selectOption('[name=retention_mode]', 'custom'); await page.fill('[name=retention_custom]', '17 */6 * * mon-fri');
|
||||
await page.click('#settings-form button[type=submit]');
|
||||
await page.waitForFunction(() => window.MBS.state.cache.settings.export_cron === '30 4 * * mon');
|
||||
const saved = state.calls.filter(c => c.path === '/api/settings' && c.method === 'PUT').at(-1).body;
|
||||
assert.equal(Object.hasOwn(saved, 'smtp_password'), false); assert.equal(Object.hasOwn(saved, 'global_ssh_key'), false);
|
||||
assert.equal(saved.binary_cron, '15 5 * * *'); assert.equal(saved.retention_cron, '17 */6 * * mon-fri');
|
||||
await go('/settings?tab=schedule'); assert.equal(new URL(page.url()).searchParams.get('tab'), 'automation');
|
||||
assert.equal(await page.inputValue('[name=binary_mode]'), 'daily'); assert.equal(await page.inputValue('[name=retention_mode]'), 'custom');
|
||||
await page.click('[data-settings-tab=smtp]'); await page.check('[name=clear_smtp_password]'); await page.click('#settings-form button[type=submit]');
|
||||
await page.waitForFunction(() => window.MBS.state.cache.settings.clear_smtp_password === true);
|
||||
assert.equal(state.calls.filter(call => call.path === '/api/settings' && call.method === 'PUT').at(-1).body.clear_smtp_password, true);
|
||||
const emailResponse = page.waitForResponse(response => response.url().endsWith('/api/settings/test-email'));
|
||||
await page.click('[data-action=test-email]'); await emailResponse;
|
||||
await page.click('[data-settings-tab=pushover]');
|
||||
const pushResponse = page.waitForResponse(response => response.url().endsWith('/api/settings/test-pushover'));
|
||||
await page.click('[data-action=test-pushover]'); await pushResponse;
|
||||
await page.click('[data-settings-tab=credentials]'); await page.click('[data-action=reveal-ssh]');
|
||||
await page.fill('dialog [name=password]', 'test-password'); await page.click('#reveal-form button[type=submit]');
|
||||
await page.locator('.secret-preview').waitFor(); assert.equal(await page.locator('.secret-preview').textContent(), 'FAKE-TEST-KEY-NOT-REAL');
|
||||
await page.click('dialog [data-action=close-dialog]'); await page.locator('dialog').waitFor({ state: 'detached' }); assert.equal(await page.locator('body').textContent().then(text => text.includes('FAKE-TEST-KEY-NOT-REAL')), false);
|
||||
await page.selectOption('#preferences-form [name=preferred_language]', 'en'); await page.selectOption('#preferences-form [name=preferred_font]', 'hack'); await page.click('#preferences-form button[type=submit]');
|
||||
await page.waitForFunction(() => document.documentElement.lang === 'en'); assert.equal(await page.getAttribute('html', 'data-font'), 'hack');
|
||||
console.log('PASS schedule weekdays, masked secrets, reveal lifecycle, language and font preferences');
|
||||
|
||||
await go('/logs'); assert.equal(await page.evaluate(() => window.hacked), undefined);
|
||||
const deleteBox = await page.locator('#logs-cleanup button').boundingBox(), daysBox = await page.locator('#logs-cleanup [name=days]').boundingBox(), searchBox = await page.locator('#logs-search').boundingBox();
|
||||
assert.ok(Math.abs(deleteBox.y - daysBox.y) <= 1 && Math.abs(deleteBox.y - searchBox.y) <= 1, 'Delete must align with the filter inputs');
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'logs.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.fill('#logs-search', 'Backup'); await page.waitForTimeout(250); assert.equal(await page.locator('#logs-table tbody tr').count(), 1);
|
||||
await go('/settings'); await page.click('[data-settings-tab=account]'); await page.locator('#password-form').waitFor({ state: 'visible' });
|
||||
assert.equal(new URL(page.url()).searchParams.get('tab'), 'account');
|
||||
assert.equal(await page.locator('#settings-form').isVisible(), false);
|
||||
assert.equal(await page.locator('#preferences-form').isVisible(), true);
|
||||
await go('/change-password');
|
||||
assert.equal(new URL(page.url()).pathname, '/settings');
|
||||
assert.equal(new URL(page.url()).searchParams.get('tab'), 'account');
|
||||
assert.equal(await page.locator('[data-settings-tab=account]').getAttribute('aria-selected'), 'true');
|
||||
await page.reload(); await page.locator('#password-form').waitFor({ state: 'visible' });
|
||||
if (process.env.MBS_SCREENSHOT_DIR) await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'settings-account.png'), fullPage: true, animations: 'disabled' });
|
||||
await page.fill('[name=current_password]', 'test-password'); await page.fill('[name=new_password]', 'new-pass-123'); await page.fill('[name=confirm_password]', 'wrong');
|
||||
await page.click('#password-form button[type=submit]'); await page.locator('#password-form .form-error').waitFor();
|
||||
assert.equal(state.calls.filter(c => c.path === '/api/auth/change-password').length, 0);
|
||||
await page.fill('[name=confirm_password]', 'new-pass-123'); await page.click('#password-form button[type=submit]');
|
||||
await page.waitForFunction(() => document.querySelector('[name=new_password]').value === '');
|
||||
assert.equal(state.calls.filter(c => c.path === '/api/auth/change-password').length, 1);
|
||||
await page.click('[data-settings-tab=general]'); assert.equal(await page.locator('#password-form').isVisible(), false); assert.equal(await page.locator('#settings-form').isVisible(), true);
|
||||
console.log('PASS log XSS/search, account tab, legacy route, refresh and password matching/submission');
|
||||
|
||||
await go('/'); await page.click('[data-action=batch-export]'); await page.click('#accept-confirm');
|
||||
await page.locator('dialog .notice.warning').waitFor(); assert.match(await page.locator('dialog').textContent(), /Fixture test of partial failure/); await page.click('dialog [data-action=close-dialog]');
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.click('[data-action=sidebar]'); assert.equal(await page.locator('.layout.mobile-open').count(), 1); await page.click('[data-action=sidebar-close]');
|
||||
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1));
|
||||
const logoutBox = await page.locator('[data-action=logout]').boundingBox();
|
||||
assert.ok(logoutBox.y >= 0 && logoutBox.y + logoutBox.height <= 54, 'Mobile header controls must stay on one row');
|
||||
await page.click('[data-action=theme]'); assert.equal(await page.getAttribute('html', 'data-theme'), 'light');
|
||||
if (process.env.MBS_SCREENSHOT_DIR) { fs.mkdirSync(process.env.MBS_SCREENSHOT_DIR, { recursive: true }); await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'mobile.png'), fullPage: true, animations: 'disabled' }); await page.setViewportSize({ width: 1440, height: 1000 }); await page.click('[data-action=theme]'); await page.screenshot({ path: path.join(process.env.MBS_SCREENSHOT_DIR, 'dashboard.png'), fullPage: true, animations: 'disabled' }); }
|
||||
await page.click('[data-action=logout]'); await page.locator('#login-form').waitFor();
|
||||
assert.equal(await page.evaluate(() => sessionStorage.getItem('mbs_token')), null);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS partial failure report, responsive layout, themes and logout');
|
||||
console.log(`PASS browser smoke: ${state.calls.length} local API fixture requests; no page errors`);
|
||||
} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; server.close(); });
|
||||
Reference in new issue
Block a user