// 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('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"'); } // ================================================================ // 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/ 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 = `
${esc(t(tool.title))}${locked ? ' ' : ''}

${esc(t(tool.desc))}

`; 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 = ``; 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 = `

${tHtml('rptmgmt.tool_coming', { tool: t(tool.title) })}

`; } // ================================================================ // 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.3–4.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 = `
`; 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 = `

${tHtml('rptmgmt.status.loading')}

`; 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 = `

${esc((data && data.error) || t('rptmgmt.status_failed'))}

`; 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]) => `${esc(label)}${value}` ).join(''); return `
${esc(title)}
${body}
`; } 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'), ''], [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)} (flood ${fmtInt(s.sent_flood)} · direct ${fmtInt(s.sent_direct)})`], [t('rptmgmt.status.received'), `${fmtInt(s.nb_recv)} (flood ${fmtInt(s.recv_flood)} · direct ${fmtInt(s.recv_direct)})`], [t('rptmgmt.status.duplicates'), `flood ${fmtInt(s.flood_dups)} · direct ${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 = `
`; 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 = `

${tHtml('rptmgmt.tel.loading')}
${tHtml('rptmgmt.tel.loading_hint')}

`; 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 = `

${esc((data && data.error) || t('rptmgmt.telemetry_failed'))}

`; 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 = `
${tHtml('rptmgmt.telemetry_none')}
`; 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 = '
'; 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 ` ${label} ${value}${disp.unit ? ' ' + disp.unit : ''} `; }).join(''); // Channel 1 carries the repeater's own vitals (battery, MCU temp) const chLabel = ch === 1 ? `${tHtml('rptmgmt.tel.channel', { n: ch })} · ${tHtml('rptmgmt.tel.device_suffix')}` : tHtml('rptmgmt.tel.channel', { n: ch }); html += `
${chLabel}
${rows}
`; }); html += '
'; 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 => `` ).join(''); body.innerHTML = `
${tHtml('rptmgmt.cli.intro', { name: _repeater ? _repeater.name : t('rptmgmt.cli.the_repeater') })}
${chips}
`; 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 = `
`; 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 = `

${tHtml('rptmgmt.neigh.loading')}
${tHtml('rptmgmt.neigh.loading_hint')}

`; 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 = `

${esc((data && data.error) || t('rptmgmt.neighbours_failed'))}

`; 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 = `
${tHtml('rptmgmt.neighbours_none')}
${tHtml('rptmgmt.neighbours_none_hint')}
`; return; } const rows = entries.map(n => ` ${n.name ? esc(n.name) : `[${esc(n.pubkey_prefix)}]`} ${n.lat != null ? `` : ''} ${fmtHeardAgo(n.secs_ago)} ${n.snr != null ? n.snr + ' dB' : '—'} `).join(''); container.innerHTML = ` ${rows}
${tHtml('rptmgmt.neigh.repeater')} ${tHtml('rptmgmt.neigh.heard')} SNR
`; } 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: '© OpenStreetMap' }).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(`${esc(_repeater.name || t('rptmgmt.this_repeater'))}
${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( `${esc(neighborLabel(n))}
` + `SNR: ${n.snr != null ? n.snr + ' dB' : '—'}
` + 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 = ''; 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 = ``; const b = document.getElementById('clockRetryBtn'); if (b) b.addEventListener('click', loadClock); } } catch (e) { el.className = ''; el.innerHTML = ``; 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 `
`; } if (f.type === 'select') { const opts = (f.options || []).map(o => ``).join(''); return ``; } if (f.type === 'textarea') { return ``; } if (f.type === 'password') { // Write-only: usable without loading, never prefilled return ``; } 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) => `
`).join(''); return `
${subs}
`; } 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 ``; } function settingsFieldHtml(f) { return `
${settingsControlHtml(f)}
${f.help ? `
${esc(t(f.help))}
` : ''}
`; } function renderSettingsPane(body) { _settingsState = {}; const items = SETTINGS_SECTIONS.map(sec => { _settingsState[sec.key] = { loaded: {}, loadedOnce: false, loading: false, applying: false }; return `

${sec.note ? `
${esc(t(sec.note))}
` : ''}
${tHtml('rptmgmt.set.reboot_note')}
${sec.fields.map(settingsFieldHtml).join('')}
${sec.key === 'location' ? ` ` : ''}
`; }).join(''); body.innerHTML = `

${tHtml('rptmgmt.set.intro')}

${items}
`; 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 = ''; } else if (kind === 'ok') { badge.innerHTML = ''; } else if (kind === 'reboot') { badge.innerHTML = `${tHtml('rptmgmt.reboot_required')}`; } else if (kind === 'error') { badge.innerHTML = ''; 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 = ` ${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 = '' + 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: '© OpenStreetMap' }).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 `
${esc(t(a.title))}
${esc(t(a.desc))}
`; } function renderActionsPane(body) { _actionPending = false; body.innerHTML = `
${REPEATER_ACTIONS.map(actionRowHtml).join('
')}
${tHtml('rptmgmt.danger_zone')}
${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', })}
${tHtml('rptmgmt.erase_fs_note')}
`; 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 = ''; 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 ? `${esc(errorHint)}
${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(); });