Files
MarekWo 05ec5fa13e feat(https): optional HTTPS front end via Nginx Proxy Manager
Adds an opt-in HTTPS layer without touching anything for existing installs.

The proxy is a service in docker-compose.yml behind the "https" Compose
profile, activated with COMPOSE_PROFILES=https in .env. That keeps the whole
setup inside files git owns while the switch lives in a file it ignores --
important because scripts/update.sh runs `git pull`, so any user edit to a
tracked compose file would break the next update with a merge conflict.
mcupdate needs no change: compose reads COMPOSE_PROFILES from .env itself.

Both services share the project's default network, so the proxy forwards to
http://mc-webui:5000 internally; MC_BIND_ADDRESS lets the plain-HTTP port be
restricted to loopback once HTTPS works, and MC_TRUST_PROXY turns on ProxyFix
so the app sees the real client address and scheme.

Also fixes copying over plain HTTP. navigator.clipboard only exists in a
secure context, and 9 call sites across 5 entry points used it with no
fallback, so copy buttons silently did nothing on a LAN address. They now
share clipboard-utils.js, loaded via _head_i18n.html (the one include every
entry point already has), which falls back to execCommand.

Verified locally against the real stack: self-signed cert uploaded to NPM,
proxy host created, app served over HTTPS with socket.io upgrading to a real
wss:// WebSocket (transport "websocket"), and the clipboard fallback checked
end-to-end by pasting back what it copied over http://.

Two findings from that run are documented in docs/https-setup.md:
NPM 2.15 dropped the default admin@example.com account for a setup wizard,
and its default 443 server now refuses connections without SNI -- which is
every connection made to a bare IP address. docker/npm-default-site.conf is
the opt-in way around that, mounted writable (a :ro mount aborts s6 init and
leaves nginx not listening at all).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 17:58:03 +02:00

1862 lines
74 KiB
JavaScript
Raw Permalink 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.
// Repeater Management panel (one repeater, after login)
// Loaded as /repeaters/manage?pubkey=<64 hex> inside the My Repeaters iframe.
// ================================================================
// UI settings + toast (same behavior as repeaters.js)
// ================================================================
const RPT_UI_SETTINGS_DEFAULTS = {
toast_timeout_sec: 2,
toast_no_autoclose: false,
toast_position: 'top-left'
};
const RPT_TOAST_POSITION_CLASSES = {
'top-left': ['top-0', 'start-0'],
'top-right': ['top-0', 'end-0'],
'bottom-left': ['bottom-0', 'start-0'],
'bottom-right': ['bottom-0', 'end-0'],
'center': ['top-50', 'start-50', 'translate-middle']
};
const RPT_ALL_POSITION_CLASSES = ['top-0', 'top-50', 'start-0', 'start-50', 'bottom-0', 'end-0', 'translate-middle'];
window.uiSettingsCache = window.uiSettingsCache || { ...RPT_UI_SETTINGS_DEFAULTS };
function applyToastPosition(position) {
const classes = RPT_TOAST_POSITION_CLASSES[position] || RPT_TOAST_POSITION_CLASSES['top-left'];
document.querySelectorAll('[data-toast-container]').forEach(el => {
RPT_ALL_POSITION_CLASSES.forEach(c => el.classList.remove(c));
classes.forEach(c => el.classList.add(c));
});
}
async function loadUiSettings() {
try {
const resp = await fetch('/api/ui/settings');
if (resp.ok) {
const data = await resp.json();
window.uiSettingsCache = { ...RPT_UI_SETTINGS_DEFAULTS, ...data };
applyToastPosition(window.uiSettingsCache.toast_position);
}
} catch (e) {
console.error('Failed to load UI settings:', e);
}
}
function showNotification(message, type = 'info') {
const toastEl = document.getElementById('notificationToast');
if (!toastEl) return;
const toastBody = toastEl.querySelector('.toast-body');
if (toastBody) {
toastBody.textContent = message;
}
const toastHeader = toastEl.querySelector('.toast-header');
if (toastHeader) {
toastHeader.className = 'toast-header';
if (type === 'success') {
toastHeader.classList.add('bg-success', 'text-white');
} else if (type === 'danger') {
toastHeader.classList.add('bg-danger', 'text-white');
} else if (type === 'warning') {
toastHeader.classList.add('bg-warning');
}
}
const cfg = window.uiSettingsCache || {};
const noAutoclose = !!cfg.toast_no_autoclose;
const timeoutSec = parseFloat(cfg.toast_timeout_sec);
const delay = isFinite(timeoutSec) && timeoutSec > 0 ? Math.round(timeoutSec * 1000) : 2000;
const toast = new bootstrap.Toast(toastEl, {
autohide: !noAutoclose,
delay: delay
});
toast.show();
}
function esc(s) {
return String(s ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
// ================================================================
// Tools configuration
// ================================================================
// title/desc hold catalog KEYS, resolved with t() at render time.
const TOOLS = [
{ key: 'status', icon: 'bi-bar-chart-line', title: 'rptmgmt.tools.status',
desc: 'rptmgmt.tools.status_desc', adminOnly: false },
{ key: 'telemetry', icon: 'bi-activity', title: 'rptmgmt.tools.telemetry',
desc: 'rptmgmt.tools.telemetry_desc', adminOnly: false },
{ key: 'neighbors', icon: 'bi-people', title: 'rptmgmt.tools.neighbors',
desc: 'rptmgmt.tools.neighbors_desc', adminOnly: false },
{ key: 'cli', icon: 'bi-terminal', title: 'rptmgmt.tools.cli',
desc: 'rptmgmt.tools.cli_desc', adminOnly: true },
{ key: 'settings', icon: 'bi-gear', title: 'rptmgmt.tools.settings',
desc: 'rptmgmt.tools.settings_desc', adminOnly: true },
{ key: 'actions', icon: 'bi-lightning', title: 'rptmgmt.tools.actions',
desc: 'rptmgmt.tools.actions_desc', adminOnly: true },
];
// ================================================================
// State
// ================================================================
let _pubkey = null;
let _repeater = null; // merged entry from GET /api/repeaters/<pk>
let _session = null; // {logged_in, is_admin, ...}
let _passwordModal = null;
// ================================================================
// State screens
// ================================================================
function showLoading(text) {
document.getElementById('loadingState').style.display = '';
document.getElementById('loadingText').textContent = text || t('common.loading');
document.getElementById('errorState').style.display = 'none';
document.getElementById('panelContent').style.display = 'none';
}
function showError(text) {
document.getElementById('loadingState').style.display = 'none';
document.getElementById('errorState').style.display = '';
document.getElementById('errorText').textContent = text || t('rptmgmt.error_generic');
document.getElementById('panelContent').style.display = 'none';
}
function showPanel() {
document.getElementById('loadingState').style.display = 'none';
document.getElementById('errorState').style.display = 'none';
document.getElementById('panelContent').style.display = '';
document.getElementById('logoutBtn').classList.remove('d-none');
renderHeader();
renderTools();
showToolsGrid();
}
function goBackToList() {
window.location.href = '/repeaters';
}
// ================================================================
// Header + tools rendering
// ================================================================
function shortPubkey(pk) {
return `${pk.substring(0, 12)}${pk.substring(pk.length - 8)}`;
}
function renderHeader() {
const r = _repeater;
document.getElementById('rptName').textContent = r.name || r.public_key.substring(0, 12);
document.getElementById('rptPubkey').textContent = `<${shortPubkey(r.public_key)}>`;
document.getElementById('rptPath').textContent = r.path_or_mode || '—';
const loc = (r.adv_lat != null && r.adv_lon != null && (r.adv_lat !== 0 || r.adv_lon !== 0))
? `${r.adv_lat.toFixed(4)}, ${r.adv_lon.toFixed(4)}`
: '—';
document.getElementById('rptLocation').textContent = loc;
const badge = document.getElementById('roleBadge');
if (_session && _session.logged_in) {
const admin = !!_session.is_admin;
badge.textContent = admin ? 'ADMIN' : 'GUEST';
badge.className = 'badge ' + (admin ? 'bg-success' : 'bg-secondary');
} else {
badge.textContent = '';
badge.className = 'badge';
}
}
function renderTools() {
const row = document.getElementById('toolsRow');
row.innerHTML = '';
const isAdmin = !!(_session && _session.is_admin);
TOOLS.forEach(tool => {
const locked = tool.adminOnly && !isAdmin;
const col = document.createElement('div');
col.className = 'col-12 col-sm-6 col-lg-4';
col.innerHTML = `
<div class="tool-tile${locked ? ' disabled' : ''}" data-tool="${tool.key}"
${locked ? `title="${tHtml('rptmgmt.admin_required')}"` : ''}>
<div class="tool-icon ${tool.key}"><i class="bi ${tool.icon}"></i></div>
<div class="flex-grow-1" style="min-width: 0;">
<h6>${esc(t(tool.title))}${locked ? ' <i class="bi bi-lock-fill small text-muted"></i>' : ''}</h6>
<p class="tool-desc">${esc(t(tool.desc))}</p>
</div>
<i class="bi bi-chevron-right text-muted"></i>
</div>
`;
const tile = col.querySelector('.tool-tile');
tile.addEventListener('click', () => {
if (locked) {
showNotification(t('rptmgmt.toast.admin_required'), 'warning');
return;
}
openToolPane(tool);
});
row.appendChild(col);
});
}
// ================================================================
// Tool panes
// ================================================================
function showToolsGrid() {
document.getElementById('toolsGrid').style.display = '';
document.getElementById('toolPane').style.display = 'none';
}
function openToolPane(tool) {
document.getElementById('toolsGrid').style.display = 'none';
const pane = document.getElementById('toolPane');
pane.style.display = '';
const icon = document.getElementById('paneIcon');
icon.className = `tool-icon ${tool.key}`;
icon.style.width = '32px';
icon.style.height = '32px';
icon.style.fontSize = '1rem';
icon.innerHTML = `<i class="bi ${tool.icon}"></i>`;
document.getElementById('paneTitle').textContent = t(tool.title);
const body = document.getElementById('paneBody');
if (tool.key === 'status') {
renderStatusPane(body);
return;
}
if (tool.key === 'telemetry') {
renderTelemetryPane(body);
return;
}
if (tool.key === 'neighbors') {
renderNeighborsPane(body);
return;
}
if (tool.key === 'cli') {
renderCliPane(body);
return;
}
if (tool.key === 'settings') {
renderSettingsPane(body);
return;
}
if (tool.key === 'actions') {
renderActionsPane(body);
return;
}
body.innerHTML = `
<div class="text-center text-muted py-4">
<i class="bi ${tool.icon}" style="font-size: 2rem;"></i>
<p class="mt-2 mb-0">${tHtml('rptmgmt.tool_coming', { tool: t(tool.title) })}</p>
</div>
`;
}
// ================================================================
// Formatting helpers
// ================================================================
function fmtDuration(seconds) {
if (seconds == null || isNaN(seconds)) return '—';
seconds = Math.floor(seconds);
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
const parts = [];
if (d) parts.push(`${d}d`);
if (h || d) parts.push(`${h}h`);
parts.push(`${m}m`);
return parts.join(' ');
}
// Delegates to datetime-utils.js; no longer forces en-US thousands separators, so
// numbers follow the reader's locale. Kept as a declaration (not const) so it stays
// hoisted, like the implementation it replaced.
function fmtInt(n) {
return formatInt(n);
}
function batteryPercent(mv) {
if (mv == null || isNaN(mv)) return null;
// Simple linear LiPo estimate over 3.34.2 V.
const pct = ((mv / 1000) - 3.3) / (4.2 - 3.3) * 100;
return Math.max(0, Math.min(100, Math.round(pct)));
}
// ================================================================
// Status tool
// ================================================================
let _statusUpdatedAt = null;
let _statusTimer = null;
function renderStatusPane(body) {
body.innerHTML = `
<div class="d-flex align-items-center mb-2">
<span class="text-muted small flex-grow-1" id="statusUpdated"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="statusRefreshBtn" title="${tHtml('rptmgmt.status.refresh_title')}">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
</div>
<div id="statusContainer"></div>
`;
body.querySelector('#statusRefreshBtn').addEventListener('click', loadStatus);
loadStatus();
}
function setStatusUpdatedLabel() {
const el = document.getElementById('statusUpdated');
if (!el) return;
if (_statusTimer) { clearInterval(_statusTimer); _statusTimer = null; }
if (!_statusUpdatedAt) { el.textContent = ''; return; }
const tick = () => {
const label = document.getElementById('statusUpdated');
if (!label) { clearInterval(_statusTimer); _statusTimer = null; return; }
const secs = Math.floor((Date.now() - _statusUpdatedAt) / 1000);
if (secs < 2) label.textContent = t('rptmgmt.updated_just_now');
else if (secs < 60) label.textContent = t('rptmgmt.updated_ago', { time: `${secs}s` });
else label.textContent = t('rptmgmt.updated_ago', { time: fmtDuration(secs) });
};
tick();
_statusTimer = setInterval(tick, 5000);
}
async function loadStatus() {
const container = document.getElementById('statusContainer');
const btn = document.getElementById('statusRefreshBtn');
if (!container) return;
if (btn) btn.disabled = true;
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">${tHtml('rptmgmt.status.loading')}</p>
</div>
`;
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/status`);
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
if (btn) btn.disabled = false;
if (!data || !data.success) {
container.innerHTML = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.status_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="statusRetryBtn">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.try_again')}
</button>
</div>
`;
const retry = document.getElementById('statusRetryBtn');
if (retry) retry.addEventListener('click', loadStatus);
return;
}
renderStatusTable(container, data.data);
_statusUpdatedAt = Date.now();
setStatusUpdatedLabel();
loadClock();
}
function statusSection(title, rows) {
const body = rows.map(([label, value]) =>
`<tr><td class="text-muted">${esc(label)}</td><td class="text-end fw-medium">${value}</td></tr>`
).join('');
return `
<div class="text-muted text-uppercase small fw-bold mt-3 mb-1">${esc(title)}</div>
<table class="table table-sm mb-0"><tbody>${body}</tbody></table>
`;
}
function renderStatusTable(container, s) {
const bat = s.bat;
const pct = batteryPercent(bat);
const batStr = bat != null
? `${pct != null ? pct + '% / ' : ''}${(bat / 1000).toFixed(2)} V`
: '—';
const util = (s.airtime != null && s.rx_airtime != null && s.uptime)
? (((s.airtime + s.rx_airtime) / s.uptime) * 100).toFixed(2) + '%'
: '—';
// Row labels use t(), not tHtml(): statusSection() runs esc() over the
// title and every label, so a pre-escaped value would be escaped twice.
// "flood" / "direct" inside the values are protocol terms and stay
// English (see docs/translations.md).
const system = statusSection(t('rptmgmt.status.system'), [
[t('rptmgmt.status.battery'), batStr],
[t('rptmgmt.status.uptime'), fmtDuration(s.uptime)],
[t('rptmgmt.status.clock'), '<span id="statusClock" class="text-muted">…</span>'],
[t('rptmgmt.status.queue'), fmtInt(s.tx_queue_len)],
[t('rptmgmt.status.events'), fmtInt(s.full_evts)],
]);
const radio = statusSection(t('rptmgmt.status.radio'), [
[t('rptmgmt.status.last_rssi'), s.last_rssi != null ? `${s.last_rssi} dBm` : '—'],
[t('rptmgmt.status.last_snr'), s.last_snr != null ? `${s.last_snr} dB` : '—'],
[t('rptmgmt.status.noise'), s.noise_floor != null ? `${s.noise_floor} dBm` : '—'],
[t('rptmgmt.status.tx_airtime'), fmtDuration(s.airtime)],
[t('rptmgmt.status.rx_airtime'), fmtDuration(s.rx_airtime)],
]);
const packets = statusSection(t('rptmgmt.status.packets'), [
[t('rptmgmt.status.sent'), `${fmtInt(s.nb_sent)} <span class="text-muted small">(flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})</span>`],
[t('rptmgmt.status.received'), `${fmtInt(s.nb_recv)} <span class="text-muted small">(flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})</span>`],
[t('rptmgmt.status.duplicates'), `<span class="text-muted small">flood</span> ${fmtInt(s.flood_dups)} · <span class="text-muted small">direct</span> ${fmtInt(s.direct_dups)}`],
...(s.recv_errors != null ? [[t('rptmgmt.status.rx_errors'), fmtInt(s.recv_errors)]] : []),
[t('rptmgmt.status.utilization'), util],
]);
container.innerHTML = system + radio + packets;
}
// ================================================================
// Telemetry tool
// ================================================================
// Cayenne LPP type name -> {unit, decimals, icon}
const LPP_DISPLAY = {
'voltage': { unit: 'V', icon: 'bi-battery-half' },
'current': { unit: 'A', icon: 'bi-lightning-charge' },
'power': { unit: 'W', icon: 'bi-plug' },
'energy': { unit: 'kWh', icon: 'bi-plug-fill' },
'temperature': { unit: '°C', icon: 'bi-thermometer-half' },
'humidity': { unit: '%', icon: 'bi-droplet' },
'percentage': { unit: '%', icon: 'bi-percent' },
'barometer': { unit: 'hPa', icon: 'bi-speedometer2' },
'illuminance': { unit: 'lx', icon: 'bi-sun' },
'altitude': { unit: 'm', icon: 'bi-arrow-up-right' },
'distance': { unit: 'm', icon: 'bi-rulers' },
'frequency': { unit: 'Hz', icon: 'bi-soundwave' },
'concentration': { unit: 'ppm', icon: 'bi-cloud-haze' },
'load': { unit: 'kg', icon: 'bi-box' },
'direction': { unit: '°', icon: 'bi-compass' },
'gps': { unit: '', icon: 'bi-geo-alt' },
'digital input': { unit: '', icon: 'bi-toggle-on' },
'digital output': { unit: '', icon: 'bi-toggle-off' },
'analog input': { unit: '', icon: 'bi-sliders' },
'analog output': { unit: '', icon: 'bi-sliders' },
'generic sensor': { unit: '', icon: 'bi-cpu' },
'presence': { unit: '', icon: 'bi-person-check' },
'switch': { unit: '', icon: 'bi-toggle2-on' },
'time': { unit: '', icon: 'bi-clock' },
};
function fmtLppValue(type, value) {
if (value == null) return '—';
if (type === 'gps' && typeof value === 'object') {
// lib returns {latitude, longitude, altitude}; tolerate array form too
const lat = value.latitude ?? value[0];
const lon = value.longitude ?? value[1];
const alt = value.altitude ?? value[2];
if (lat == null || lon == null) return esc(JSON.stringify(value));
let s = `${Number(lat).toFixed(5)}, ${Number(lon).toFixed(5)}`;
if (alt != null) s += ` (${Number(alt).toFixed(0)} m)`;
return esc(s);
}
if (Array.isArray(value)) return esc(value.join(', '));
if (typeof value === 'object') return esc(JSON.stringify(value));
if (typeof value === 'number' && !Number.isInteger(value)) {
// Trim float noise, keep up to 3 decimals
return esc(String(Math.round(value * 1000) / 1000));
}
return esc(String(value));
}
let _telemetryUpdatedAt = null;
let _telemetryTimer = null;
function renderTelemetryPane(body) {
body.innerHTML = `
<div class="d-flex align-items-center mb-2">
<span class="text-muted small flex-grow-1" id="telemetryUpdated"></span>
<button type="button" class="btn btn-sm btn-outline-secondary" id="telemetryRefreshBtn" title="${tHtml('rptmgmt.tel.refresh_title')}">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
</div>
<div id="telemetryContainer"></div>
`;
body.querySelector('#telemetryRefreshBtn').addEventListener('click', loadTelemetry);
loadTelemetry();
}
function setTelemetryUpdatedLabel() {
if (_telemetryTimer) { clearInterval(_telemetryTimer); _telemetryTimer = null; }
if (!_telemetryUpdatedAt) return;
const tick = () => {
const label = document.getElementById('telemetryUpdated');
if (!label) { clearInterval(_telemetryTimer); _telemetryTimer = null; return; }
const secs = Math.floor((Date.now() - _telemetryUpdatedAt) / 1000);
if (secs < 2) label.textContent = t('rptmgmt.updated_just_now');
else if (secs < 60) label.textContent = t('rptmgmt.updated_ago', { time: `${secs}s` });
else label.textContent = t('rptmgmt.updated_ago', { time: fmtDuration(secs) });
};
tick();
_telemetryTimer = setInterval(tick, 5000);
}
async function loadTelemetry() {
const container = document.getElementById('telemetryContainer');
const btn = document.getElementById('telemetryRefreshBtn');
if (!container) return;
if (btn) btn.disabled = true;
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">${tHtml('rptmgmt.tel.loading')}<br>
<span class="small">${tHtml('rptmgmt.tel.loading_hint')}</span></p>
</div>
`;
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/telemetry`);
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
if (btn) btn.disabled = false;
if (!data || !data.success) {
container.innerHTML = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.telemetry_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="telemetryRetryBtn">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.try_again')}
</button>
</div>
`;
const retry = document.getElementById('telemetryRetryBtn');
if (retry) retry.addEventListener('click', loadTelemetry);
return;
}
renderTelemetryCards(container, data.lpp || []);
_telemetryUpdatedAt = Date.now();
setTelemetryUpdatedLabel();
}
function renderTelemetryCards(container, lpp) {
if (!lpp.length) {
container.innerHTML = `<div class="text-muted text-center py-4">${tHtml('rptmgmt.telemetry_none')}</div>`;
return;
}
// Group entries by channel, keep entry order inside a channel
const byChannel = new Map();
lpp.forEach(entry => {
const ch = entry.channel ?? 0;
if (!byChannel.has(ch)) byChannel.set(ch, []);
byChannel.get(ch).push(entry);
});
const channels = [...byChannel.keys()].sort((a, b) => a - b);
let html = '<div class="row g-3">';
channels.forEach(ch => {
const rows = byChannel.get(ch).map(entry => {
const disp = LPP_DISPLAY[entry.type] || { unit: '', icon: 'bi-activity' };
const label = esc(entry.type.charAt(0).toUpperCase() + entry.type.slice(1));
const value = fmtLppValue(entry.type, entry.value);
return `
<tr>
<td class="text-muted"><i class="bi ${disp.icon} me-1"></i>${label}</td>
<td class="text-end fw-medium">${value}${disp.unit ? ' ' + disp.unit : ''}</td>
</tr>
`;
}).join('');
// Channel 1 carries the repeater's own vitals (battery, MCU temp)
const chLabel = ch === 1
? `${tHtml('rptmgmt.tel.channel', { n: ch })} <span class="text-muted fw-normal">· ${tHtml('rptmgmt.tel.device_suffix')}</span>`
: tHtml('rptmgmt.tel.channel', { n: ch });
html += `
<div class="col-12 col-md-6 col-xl-4">
<div class="border rounded p-2 h-100">
<div class="small fw-bold mb-1">${chLabel}</div>
<table class="table table-sm mb-0"><tbody>${rows}</tbody></table>
</div>
</div>
`;
});
html += '</div>';
container.innerHTML = html;
}
// ================================================================
// CLI tool
// ================================================================
const CLI_QUICK_COMMANDS = ['get name', 'get radio', 'get tx', 'ver', 'clock', 'neighbors'];
let _cliHistory = [];
let _cliHistoryIndex = -1;
let _cliPending = false;
function cliHistoryKey() {
return `mc-webui-rpt-cli-history-${_pubkey}`;
}
function loadCliHistory() {
try {
_cliHistory = JSON.parse(localStorage.getItem(cliHistoryKey()) || '[]');
} catch (e) {
_cliHistory = [];
}
_cliHistoryIndex = -1;
}
function pushCliHistory(cmd) {
_cliHistory = _cliHistory.filter(c => c !== cmd);
_cliHistory.push(cmd);
if (_cliHistory.length > 50) _cliHistory = _cliHistory.slice(-50);
localStorage.setItem(cliHistoryKey(), JSON.stringify(_cliHistory));
_cliHistoryIndex = -1;
}
function renderCliPane(body) {
loadCliHistory();
_cliPending = false;
const chips = CLI_QUICK_COMMANDS.map(c =>
`<button type="button" class="btn btn-outline-secondary btn-sm cli-chip font-monospace" data-cmd="${esc(c)}">${esc(c)}</button>`
).join('');
body.innerHTML = `
<div class="cli-terminal" id="cliOutput">
<div class="cli-line meta">${tHtml('rptmgmt.cli.intro', { name: _repeater ? _repeater.name : t('rptmgmt.cli.the_repeater') })}</div>
</div>
<div class="d-flex flex-wrap gap-1 mt-2">${chips}</div>
<form id="cliForm" class="d-flex gap-2 mt-2">
<input type="text" id="cliInput" class="form-control form-control-sm font-monospace"
placeholder="${tHtml('rptmgmt.cli.input_ph')}" autocomplete="off"
autocapitalize="off" spellcheck="false">
<button type="submit" class="btn btn-sm btn-success" id="cliSendBtn">
<i class="bi bi-send"></i>
</button>
</form>
`;
const form = body.querySelector('#cliForm');
const input = body.querySelector('#cliInput');
form.addEventListener('submit', (e) => {
e.preventDefault();
sendCliCommand(input.value);
});
input.addEventListener('keydown', (e) => {
if (e.key === 'ArrowUp') {
e.preventDefault();
if (!_cliHistory.length) return;
if (_cliHistoryIndex === -1) _cliHistoryIndex = _cliHistory.length;
if (_cliHistoryIndex > 0) _cliHistoryIndex--;
input.value = _cliHistory[_cliHistoryIndex] || '';
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (_cliHistoryIndex === -1) return;
_cliHistoryIndex++;
if (_cliHistoryIndex >= _cliHistory.length) {
_cliHistoryIndex = -1;
input.value = '';
} else {
input.value = _cliHistory[_cliHistoryIndex] || '';
}
}
});
body.querySelectorAll('.cli-chip').forEach(chip => {
chip.addEventListener('click', () => {
input.value = chip.dataset.cmd;
input.focus();
});
});
setTimeout(() => input.focus(), 200);
}
function cliAppend(cls, text) {
const out = document.getElementById('cliOutput');
if (!out) return null;
const line = document.createElement('div');
line.className = `cli-line ${cls}`;
line.textContent = text;
out.appendChild(line);
out.scrollTop = out.scrollHeight;
return line;
}
async function sendCliCommand(raw) {
const command = (raw || '').trim();
const input = document.getElementById('cliInput');
const sendBtn = document.getElementById('cliSendBtn');
if (!command || _cliPending) return;
_cliPending = true;
if (input) { input.value = ''; input.disabled = true; }
if (sendBtn) sendBtn.disabled = true;
pushCliHistory(command);
cliAppend('cmd', command);
const pendingLine = cliAppend('meta cli-pending', t('rptmgmt.cli.waiting'));
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/cli`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command })
});
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
if (pendingLine) pendingLine.remove();
if (data && data.success) {
cliAppend('reply', data.output || t('rptmgmt.cli.empty_reply'));
if (data.elapsed_ms != null) {
cliAppend('meta', `(${(data.elapsed_ms / 1000).toFixed(1)} s)`);
}
} else {
cliAppend('error', (data && data.error) || t('rptmgmt.command_failed'));
}
_cliPending = false;
if (input) { input.disabled = false; input.focus(); }
if (sendBtn) sendBtn.disabled = false;
}
// ================================================================
// Neighbors tool
// ================================================================
let _neighborsData = null; // last successful response
let _neighborsView = 'list'; // 'list' | 'map'
let _nbMap = null;
let _nbMapLayers = null;
function renderNeighborsPane(body) {
_neighborsView = 'list';
_nbMap = null; // pane body was rebuilt; force map re-init
body.innerHTML = `
<div class="d-flex align-items-center gap-2 mb-2 flex-wrap">
<span class="text-muted small flex-grow-1" id="neighborsCount"></span>
<div class="btn-group btn-group-sm" role="group" id="neighborsViewToggle" style="display: none;">
<button type="button" class="btn btn-outline-secondary active" id="nbListBtn">
<i class="bi bi-list-ul"></i> ${tHtml('rptmgmt.neigh.list')}
</button>
<button type="button" class="btn btn-outline-secondary" id="nbMapBtn">
<i class="bi bi-map"></i> ${tHtml('rptmgmt.neigh.map')}
</button>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="neighborsRefreshBtn" title="${tHtml('rptmgmt.neigh.refresh_title')}">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
</div>
<div id="neighborsContainer"></div>
<div id="neighborsMapWrap" style="display: none;">
<div id="nbLeafletMap" style="height: 420px; width: 100%;" class="rounded border"></div>
<div class="small text-muted mt-1" id="nbMapNote"></div>
</div>
`;
body.querySelector('#neighborsRefreshBtn').addEventListener('click', loadNeighbors);
body.querySelector('#nbListBtn').addEventListener('click', () => setNeighborsView('list'));
body.querySelector('#nbMapBtn').addEventListener('click', () => setNeighborsView('map'));
loadNeighbors();
}
async function loadNeighbors() {
const container = document.getElementById('neighborsContainer');
const btn = document.getElementById('neighborsRefreshBtn');
if (!container) return;
if (btn) btn.disabled = true;
setNeighborsView('list');
container.innerHTML = `
<div class="text-center text-muted py-4">
<div class="spinner-border text-primary" role="status"></div>
<p class="mt-2 mb-0">${tHtml('rptmgmt.neigh.loading')}<br>
<span class="small">${tHtml('rptmgmt.neigh.loading_hint')}</span></p>
</div>
`;
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/neighbours`);
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
if (btn) btn.disabled = false;
if (!data || !data.success) {
const countEl = document.getElementById('neighborsCount');
if (countEl) countEl.textContent = '';
container.innerHTML = `
<div class="text-center py-4">
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 2rem;"></i>
<p class="mt-2 mb-2">${esc((data && data.error) || t('rptmgmt.neighbours_failed'))}</p>
<button type="button" class="btn btn-sm btn-primary" id="neighborsRetryBtn">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.try_again')}
</button>
</div>
`;
const retry = document.getElementById('neighborsRetryBtn');
if (retry) retry.addEventListener('click', loadNeighbors);
return;
}
_neighborsData = data;
renderNeighborsList();
}
function neighborLabel(n) {
return n.name || `[${n.pubkey_prefix}]`;
}
function renderNeighborsList() {
const container = document.getElementById('neighborsContainer');
const countEl = document.getElementById('neighborsCount');
const toggle = document.getElementById('neighborsViewToggle');
const data = _neighborsData;
if (!container || !data) return;
const entries = data.entries || [];
if (countEl) {
let label = tn('rptmgmt.neigh.count', data.total);
if (data.fetched < data.total) label += ` ${t('rptmgmt.neigh.showing', { count: data.fetched })}`;
countEl.textContent = label;
}
// Map view is offered when the repeater or any neighbour has a position
const mappable = entries.some(n => n.lat != null && n.lon != null)
|| (_repeater && _repeater.adv_lat && _repeater.adv_lon);
if (toggle) toggle.style.display = mappable ? '' : 'none';
if (!entries.length) {
container.innerHTML = `<div class="text-muted text-center py-4">${tHtml('rptmgmt.neighbours_none')}<br><small>${tHtml('rptmgmt.neighbours_none_hint')}</small></div>`;
return;
}
const rows = entries.map(n => `
<tr>
<td class="text-truncate" style="max-width: 220px;">
${n.name ? esc(n.name) : `<span class="font-monospace text-muted">[${esc(n.pubkey_prefix)}]</span>`}
${n.lat != null ? `<i class="bi bi-geo-alt text-muted small ms-1" title="${tHtml('rptmgmt.neigh.position_title')}"></i>` : ''}
</td>
<td class="text-muted text-nowrap">${fmtHeardAgo(n.secs_ago)}</td>
<td class="text-end text-nowrap fw-medium">${n.snr != null ? n.snr + ' dB' : '—'}</td>
</tr>
`).join('');
container.innerHTML = `
<table class="table table-sm align-middle mb-0">
<thead><tr class="small text-muted">
<th class="fw-normal">${tHtml('rptmgmt.neigh.repeater')}</th>
<th class="fw-normal">${tHtml('rptmgmt.neigh.heard')}</th>
<th class="fw-normal text-end">SNR</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
`;
}
function fmtHeardAgo(secs) {
if (secs == null) return '—';
if (secs < 60) return t('rptmgmt.ago_s', { s: secs });
if (secs < 3600) return t('rptmgmt.ago_ms', { m: Math.floor(secs / 60), s: secs % 60 });
if (secs < 86400) return t('rptmgmt.ago_hm', { h: Math.floor(secs / 3600), m: Math.floor((secs % 3600) / 60) });
return t('rptmgmt.ago_dh', { d: Math.floor(secs / 86400), h: Math.floor((secs % 86400) / 3600) });
}
function setNeighborsView(view) {
_neighborsView = view;
const listWrap = document.getElementById('neighborsContainer');
const mapWrap = document.getElementById('neighborsMapWrap');
const listBtn = document.getElementById('nbListBtn');
const mapBtn = document.getElementById('nbMapBtn');
if (!listWrap || !mapWrap) return;
const isMap = view === 'map';
listWrap.style.display = isMap ? 'none' : '';
mapWrap.style.display = isMap ? '' : 'none';
if (listBtn) listBtn.classList.toggle('active', !isMap);
if (mapBtn) mapBtn.classList.toggle('active', isMap);
if (isMap) renderNeighborsMap();
}
function renderNeighborsMap() {
const data = _neighborsData;
if (!data) return;
if (!_nbMap) {
_nbMap = L.map('nbLeafletMap').setView([52.0, 19.0], 6);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(_nbMap);
_nbMapLayers = L.layerGroup().addTo(_nbMap);
}
_nbMapLayers.clearLayers();
const hasCenter = _repeater && _repeater.adv_lat && _repeater.adv_lon;
const center = hasCenter ? [_repeater.adv_lat, _repeater.adv_lon] : null;
const bounds = [];
if (hasCenter) {
const centerMarker = L.circleMarker(center, {
radius: 11,
fillColor: '#dc3545',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.9
}).addTo(_nbMapLayers);
centerMarker.bindPopup(`<b>${esc(_repeater.name || t('rptmgmt.this_repeater'))}</b><br>${tHtml('rptmgmt.managed_repeater')}`);
bounds.push(center);
}
let placed = 0;
let skipped = 0;
(data.entries || []).forEach(n => {
if (n.lat == null || n.lon == null) { skipped++; return; }
const pos = [n.lat, n.lon];
const marker = L.circleMarker(pos, {
radius: 9,
fillColor: '#198754',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.85
}).addTo(_nbMapLayers);
marker.bindPopup(
`<b>${esc(neighborLabel(n))}</b><br>` +
`SNR: ${n.snr != null ? n.snr + ' dB' : '—'}<br>` +
tHtml('rptmgmt.neigh.heard_at', { time: fmtHeardAgo(n.secs_ago) })
);
if (hasCenter) {
const line = L.polyline([center, pos], {
color: '#6c757d',
weight: 2,
dashArray: '6 6',
opacity: 0.8
}).addTo(_nbMapLayers);
line.bindTooltip(`${n.snr != null ? n.snr + ' dB' : '?'}`, {
permanent: true,
direction: 'center',
className: 'nb-snr-tooltip'
});
}
bounds.push(pos);
placed++;
});
const note = document.getElementById('nbMapNote');
if (note) {
const parts = [`${placed} neighbor${placed === 1 ? '' : 's'} with known position`];
if (skipped) parts.push(`${skipped} without position not shown`);
if (!hasCenter) parts.push('managed repeater has no position — connection lines unavailable');
note.textContent = parts.join(' · ');
}
// Leaflet needs a size recalc after the container becomes visible
setTimeout(() => {
_nbMap.invalidateSize();
if (bounds.length > 0) {
_nbMap.fitBounds(bounds, { padding: [30, 30] });
}
}, 50);
}
async function loadClock() {
const el = document.getElementById('statusClock');
if (!el) return;
el.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/clock`);
const data = await resp.json();
if (data.success && data.timestamp) {
const d = new Date(data.timestamp * 1000);
el.classList.remove('text-muted');
el.textContent = d.toLocaleString();
} else {
el.className = '';
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">${tHtml('rptmgmt.status.clock_fetch')}</button>`;
const b = document.getElementById('clockRetryBtn');
if (b) b.addEventListener('click', loadClock);
}
} catch (e) {
el.className = '';
el.innerHTML = `<button type="button" class="btn btn-link btn-sm p-0 align-baseline" id="clockRetryBtn">${tHtml('rptmgmt.status.clock_fetch')}</button>`;
const b = document.getElementById('clockRetryBtn');
if (b) b.addEventListener('click', loadClock);
}
}
// ================================================================
// Settings tool
// ================================================================
// Sections and fields mirror _REPEATER_SETTINGS_FIELDS in api.py.
// Values travel as raw CLI strings (`get X` → `> value`, `set X value`).
// title/label/help/note hold catalog KEYS, resolved with t() at render time.
// `key` is the firmware CLI parameter and is never translated.
const SETTINGS_SECTIONS = [
{ key: 'basic', title: 'rptmgmt.set.basic', icon: 'bi-tag', fields: [
{ key: 'name', label: 'rptmgmt.set.name', type: 'text' },
{ key: 'password', label: 'rptmgmt.set.password', type: 'password', writeOnly: true,
help: 'rptmgmt.set.password_help' },
{ key: 'guest.password', label: 'rptmgmt.set.guest_password', type: 'text',
help: 'rptmgmt.set.guest_password_help' },
]},
{ key: 'radio', title: 'rptmgmt.set.radio', icon: 'bi-broadcast',
note: 'rptmgmt.set.radio_note',
fields: [
{ key: 'radio', label: 'rptmgmt.set.radio_params', type: 'radio4' },
{ key: 'tx', label: 'rptmgmt.set.tx', type: 'number', min: 0, max: 30, step: 1 },
{ key: 'radio.rxgain', label: 'rptmgmt.set.rxgain', type: 'onoff' },
]},
{ key: 'location', title: 'rptmgmt.set.location', icon: 'bi-geo-alt', fields: [
{ key: 'lat', label: 'rptmgmt.set.lat', type: 'text' },
{ key: 'lon', label: 'rptmgmt.set.lon', type: 'text' },
]},
{ key: 'features', title: 'rptmgmt.set.features', icon: 'bi-toggles', fields: [
{ key: 'repeat', label: 'rptmgmt.set.repeat', type: 'onoff' },
{ key: 'allow.read.only', label: 'rptmgmt.set.allow_read_only', type: 'onoff' },
{ key: 'multi.acks', label: 'rptmgmt.set.multi_acks', type: 'zeroone' },
]},
{ key: 'network', title: 'rptmgmt.set.network', icon: 'bi-heart-pulse', fields: [
{ key: 'loop.detect', label: 'rptmgmt.set.loop_detect', type: 'select',
options: ['off', 'minimal', 'moderate', 'strict'] },
{ key: 'dutycycle', label: 'rptmgmt.set.dutycycle', type: 'number', min: 1, max: 100, step: 1 },
]},
{ key: 'advert', title: 'rptmgmt.set.advert', icon: 'bi-megaphone', fields: [
{ key: 'advert.interval', label: 'rptmgmt.set.advert_interval', type: 'number', min: 0, max: 240, step: 1,
help: 'rptmgmt.set.advert_interval_help' },
{ key: 'flood.advert.interval', label: 'rptmgmt.set.flood_advert_interval', type: 'number', min: 0, step: 1 },
{ key: 'flood.max', label: 'rptmgmt.set.flood_max', type: 'number', min: 0, max: 64, step: 1 },
]},
{ key: 'operator', title: 'rptmgmt.set.operator', icon: 'bi-person-vcard', fields: [
{ key: 'owner.info', label: 'rptmgmt.set.owner_info', type: 'textarea',
help: 'rptmgmt.set.owner_info_help' },
]},
{ key: 'advanced', title: 'rptmgmt.set.advanced', icon: 'bi-sliders', fields: [
{ key: 'path.hash.mode', label: 'rptmgmt.set.path_hash_mode', type: 'number', min: 0, max: 2, step: 1 },
{ key: 'txdelay', label: 'rptmgmt.set.txdelay', type: 'number', min: 0, max: 2, step: 'any' },
{ key: 'direct.txdelay', label: 'rptmgmt.set.direct_txdelay', type: 'number', min: 0, max: 2, step: 'any' },
{ key: 'int.thresh', label: 'rptmgmt.set.int_thresh', type: 'number', step: 1 },
{ key: 'agc.reset.interval', label: 'rptmgmt.set.agc_reset', type: 'number', min: 0, step: 4,
help: 'rptmgmt.set.agc_reset_help' },
]},
];
let _settingsState = {}; // section key → {loaded, loadedOnce, loading, applying}
function settingsSection(secKey) {
return SETTINGS_SECTIONS.find(s => s.key === secKey);
}
function settingsSectionEl(secKey) {
return document.querySelector(`#settingsAccordion .accordion-item[data-section="${secKey}"]`);
}
function sfControl(item, fieldKey) {
return item.querySelector(`[data-field="${fieldKey}"]`);
}
function settingsControlHtml(f) {
const df = `data-field="${esc(f.key)}"`;
if (f.type === 'onoff' || f.type === 'zeroone') {
return `<div class="form-check form-switch mb-0">
<input class="form-check-input sf-input" type="checkbox" role="switch" ${df} disabled>
</div>`;
}
if (f.type === 'select') {
const opts = (f.options || []).map(o => `<option value="${esc(o)}">${esc(o)}</option>`).join('');
return `<select class="form-select form-select-sm sf-input" ${df} disabled>${opts}</select>`;
}
if (f.type === 'textarea') {
return `<textarea class="form-control form-control-sm sf-input" rows="3" ${df} disabled></textarea>`;
}
if (f.type === 'password') {
// Write-only: usable without loading, never prefilled
return `<input type="password" class="form-control form-control-sm sf-input" ${df}
placeholder="${tHtml('rptmgmt.set.pw_unchanged_ph')}" autocomplete="new-password">`;
}
if (f.type === 'radio4') {
const subs = [
[tHtml('settings.device.freq'), 'any'],
[tHtml('settings.device.bw'), 'any'],
[tHtml('settings.device.sf'), '1'],
[tHtml('settings.device.cr'), '1'],
].map(([lbl, step], i) => `
<div class="col-6 col-lg-3">
<label class="form-label small text-muted mb-0">${lbl}</label>
<input type="number" step="${step}" class="form-control form-control-sm sf-sub" data-sub="${i}" disabled>
</div>`).join('');
return `<div class="row g-2" ${df}>${subs}</div>`;
}
const attrs = [
f.min != null ? `min="${f.min}"` : '',
f.max != null ? `max="${f.max}"` : '',
f.step != null ? `step="${f.step}"` : '',
].filter(Boolean).join(' ');
const type = f.type === 'number' ? 'number' : 'text';
return `<input type="${type}" ${attrs} class="form-control form-control-sm sf-input" ${df} disabled>`;
}
function settingsFieldHtml(f) {
return `
<div class="sf-row mb-3" data-field-row="${esc(f.key)}">
<label class="form-label small fw-semibold mb-1">${esc(t(f.label))} <span class="sf-badge ms-1"></span></label>
${settingsControlHtml(f)}
<div class="sf-msg small text-danger d-none"></div>
${f.help ? `<div class="form-text small mb-0">${esc(t(f.help))}</div>` : ''}
</div>`;
}
function renderSettingsPane(body) {
_settingsState = {};
const items = SETTINGS_SECTIONS.map(sec => {
_settingsState[sec.key] = { loaded: {}, loadedOnce: false, loading: false, applying: false };
return `
<div class="accordion-item" data-section="${sec.key}">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse"
data-bs-target="#secCollapse-${sec.key}">
<i class="bi ${sec.icon} me-2"></i>${esc(t(sec.title))}
<span class="badge bg-warning text-dark ms-2 sec-dirty-badge d-none"></span>
</button>
</h2>
<div id="secCollapse-${sec.key}" class="accordion-collapse collapse">
<div class="accordion-body pt-2">
${sec.note ? `<div class="small text-muted mb-2"><i class="bi bi-info-circle me-1"></i>${esc(t(sec.note))}</div>` : ''}
<div class="sec-status small text-muted mb-2 d-none"></div>
<div class="sec-reboot alert alert-warning py-1 px-2 small d-none mb-2">
<i class="bi bi-arrow-clockwise me-1"></i>${tHtml('rptmgmt.set.reboot_note')}
</div>
<div class="sec-fields">${sec.fields.map(settingsFieldHtml).join('')}</div>
${sec.key === 'location' ? `
<button type="button" class="btn btn-sm btn-outline-primary sec-map-pick" disabled
title="${tHtml('rptmgmt.set.map_pick_title')}">
<i class="bi bi-geo-alt"></i> ${tHtml('rptmgmt.set.map_pick')}
</button>` : ''}
<div class="d-flex align-items-center gap-2 mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary sec-refresh">
<i class="bi bi-arrow-clockwise"></i> ${tHtml('common.refresh')}
</button>
<button type="button" class="btn btn-sm btn-success sec-apply" disabled>
<i class="bi bi-check-lg"></i> ${tHtml('common.apply')}
</button>
</div>
</div>
</div>
</div>`;
}).join('');
body.innerHTML = `
<p class="text-muted small mb-2">${tHtml('rptmgmt.set.intro')}</p>
<div class="accordion" id="settingsAccordion">${items}</div>
`;
SETTINGS_SECTIONS.forEach(sec => {
const item = settingsSectionEl(sec.key);
const collapse = item.querySelector('.accordion-collapse');
collapse.addEventListener('show.bs.collapse', () => {
if (!_settingsState[sec.key].loadedOnce) loadSettingsSection(sec.key);
});
item.querySelector('.sec-refresh').addEventListener('click', () => loadSettingsSection(sec.key));
item.querySelector('.sec-apply').addEventListener('click', () => applySettingsSection(sec.key));
const mapPickBtn = item.querySelector('.sec-map-pick');
if (mapPickBtn) mapPickBtn.addEventListener('click', openLocationMapPicker);
item.querySelectorAll('.sf-input, .sf-sub').forEach(inp => {
const evt = (inp.type === 'checkbox' || inp.tagName === 'SELECT') ? 'change' : 'input';
inp.addEventListener(evt, () => updateSectionDirty(sec.key));
});
});
}
function setSettingsFieldValue(item, f, raw) {
const el = sfControl(item, f.key);
if (!el) return;
const v = String(raw ?? '');
if (f.type === 'onoff' || f.type === 'zeroone') {
const low = v.trim().toLowerCase();
el.checked = (low === 'on' || low === '1' || low === 'true' || low === 'yes');
} else if (f.type === 'select') {
const val = v.trim();
if (![...el.options].some(o => o.value === val)) {
const opt = document.createElement('option');
opt.value = val;
opt.textContent = val;
el.appendChild(opt);
}
el.value = val;
} else if (f.type === 'radio4') {
const parts = v.split(',').map(s => s.trim());
el.querySelectorAll('.sf-sub').forEach((inp, i) => { inp.value = parts[i] ?? ''; });
} else if (f.type === 'textarea') {
el.value = v.split('|').join('\n');
} else if (f.type === 'number') {
// Some replies carry a unit suffix (e.g. `get dutycycle` → "70.0%")
// that a number input would reject wholesale.
el.value = v.trim().replace(/%$/, '');
} else {
el.value = v;
}
}
function getSettingsFieldValue(item, f) {
const el = sfControl(item, f.key);
if (!el) return '';
if (f.type === 'onoff') return el.checked ? 'on' : 'off';
if (f.type === 'zeroone') return el.checked ? '1' : '0';
if (f.type === 'radio4') {
return [...el.querySelectorAll('.sf-sub')].map(inp => inp.value.trim()).join(',');
}
if (f.type === 'textarea') {
return el.value.replace(/\r/g, '').split('\n').map(s => s.trim()).join('|').replace(/\|+$/, '');
}
return el.value.trim();
}
function disableSettingsField(item, f, disabled) {
const el = sfControl(item, f.key);
if (!el) return;
if (f.type === 'radio4') {
el.querySelectorAll('.sf-sub').forEach(inp => { inp.disabled = disabled; });
} else {
el.disabled = disabled;
}
}
function setFieldBadge(item, fieldKey, kind, text) {
const row = item.querySelector(`[data-field-row="${fieldKey}"]`);
if (!row) return;
const badge = row.querySelector('.sf-badge');
const msg = row.querySelector('.sf-msg');
msg.classList.add('d-none');
msg.textContent = '';
if (kind === 'pending') {
badge.innerHTML = '<span class="spinner-border spinner-border-sm text-muted"></span>';
} else if (kind === 'ok') {
badge.innerHTML = '<i class="bi bi-check-circle-fill text-success"></i>';
} else if (kind === 'reboot') {
badge.innerHTML = `<span class="badge bg-warning text-dark">${tHtml('rptmgmt.reboot_required')}</span>`;
} else if (kind === 'error') {
badge.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-danger"></i>';
if (text) {
msg.textContent = text;
msg.classList.remove('d-none');
}
} else {
badge.innerHTML = '';
}
}
function isFieldDirty(item, st, f) {
if (f.writeOnly) {
const el = sfControl(item, f.key);
return !!(el && el.value);
}
if (!(f.key in st.loaded)) return false; // never loaded → not editable
return getSettingsFieldValue(item, f) !== st.loaded[f.key];
}
function updateSectionDirty(secKey) {
const item = settingsSectionEl(secKey);
const st = _settingsState[secKey];
const sec = settingsSection(secKey);
if (!item || !st || !sec) return 0;
let count = 0;
sec.fields.forEach(f => {
const dirty = isFieldDirty(item, st, f);
if (dirty) count++;
const row = item.querySelector(`[data-field-row="${f.key}"]`);
if (row) row.classList.toggle('sf-dirty', dirty);
});
const applyBtn = item.querySelector('.sec-apply');
applyBtn.disabled = count === 0 || st.loading || st.applying;
applyBtn.innerHTML = `<i class="bi bi-check-lg"></i> ${count ? tHtml('rptmgmt.set.apply_count', { count }) : tHtml('common.apply')}`;
const badge = item.querySelector('.sec-dirty-badge');
badge.classList.toggle('d-none', count === 0);
badge.textContent = count;
return count;
}
async function loadSettingsSection(secKey) {
const st = _settingsState[secKey];
const item = settingsSectionEl(secKey);
const sec = settingsSection(secKey);
if (!st || !item || !sec || st.loading || st.applying) return;
st.loading = true;
const statusEl = item.querySelector('.sec-status');
statusEl.classList.remove('d-none', 'text-danger');
statusEl.classList.add('text-muted');
statusEl.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>'
+ tHtml('rptmgmt.set.reading');
item.querySelector('.sec-refresh').disabled = true;
item.querySelector('.sec-apply').disabled = true;
const mapPickBtn = item.querySelector('.sec-map-pick');
if (mapPickBtn) mapPickBtn.disabled = true;
sec.fields.forEach(f => {
if (f.writeOnly) return;
setFieldBadge(item, f.key, 'clear');
disableSettingsField(item, f, true);
});
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/settings?section=${encodeURIComponent(secKey)}`);
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
st.loading = false;
item.querySelector('.sec-refresh').disabled = false;
if (!data || !data.success) {
statusEl.classList.remove('text-muted');
statusEl.classList.add('text-danger');
statusEl.textContent = (data && data.error) || t('rptmgmt.settings_read_failed');
updateSectionDirty(secKey);
return;
}
const values = data.values || {};
const errors = data.errors || {};
st.loaded = {};
let errCount = 0;
sec.fields.forEach(f => {
if (f.writeOnly) return;
if (f.key in values) {
setSettingsFieldValue(item, f, values[f.key]);
disableSettingsField(item, f, false);
// Baseline = the value as the control round-trips it, so
// firmware formatting quirks never show up as dirty fields.
st.loaded[f.key] = getSettingsFieldValue(item, f);
setFieldBadge(item, f.key, 'clear');
} else {
errCount++;
disableSettingsField(item, f, true);
setFieldBadge(item, f.key, 'error', errors[f.key] || t('rptmgmt.read_failed'));
}
});
if (mapPickBtn) mapPickBtn.disabled = !('lat' in st.loaded && 'lon' in st.loaded);
st.loadedOnce = true;
if (errCount) {
statusEl.classList.remove('text-muted');
statusEl.classList.add('text-danger');
statusEl.textContent = tn('rptmgmt.set.load_errors', errCount);
} else {
statusEl.classList.add('d-none');
}
updateSectionDirty(secKey);
}
async function applySettingsSection(secKey) {
const st = _settingsState[secKey];
const item = settingsSectionEl(secKey);
const sec = settingsSection(secKey);
if (!st || !item || !sec || st.loading || st.applying) return;
const dirty = {};
sec.fields.forEach(f => {
if (isFieldDirty(item, st, f)) {
dirty[f.key] = f.writeOnly ? sfControl(item, f.key).value : getSettingsFieldValue(item, f);
}
});
const keys = Object.keys(dirty);
if (!keys.length) return;
const emptyKey = keys.find(k => dirty[k] === '' && k !== 'owner.info');
if (emptyKey) {
showNotification(t('rptmgmt.toast.field_empty', { field: emptyKey }), 'warning');
return;
}
if ('radio' in dirty && !window.confirm(
t('rptmgmt.confirm.radio', { params: dirty.radio }))) {
return;
}
st.applying = true;
item.querySelector('.sec-refresh').disabled = true;
item.querySelector('.sec-apply').disabled = true;
keys.forEach(k => setFieldBadge(item, k, 'pending'));
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/settings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ values: dirty })
});
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
st.applying = false;
item.querySelector('.sec-refresh').disabled = false;
if (!data || !data.success) {
keys.forEach(k => setFieldBadge(item, k, 'error'));
showNotification((data && data.error) || t('rptmgmt.settings_apply_failed'), 'danger');
updateSectionDirty(secKey);
return;
}
const results = data.results || {};
let okCount = 0, failCount = 0, rebootCount = 0;
for (const f of sec.fields) {
if (!(f.key in dirty)) continue;
const res = results[f.key] || { status: 'failed', error: t('rptmgmt.no_result') };
if (res.status === 'ok' || res.status === 'reboot_required') {
if (res.status === 'reboot_required') {
rebootCount++;
setFieldBadge(item, f.key, 'reboot');
} else {
okCount++;
setFieldBadge(item, f.key, 'ok');
}
if (f.writeOnly) {
sfControl(item, f.key).value = '';
if (f.key === 'password') await syncSavedPassword(dirty[f.key]);
} else {
st.loaded[f.key] = dirty[f.key];
if (f.key === 'name' && _repeater) {
_repeater.name = dirty[f.key];
renderHeader();
}
}
} else {
failCount++;
setFieldBadge(item, f.key, 'error', res.error || res.reply || t('rptmgmt.failed'));
}
}
if (rebootCount) item.querySelector('.sec-reboot').classList.remove('d-none');
updateSectionDirty(secKey);
if (failCount === 0) {
showNotification(rebootCount ? t('rptmgmt.toast.applied_reboot') : t('rptmgmt.toast.applied'), 'success');
} else {
showNotification(tn('rptmgmt.toast.apply_failed_count', failCount), 'danger');
}
if (okCount) {
setTimeout(() => {
sec.fields.forEach(f => {
const row = item.querySelector(`[data-field-row="${f.key}"]`);
if (row && row.querySelector('.sf-badge .bi-check-circle-fill')) {
setFieldBadge(item, f.key, 'clear');
}
});
}, 4000);
}
}
async function syncSavedPassword(newPassword) {
// Keep auto-login working: when a password is saved for this repeater,
// replace it with the one just set on the repeater itself.
if (!_repeater || !_repeater.password_set) return;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password: newPassword })
});
const data = await resp.json();
if (data && data.success) {
showNotification(t('rptmgmt.toast.password_synced'), 'success');
}
} catch (e) {
console.error('Failed to update saved password:', e);
}
}
// ---------------- Location map picker (Settings → Location) ----------------
let _locMap = null;
let _locMapMarker = null;
let _locMapModal = null;
let _locMapPicked = null; // {lat, lon} currently chosen on the map
function openLocationMapPicker() {
const item = settingsSectionEl('location');
if (!item) return;
const latEl = sfControl(item, 'lat');
const lonEl = sfControl(item, 'lon');
// Seed from the loaded field values, then the advertised position, then a
// wide default view of the mesh area.
let seedLat = parseFloat(latEl && latEl.value);
let seedLon = parseFloat(lonEl && lonEl.value);
if (!isFinite(seedLat) || !isFinite(seedLon)) {
seedLat = (_repeater && _repeater.adv_lat) || NaN;
seedLon = (_repeater && _repeater.adv_lon) || NaN;
}
const hasSeed = isFinite(seedLat) && isFinite(seedLon) && (seedLat !== 0 || seedLon !== 0);
if (!_locMapModal) {
_locMapModal = new bootstrap.Modal(document.getElementById('locationMapModal'));
}
const useBtn = document.getElementById('locMapUseBtn');
const selLabel = document.getElementById('locMapSelected');
_locMapPicked = null;
if (useBtn) useBtn.disabled = true;
if (selLabel) selLabel.textContent = '—';
const modalEl = document.getElementById('locationMapModal');
const onShown = function () {
const center = hasSeed ? [seedLat, seedLon] : [52.0, 19.0];
const zoom = hasSeed ? 13 : 6;
if (!_locMap) {
_locMap = L.map('locLeafletMap').setView(center, zoom);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(_locMap);
_locMap.on('click', (e) => setLocMapMarker(e.latlng.lat, e.latlng.lng));
} else {
_locMap.setView(center, zoom);
}
_locMap.invalidateSize();
if (_locMapMarker) { _locMap.removeLayer(_locMapMarker); _locMapMarker = null; }
if (hasSeed) setLocMapMarker(seedLat, seedLon);
modalEl.removeEventListener('shown.bs.modal', onShown);
};
modalEl.addEventListener('shown.bs.modal', onShown);
_locMapModal.show();
}
function setLocMapMarker(lat, lon) {
_locMapPicked = { lat, lon };
if (!_locMapMarker) {
_locMapMarker = L.circleMarker([lat, lon], {
radius: 9, fillColor: '#dc3545', color: '#fff',
weight: 2, opacity: 1, fillOpacity: 0.9
}).addTo(_locMap);
} else {
_locMapMarker.setLatLng([lat, lon]);
}
const useBtn = document.getElementById('locMapUseBtn');
const selLabel = document.getElementById('locMapSelected');
if (useBtn) useBtn.disabled = false;
if (selLabel) selLabel.textContent = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
}
function applyLocationFromMap() {
if (!_locMapPicked) return;
const item = settingsSectionEl('location');
if (!item) return;
const latEl = sfControl(item, 'lat');
const lonEl = sfControl(item, 'lon');
if (latEl) latEl.value = _locMapPicked.lat.toFixed(6);
if (lonEl) lonEl.value = _locMapPicked.lon.toFixed(6);
updateSectionDirty('location');
if (_locMapModal) _locMapModal.hide();
}
// ================================================================
// Actions tool
// ================================================================
// Keys mirror _REPEATER_ACTIONS in api.py.
// title/desc/btn hold catalog KEYS, resolved with t() at render time.
const REPEATER_ACTIONS = [
{ key: 'zerohop_advert', icon: 'bi-megaphone', iconClass: 'text-primary',
title: 'rptmgmt.act.zerohop', btn: 'rptmgmt.act.send', btnClass: 'btn-outline-primary',
desc: 'rptmgmt.act.zerohop_desc' },
{ key: 'flood_advert', icon: 'bi-broadcast-pin', iconClass: 'text-warning',
title: 'rptmgmt.act.flood', btn: 'rptmgmt.act.send', btnClass: 'btn-outline-warning',
desc: 'rptmgmt.act.flood_desc' },
{ key: 'clock_sync', icon: 'bi-clock-history', iconClass: 'text-primary',
title: 'rptmgmt.act.clock_sync', btn: 'rptmgmt.act.sync', btnClass: 'btn-outline-primary',
desc: 'rptmgmt.act.clock_sync_desc' },
];
let _actionPending = false;
function actionRowHtml(a) {
return `
<div class="d-flex align-items-start gap-3 py-2 action-row" data-action="${a.key}">
<i class="bi ${a.icon} fs-4 ${a.iconClass || ''}"></i>
<div class="flex-grow-1" style="min-width: 0;">
<div class="fw-semibold">${esc(t(a.title))}</div>
<div class="small text-muted">${esc(t(a.desc))}</div>
<div class="small action-result d-none mt-1"></div>
</div>
<button type="button" class="btn btn-sm ${a.btnClass} action-btn" style="min-width: 84px;">
${esc(t(a.btn))}
</button>
</div>`;
}
function renderActionsPane(body) {
_actionPending = false;
body.innerHTML = `
<div class="card mb-3">
<div class="card-body py-2">
${REPEATER_ACTIONS.map(actionRowHtml).join('<hr class="my-1">')}
</div>
</div>
<div class="card border-danger">
<div class="card-header py-2 text-danger">
<i class="bi bi-exclamation-octagon me-1"></i>${tHtml('rptmgmt.danger_zone')}
</div>
<div class="card-body py-2">
${actionRowHtml({
key: 'reboot', icon: 'bi-arrow-clockwise', iconClass: 'text-danger',
title: 'rptmgmt.act.reboot', btn: 'rptmgmt.act.reboot_btn', btnClass: 'btn-danger',
desc: 'rptmgmt.act.reboot_desc',
})}
<div class="small text-muted mt-2">
<i class="bi bi-info-circle me-1"></i>${tHtml('rptmgmt.erase_fs_note')}
</div>
</div>
</div>
`;
body.querySelectorAll('.action-row .action-btn').forEach(btn => {
const row = btn.closest('.action-row');
btn.addEventListener('click', () => runRepeaterAction(row.dataset.action));
});
}
async function runRepeaterAction(action) {
if (_actionPending) return;
if (action === 'reboot' && !window.confirm(
t('rptmgmt.confirm.reboot', {
name: (_repeater && _repeater.name) || t('rptmgmt.this_repeater'),
}))) {
return;
}
const row = document.querySelector(`.action-row[data-action="${action}"]`);
const btn = row ? row.querySelector('.action-btn') : null;
const resultEl = row ? row.querySelector('.action-result') : null;
_actionPending = true;
document.querySelectorAll('.action-row .action-btn').forEach(b => { b.disabled = true; });
const btnLabel = btn ? btn.innerHTML : '';
if (btn) btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
if (resultEl) resultEl.classList.add('d-none');
let data = null;
try {
const resp = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/action`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action })
});
data = await resp.json();
} catch (e) {
data = { success: false, error: t('rptmgmt.request_failed') };
}
_actionPending = false;
document.querySelectorAll('.action-row .action-btn').forEach(b => { b.disabled = false; });
if (btn) btn.innerHTML = btnLabel;
if (resultEl) {
resultEl.classList.remove('d-none', 'text-success', 'text-danger');
if (data && data.success) {
resultEl.classList.add(data.ok ? 'text-success' : 'text-danger');
const elapsed = data.elapsed_ms != null ? ` (${(data.elapsed_ms / 1000).toFixed(1)} s)` : '';
resultEl.textContent = (data.reply || t('rptmgmt.done')) + elapsed;
} else {
resultEl.classList.add('text-danger');
resultEl.textContent = (data && data.error) || t('rptmgmt.action_failed');
}
}
if (!data || !data.success) {
showNotification((data && data.error) || t('rptmgmt.action_failed'), 'danger');
}
}
// ================================================================
// Login flow
// ================================================================
async function fetchRepeater() {
const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}`);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || t('rptmgmt.load_failed'));
}
_repeater = data.repeater;
_session = data.session;
}
async function doLogin(password, save) {
const name = (_repeater && _repeater.name) || t('rptmgmt.login.repeater_fallback');
showLoading(t('rptmgmt.login.progress', { name }));
let data = null;
try {
const body = {};
if (password) {
body.password = password;
body.save = !!save;
}
const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
data = await response.json();
} catch (e) {
console.error('Login request failed:', e);
data = { success: false, error: t('rptmgmt.login_request_failed') };
}
if (data && data.success) {
_session = {
logged_in: true,
is_admin: !!data.is_admin,
permissions: data.permissions
};
const role = data.is_admin ? 'ADMIN' : 'GUEST';
showNotification(t('rptmgmt.login.logged_in', { role }), 'success');
showPanel();
} else {
const error = (data && data.error) || t('rptmgmt.login.failed');
openPasswordModal(error);
}
}
function openPasswordModal(errorHint = '') {
// Keep the loading screen behind the modal but stop the spinner text
showLoading(t('rptmgmt.login.waiting'));
const name = (_repeater && _repeater.name) || _pubkey.substring(0, 12);
document.getElementById('passwordModalTitle').textContent = t('rptmgmt.login.modal_title', { name });
const info = document.getElementById('passwordModalInfo');
info.innerHTML = errorHint
? `<span class="text-danger">${esc(errorHint)}</span><br>${tHtml('rptmgmt.login.retry_hint')}`
: tHtml('rptmgmt.login.prompt');
const input = document.getElementById('passwordInput');
input.value = '';
input.type = 'password';
document.getElementById('savePasswordCheck').checked = true;
_passwordModal.show();
setTimeout(() => input.focus(), 300);
}
async function submitPasswordModal() {
const input = document.getElementById('passwordInput');
const password = input.value;
if (!password) {
showNotification(t('rptmgmt.login.empty_password'), 'warning');
return;
}
const save = document.getElementById('savePasswordCheck').checked;
_passwordModal.hide();
await doLogin(password, save);
}
async function logout() {
const logoutBtn = document.getElementById('logoutBtn');
logoutBtn.disabled = true;
try {
const response = await fetch(`/api/repeaters/${encodeURIComponent(_pubkey)}/logout`, { method: 'POST' });
const data = await response.json();
if (!data.success) {
showNotification(data.error || t('rptmgmt.logout_failed'), 'danger');
logoutBtn.disabled = false;
return;
}
} catch (e) {
console.error('Logout failed:', e);
}
goBackToList();
}
// ================================================================
// Init
// ================================================================
async function init() {
const params = new URLSearchParams(window.location.search);
_pubkey = (params.get('pubkey') || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(_pubkey)) {
showError(t('rptmgmt.invalid_pubkey'));
return;
}
showLoading(t('common.loading'));
try {
await fetchRepeater();
} catch (e) {
showError(e.message);
return;
}
if (!_repeater.on_device) {
showError(t('rptmgmt.not_on_device'));
return;
}
if (_session && _session.logged_in) {
showPanel();
} else if (_repeater.password_set) {
// Saved password: log in automatically (e.g. after app restart)
await doLogin(null, false);
} else {
openPasswordModal();
}
}
document.addEventListener('DOMContentLoaded', () => {
_passwordModal = new bootstrap.Modal(document.getElementById('passwordModal'));
loadUiSettings();
document.getElementById('backBtn').addEventListener('click', goBackToList);
document.getElementById('errorBackBtn').addEventListener('click', goBackToList);
document.getElementById('errorRetryBtn').addEventListener('click', init);
document.getElementById('logoutBtn').addEventListener('click', logout);
document.getElementById('paneBackBtn').addEventListener('click', showToolsGrid);
document.getElementById('passwordSubmitBtn').addEventListener('click', submitPasswordModal);
document.getElementById('passwordCancelBtn').addEventListener('click', () => {
_passwordModal.hide();
goBackToList();
});
document.getElementById('passwordInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
submitPasswordModal();
}
});
document.getElementById('togglePasswordBtn').addEventListener('click', () => {
const input = document.getElementById('passwordInput');
input.type = input.type === 'password' ? 'text' : 'password';
});
document.getElementById('locMapUseBtn').addEventListener('click', applyLocationFromMap);
document.getElementById('copyPubkeyBtn').addEventListener('click', async () => {
try {
await copyTextToClipboard(_repeater ? _repeater.public_key : _pubkey);
showNotification(t('rptmgmt.pubkey_copied'), 'info');
} catch (e) {
showNotification(t('common.copy_failed'), 'warning');
}
});
init();
});