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

274 lines
50 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 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);