PHP Classes

File: templates/admin/packet_bbs.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/admin/packet_bbs.twig   Download  
File: templates/admin/packet_bbs.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 54,058 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}Packet BBS Nodes{% endblock %} {% block head %} <link rel="stylesheet" href="/assets/leaflet/leaflet.css"> {% endblock %} {% block content %} <div class="container-fluid"> <div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom"> <h1 class="h2"><i class="fas fa-broadcast-tower me-2"></i>Packet BBS / MeshCore Nodes</h1> <div class="d-flex gap-2"> <a href="/admin/docs/view/PacketBBS" class="btn btn-outline-secondary"> <i class="fas fa-question-circle me-1"></i>Help </a> <button class="btn btn-primary" onclick="showAddNodeModal()"> <i class="fas fa-plus me-2"></i>Add Node </button> </div> </div> <!-- Registered Nodes --> <div class="card shadow mb-4"> <div class="card-header"><strong>Registered Nodes</strong></div> <div class="card-body p-0"> <div class="table-responsive"> <table class="table table-hover mb-0" id="nodesTable"> <thead class="table-dark"> <tr> <th>Node ID</th> <th>Handle</th> <th>Description</th> <th>Interface</th> <th>Last Seen</th> <th>Actions</th> </tr> </thead> <tbody id="nodesBody"> <tr><td colspan="6" class="text-center text-muted py-3">Loading?</td></tr> </tbody> </table> </div> </div> </div> <!-- Active Sessions --> <div class="card shadow mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> <strong>Active Sessions</strong> <button class="btn btn-sm btn-outline-secondary" onclick="loadSessions()"> <i class="fas fa-sync-alt"></i> Refresh </button> </div> <div class="card-body p-0"> <div class="table-responsive"> <table class="table table-hover mb-0"> <thead class="table-dark"> <tr> <th>Node ID</th> <th>User</th> <th>State</th> <th>Last Activity</th> <th>Actions</th> </tr> </thead> <tbody id="sessionsBody"> <tr><td colspan="5" class="text-center text-muted py-3">Loading?</td></tr> </tbody> </table> </div> </div> </div> <!-- Outbound Queue --> <div class="card shadow mb-4"> <div class="card-header d-flex justify-content-between align-items-center"> <strong>Outbound Queue</strong> <div> <button class="btn btn-sm btn-outline-secondary me-2" onclick="loadQueue()"> <i class="fas fa-sync-alt"></i> Refresh </button> <button class="btn btn-sm btn-outline-danger" onclick="flushQueue()"> <i class="fas fa-trash"></i> Flush Queue </button> </div> </div> <div class="card-body p-0"> <div class="table-responsive"> <table class="table table-hover mb-0"> <thead class="table-dark"> <tr> <th>ID</th> <th>Node ID</th> <th>Payload</th> <th>Queued At</th> </tr> </thead> <tbody id="queueBody"> <tr><td colspan="4" class="text-center text-muted py-3">Loading?</td></tr> </tbody> </table> </div> </div> </div> </div> <!-- Add/Edit Node Modal --> <div class="modal fade" id="nodeModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="nodeModalLabel">Add Node</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <form id="nodeForm"> <input type="hidden" id="nodeDbId"> <div class="row g-4"> <!-- Left column: node details --> <div class="col-md-6"> <div class="mb-3"> <label class="form-label">Node ID <span class="text-danger">*</span></label> <input type="text" class="form-control" id="nodeId" placeholder="e.g. !a1b2c3d4 or W1ABC-1" required> <div class="form-text">The unique radio network address of the node.</div> </div> <div class="mb-3"> <label class="form-label">Handle / Callsign</label> <input type="text" class="form-control" id="nodeHandle" placeholder="Node name"> <div class="form-text">A name for this radio node. Consider using your BBS hostname so that radio users who hear the node's adverts or beacons can identify which BBS it belongs to.</div> </div> <div class="mb-3"> <label class="form-label">Interface Type</label> <select class="form-select" id="nodeInterface"> <option value="meshcore">MeshCore</option> <option value="meshtastic">Meshtastic</option> <option value="tnc">AX.25 TNC (KISS)</option> </select> </div> <div class="mb-3"> <label class="form-label">Location Description (optional)</label> <input type="text" class="form-control" id="nodeLocation" placeholder="e.g. Lower Mainland BC" maxlength="255"> <div class="form-text">Human-readable location shown in the node list and dashboard.</div> </div> <div class="mb-3"> <label class="form-label">Description (optional)</label> <textarea class="form-control" id="nodeDescription" rows="3" placeholder="Brief description of this node?"></textarea> </div> <div class="mb-3"> <label class="form-label">Coordinates (optional)</label> <div class="input-group"> <input type="text" class="form-control" id="nodeLat" placeholder="Latitude" aria-label="Latitude"> <input type="text" class="form-control" id="nodeLon" placeholder="Longitude" aria-label="Longitude"> <button type="button" class="btn btn-outline-secondary map-picker-trigger" data-lat-field="#nodeLat" data-lon-field="#nodeLon" title="Pick on map"> <i class="fas fa-map-marker-alt"></i> </button> </div> <div class="form-text">GPS coordinates. Used to show this node on the map.</div> </div> </div> <!-- Right column: auto-add contact policy (MeshCore only) --> <div class="col-md-6" id="nodeAutoaddSection" style="display:none!important"> <div class="d-flex align-items-center justify-content-between mb-1"> <strong>{{ t('ui.admin.meshcore.autoadd_title') }}</strong> <button type="button" class="btn btn-sm btn-outline-secondary" id="nodeAutoaddReadBtn" onclick="readAutoaddConfig()"> <i class="fas fa-download me-1"></i>{{ t('ui.admin.meshcore.autoadd_read_device') }} </button> </div> <div class="form-text mb-2">{{ t('ui.admin.meshcore.autoadd_help') }}</div> <div id="nodeAutoaddUnknown" class="text-muted fst-italic mb-2 d-none"> {{ t('ui.admin.meshcore.autoadd_unknown') }} </div> <div class="form-check"> <input class="form-check-input" type="checkbox" id="autoaddChat" value="2"> <label class="form-check-label" for="autoaddChat">{{ t('ui.admin.meshcore.autoadd_chat') }}</label> </div> <div class="form-check"> <input class="form-check-input" type="checkbox" id="autoaddRepeater" value="4"> <label class="form-check-label" for="autoaddRepeater">{{ t('ui.admin.meshcore.autoadd_repeater') }}</label> </div> <div class="form-check"> <input class="form-check-input" type="checkbox" id="autoaddRoomServer" value="8"> <label class="form-check-label" for="autoaddRoomServer">{{ t('ui.admin.meshcore.autoadd_room_server') }}</label> </div> <div class="form-check"> <input class="form-check-input" type="checkbox" id="autoaddSensor" value="16"> <label class="form-check-label" for="autoaddSensor">{{ t('ui.admin.meshcore.autoadd_sensor') }}</label> </div> <div class="form-check mt-1"> <input class="form-check-input" type="checkbox" id="autoaddOverwriteOldest" value="1"> <label class="form-check-label" for="autoaddOverwriteOldest">{{ t('ui.admin.meshcore.autoadd_overwrite_oldest') }}</label> </div> <div id="nodeAutoaddPendingSync" class="alert alert-warning py-1 px-2 small mt-2 d-none"> <i class="fas fa-clock me-1"></i>{{ t('ui.admin.meshcore.autoadd_pending_sync') }} </div> </div> </div> </form> <div id="nodeModalError" class="alert alert-danger d-none mt-3"></div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn btn-primary" id="nodeSaveBtn" onclick="saveNode()">Save</button> </div> </div> </div> </div> <!-- API Key Modal --> <div class="modal fade" id="nodeKeyModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">API Key ? <code id="keyModalNodeId"></code></h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <p id="keyModalStatus"></p> <div id="keyModalResult" class="d-none"> <div class="alert alert-warning py-2"> <i class="fas fa-exclamation-triangle me-1"></i> Copy this key now ? it will not be shown again. </div> <div class="input-group"> <input type="text" class="form-control font-monospace" id="keyValue" readonly> <button class="btn btn-outline-secondary" id="keyCopyBtn" onclick="copyKey()" title="Copy"> <i class="fas fa-copy"></i> </button> </div> <div class="form-text mt-1"> Use as <code>Authorization: Bearer &lt;key&gt;</code> in bridge requests for this node. </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> <button type="button" class="btn btn-primary" id="keyModalRegenBtn" onclick="regenerateKey()">Generate Key</button> </div> </div> </div> </div> <!-- Contacts Modal --> <div class="modal fade" id="contactsModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-xl"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"> <i class="fas fa-address-book me-2"></i>Contacts ? <code id="contactsModalNodeId"></code> </h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body p-0"> <div class="table-responsive"> <table class="table table-hover mb-0"> <thead class="table-dark"> <tr> <th style="width:1px"><input type="checkbox" id="contactSelectAll" onchange="toggleSelectAll(this.checked)" title="Select all"></th> <th>Node ID (prefix)</th> <th>Name</th> <th>Type</th> <th>Owner</th> <th>Location</th> <th>Last Seen</th> <th>Actions</th> </tr> </thead> <tbody id="contactsBody"> <tr><td colspan="8" class="text-center text-muted py-3">Loading?</td></tr> </tbody> </table> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-danger me-auto d-none" id="bulkDeleteBtn" onclick="deleteSelectedContacts()"> {{ t('ui.admin.meshcore.delete_selected') }} <span class="badge bg-light text-danger ms-1" id="bulkDeleteCount">0</span> </button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button> </div> </div> </div> </div> <!-- Contact Edit Modal --> <div class="modal fade" id="contactEditModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">Edit Contact ? <code id="contactEditKey"></code></h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <input type="hidden" id="contactEditId"> <input type="hidden" id="contactEditNodeDbId"> <div class="mb-3"> <label class="form-label">Display Name</label> <input type="text" class="form-control" id="contactEditName" maxlength="64" placeholder="Leave blank to use name from radio"> </div> <div class="mb-3"> <label class="form-label">Owner</label> <input type="hidden" id="contactEditUserId"> <div class="position-relative"> <input type="text" class="form-control" id="contactOwnerSearch" autocomplete="off" placeholder="Search username?"> <div id="contactOwnerResults" class="list-group position-absolute w-100 shadow-sm d-none" style="z-index:1070;"></div> </div> <div class="form-text">The BBS user who owns this radio. Leave blank to leave unowned.</div> </div> <div class="mb-3"> <label class="form-label">Notes</label> <textarea class="form-control" id="contactEditNotes" rows="2" maxlength="500" placeholder="Optional admin notes"></textarea> </div> <div id="contactEditError" class="alert alert-danger d-none"></div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel') }}</button> <button type="button" class="btn btn-primary" onclick="saveContact()">{{ t('ui.common.save') }}</button> </div> </div> </div> </div> <!-- Session Detail Modal --> <div class="modal fade" id="sessionDetailModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><i class="fas fa-info-circle me-2"></i>{{ t('ui.admin.packet_bbs.session.detail_title') }} ? <code id="sessionDetailNodeId"></code></h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body" id="sessionDetailBody"></div> <div class="modal-footer"> <button type="button" class="btn btn-outline-secondary me-auto" onclick="refreshSessionDetail()"> <i class="fas fa-sync-alt"></i> {{ t('ui.common.refresh') }} </button> <button type="button" class="btn btn-outline-danger me-2" onclick="killSessionFromModal()"> <i class="fas fa-times"></i> {{ t('ui.admin.packet_bbs.session.kill') }} </button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close') }}</button> </div> </div> </div> </div> {% include 'partials/map_picker_modal.twig' %} <script> const currentAdminUser = {{ current_admin_user | json_encode | raw }}; const SITE_HOSTNAME = {{ ((site_url | split('//') | last) | split('/') | first) | json_encode | raw }}; let nodeModal, keyModal, contactsModal, contactEditModal, sessionDetailModal; let activeContactNodeDbId = null; let activeSessionNodeId = null; let contactOwnerSearchTimer = null; let selectedContactOwner = null; let autoaddPollTimer = null; let autoaddConfirmed = false; let autoaddInitialConfig = null; // config byte when the edit modal was opened; null = not loaded const AUTOADD_POLL_INTERVAL = 4000; function updateSaveBtn(mode) { const btn = document.getElementById('nodeSaveBtn'); if (mode === 'close') { btn.textContent = window.t('ui.common.close', {}, 'Close'); btn.classList.replace('btn-primary', 'btn-secondary'); } else { btn.textContent = window.t('ui.common.save', {}, 'Save'); btn.classList.replace('btn-secondary', 'btn-primary'); } } const AUTOADD_POLL_TIMEOUT = 90000; document.addEventListener('DOMContentLoaded', function() { nodeModal = new bootstrap.Modal(document.getElementById('nodeModal')); contactsModal = new bootstrap.Modal(document.getElementById('contactsModal')); contactEditModal = new bootstrap.Modal(document.getElementById('contactEditModal')); sessionDetailModal = new bootstrap.Modal(document.getElementById('sessionDetailModal')); document.getElementById('nodeModal').addEventListener('hidden.bs.modal', function() { stopAutoaddPoll(); document.getElementById('nodeAutoaddReadBtn').disabled = false; autoaddConfirmed = false; updateSaveBtn('save'); }); // Any form change after a confirmed save marks the form dirty again. document.getElementById('nodeModal').addEventListener('input', function() { if (autoaddConfirmed) { autoaddConfirmed = false; updateSaveBtn('save'); } }); document.getElementById('nodeModal').addEventListener('change', function() { if (autoaddConfirmed) { autoaddConfirmed = false; updateSaveBtn('save'); } }); loadNodes(); loadSessions(); loadQueue(); document.getElementById('contactOwnerSearch').addEventListener('input', function() { selectedContactOwner = null; document.getElementById('contactEditUserId').value = ''; clearTimeout(contactOwnerSearchTimer); contactOwnerSearchTimer = setTimeout(() => searchContactOwner(this.value), 200); }); document.getElementById('contactOwnerSearch').addEventListener('blur', function() { setTimeout(() => { document.getElementById('contactOwnerResults').classList.add('d-none'); }, 150); }); document.getElementById('contactOwnerSearch').addEventListener('focus', function() { searchContactOwner(this.value); }); document.getElementById('contactsModal').addEventListener('hidden.bs.modal', function() { activeContactNodeDbId = null; resetBulkSelect(); }); }); function loadNodes() { fetch('/admin/api/packet-bbs/nodes') .then(r => r.json()) .then(data => { const tbody = document.getElementById('nodesBody'); if (!data.nodes || data.nodes.length === 0) { tbody.innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3">No nodes registered. Add one to get started.</td></tr>'; return; } tbody.innerHTML = data.nodes.map(n => ` <tr> <td><code>${escHtml(n.node_id)}</code></td> <td>${escHtml(n.handle || '?')}</td> <td class="text-muted small">${escHtml(n.description || '')}</td> <td><span class="badge bg-secondary">${escHtml(n.interface_type)}</span></td> <td>${n.last_seen_at ? new Date(n.last_seen_at).toLocaleString() : '<span class="text-muted">' + window.t('ui.admin.packet_bbs.node.never_seen', {}, 'never') + '</span>'}</td> <td> ${n.interface_type === 'meshcore' ? ` <button class="btn btn-sm btn-outline-info me-1" onclick="showContactsModal(${n.id}, '${escHtml(n.node_id)}')" title="${window.t('ui.admin.packet_bbs.node.manage_contacts', {}, 'Manage Contacts')}"> <i class="fas fa-address-book"></i> </button>` : ''} <button class="btn btn-sm btn-outline-secondary me-1" data-node="${escHtml(JSON.stringify(n))}" onclick="editNode(JSON.parse(this.dataset.node))"> <i class="fas fa-edit"></i> </button> <button class="btn btn-sm ${n.has_api_key ? 'btn-outline-success' : 'btn-outline-warning'} me-1" onclick="showKeyModal(${n.id}, '${escHtml(n.node_id)}', ${n.has_api_key ? 'true' : 'false'})" title="${n.has_api_key ? window.t('ui.admin.packet_bbs.key.configured', {}, 'API key is configured') : window.t('ui.admin.packet_bbs.key.not_set', {}, 'No API key set')}"> <i class="fas fa-key"></i> </button> <button class="btn btn-sm btn-outline-danger" onclick="deleteNode(${n.id}, '${escHtml(n.node_id)}')"> <i class="fas fa-trash"></i> </button> </td> </tr> `).join(''); }); } function loadSessions() { fetch('/admin/api/packet-bbs/sessions') .then(r => r.json()) .then(data => { const tbody = document.getElementById('sessionsBody'); if (!data.sessions || data.sessions.length === 0) { tbody.innerHTML = '<tr><td colspan="5" class="text-center text-muted py-3">' + window.t('ui.admin.packet_bbs.session.empty', {}, 'No active sessions.') + '</td></tr>'; return; } tbody.innerHTML = data.sessions.map(s => ` <tr> <td><a href="#" class="font-monospace text-decoration-none" onclick="showSessionDetailModal('${escHtml(s.node_id)}'); return false;">${escHtml(s.node_id)}</a></td> <td>${s.username ? escHtml(s.username) : '<span class="text-muted">' + window.t('ui.admin.packet_bbs.session.not_logged_in', {}, 'not logged in') + '</span>'}</td> <td><code>${escHtml(s.menu_state)}</code></td> <td>${new Date(s.last_activity_at).toLocaleString()}</td> <td> <button class="btn btn-sm btn-outline-danger" onclick="killSession('${escHtml(s.node_id)}')"> <i class="fas fa-times"></i> ${window.t('ui.admin.packet_bbs.session.kill_btn', {}, 'Kill')} </button> </td> </tr> `).join(''); }); } function loadQueue() { fetch('/admin/api/packet-bbs/queue') .then(r => r.json()) .then(data => { const tbody = document.getElementById('queueBody'); if (!data.queue || data.queue.length === 0) { tbody.innerHTML = '<tr><td colspan="4" class="text-center text-muted py-3">' + window.t('ui.admin.packet_bbs.queue.empty', {}, 'Queue is empty.') + '</td></tr>'; return; } tbody.innerHTML = data.queue.map(q => ` <tr> <td>${q.id}</td> <td><code>${escHtml(q.node_id)}</code></td> <td><span class="text-truncate d-inline-block" style="max-width:300px" title="${escHtml(q.payload)}">${escHtml(q.payload)}</span></td> <td>${new Date(q.created_at).toLocaleString()}</td> </tr> `).join(''); }); } function setAutoaddCheckboxes(configByte) { document.getElementById('autoaddChat').checked = (configByte & 0x02) !== 0; document.getElementById('autoaddRepeater').checked = (configByte & 0x04) !== 0; document.getElementById('autoaddRoomServer').checked = (configByte & 0x08) !== 0; document.getElementById('autoaddSensor').checked = (configByte & 0x10) !== 0; document.getElementById('autoaddOverwriteOldest').checked = (configByte & 0x01) !== 0; } function getAutoaddConfigByte() { let b = 0; if (document.getElementById('autoaddChat').checked) b |= 0x02; if (document.getElementById('autoaddRepeater').checked) b |= 0x04; if (document.getElementById('autoaddRoomServer').checked) b |= 0x08; if (document.getElementById('autoaddSensor').checked) b |= 0x10; if (document.getElementById('autoaddOverwriteOldest').checked) b |= 0x01; return b; } function showAddNodeModal() { autoaddConfirmed = false; autoaddInitialConfig = null; updateSaveBtn('save'); document.getElementById('nodeModalLabel').textContent = window.t('ui.admin.packet_bbs.node.add_title', {}, 'Add Node'); document.getElementById('nodeDbId').value = ''; document.getElementById('nodeId').value = ''; document.getElementById('nodeId').disabled = false; document.getElementById('nodeHandle').value = ''; document.getElementById('nodeHandle').placeholder = 'Node name'; document.getElementById('nodeInterface').value = 'meshcore'; document.getElementById('nodeLocation').value = ''; document.getElementById('nodeDescription').value = ''; document.getElementById('nodeLat').value = ''; document.getElementById('nodeLon').value = ''; document.getElementById('nodeModalError').classList.add('d-none'); document.getElementById('nodeAutoaddSection').style.setProperty('display', 'none', 'important'); nodeModal.show(); } function editNode(n) { autoaddConfirmed = false; autoaddInitialConfig = null; updateSaveBtn('save'); document.getElementById('nodeModalLabel').textContent = window.t('ui.admin.packet_bbs.node.edit_title', {}, 'Edit Node'); document.getElementById('nodeDbId').value = n.id; document.getElementById('nodeId').value = n.node_id; document.getElementById('nodeId').disabled = true; document.getElementById('nodeHandle').value = n.handle || ''; document.getElementById('nodeInterface').value = n.interface_type || 'meshcore'; document.getElementById('nodeLocation').value = n.location || ''; document.getElementById('nodeDescription').value = n.description || ''; document.getElementById('nodeLat').value = n.lat != null ? n.lat : ''; document.getElementById('nodeLon').value = n.lon != null ? n.lon : ''; document.getElementById('nodeModalError').classList.add('d-none'); const isMeshcore = (n.interface_type || 'meshcore') === 'meshcore'; const autoaddEl = document.getElementById('nodeAutoaddSection'); if (isMeshcore) { autoaddEl.style.removeProperty('display'); } else { autoaddEl.style.setProperty('display', 'none', 'important'); } if (isMeshcore) { if (n.autoadd_config !== null && n.autoadd_config !== undefined && n.autoadd_config !== '') { document.getElementById('nodeAutoaddUnknown').classList.add('d-none'); const configByte = parseInt(n.autoadd_config, 10); setAutoaddCheckboxes(configByte); autoaddInitialConfig = configByte; } else { document.getElementById('nodeAutoaddUnknown').classList.remove('d-none'); setAutoaddCheckboxes(0); autoaddInitialConfig = null; } document.getElementById('nodeAutoaddPendingSync').classList.toggle('d-none', !n.autoadd_pending_sync); } nodeModal.show(); } function readAutoaddConfig() { const dbId = document.getElementById('nodeDbId').value; if (!dbId) return; const btn = document.getElementById('nodeAutoaddReadBtn'); btn.disabled = true; fetch(`/admin/api/packet-bbs/nodes/${dbId}/read-autoadd-config`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (data.queued) { startAutoaddPoll(parseInt(dbId, 10)); } else { btn.disabled = false; } }) .catch(() => { btn.disabled = false; }); } function startAutoaddPoll(nodeDbId) { const unknownEl = document.getElementById('nodeAutoaddUnknown'); const btn = document.getElementById('nodeAutoaddReadBtn'); unknownEl.textContent = window.t('ui.admin.meshcore.autoadd_read_waiting', {}, 'Waiting for device response?'); unknownEl.classList.remove('d-none'); let elapsed = 0; stopAutoaddPoll(); autoaddPollTimer = setInterval(function() { elapsed += AUTOADD_POLL_INTERVAL; fetch('/admin/api/packet-bbs/nodes') .then(r => r.json()) .then(data => { const node = (data.nodes || []).find(n => n.id === nodeDbId); if (node && node.autoadd_config !== null && node.autoadd_config !== undefined && node.autoadd_config !== '') { stopAutoaddPoll(); btn.disabled = false; unknownEl.classList.add('d-none'); document.getElementById('nodeAutoaddPendingSync').classList.add('d-none'); setAutoaddCheckboxes(parseInt(node.autoadd_config, 10)); autoaddConfirmed = true; updateSaveBtn('close'); loadNodes(); } else if (elapsed >= AUTOADD_POLL_TIMEOUT) { stopAutoaddPoll(); btn.disabled = false; unknownEl.textContent = window.t('ui.admin.meshcore.autoadd_read_timeout', {}, 'Device did not respond. Try again.'); } }) .catch(() => { if (elapsed >= AUTOADD_POLL_TIMEOUT) { stopAutoaddPoll(); btn.disabled = false; } }); }, AUTOADD_POLL_INTERVAL); } function stopAutoaddPoll() { if (autoaddPollTimer !== null) { clearInterval(autoaddPollTimer); autoaddPollTimer = null; } } function saveNode() { if (autoaddConfirmed) { nodeModal.hide(); return; } const dbId = document.getElementById('nodeDbId').value; const isEdit = dbId !== ''; const isMeshcore = document.getElementById('nodeInterface').value === 'meshcore'; const latRaw = document.getElementById('nodeLat').value.trim(); const lonRaw = document.getElementById('nodeLon').value.trim(); const payload = { node_id: document.getElementById('nodeId').value.trim(), handle: document.getElementById('nodeHandle').value.trim(), interface_type: document.getElementById('nodeInterface').value, location: document.getElementById('nodeLocation').value.trim() || null, description: document.getElementById('nodeDescription').value.trim() || null, lat: latRaw !== '' ? parseFloat(latRaw) : null, lon: lonRaw !== '' ? parseFloat(lonRaw) : null, }; // Only push autoadd config when it was loaded and has actually changed. const currentAutoaddConfig = isEdit && isMeshcore ? getAutoaddConfigByte() : null; const autoaddChanged = isEdit && isMeshcore && autoaddInitialConfig !== null && currentAutoaddConfig !== autoaddInitialConfig; if (autoaddChanged) { payload.autoadd_config = currentAutoaddConfig; } const url = isEdit ? `/admin/api/packet-bbs/nodes/${dbId}` : '/admin/api/packet-bbs/nodes'; const method = isEdit ? 'PUT' : 'POST'; fetch(url, { method, headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }) .then(r => r.json()) .then(data => { if (data.success || data.id) { if (autoaddChanged) { // Keep the modal open and auto-read back from the device to // confirm the setting was applied, clearing the pending badge. loadNodes(); readAutoaddConfig(); } else { nodeModal.hide(); loadNodes(); } } else { showNodeError(data.error || window.t('errors.admin.packet_bbs.save_failed', {}, 'Save failed.')); } }) .catch(() => showNodeError(window.t('errors.admin.packet_bbs.request_failed', {}, 'Request failed.'))); } function deleteNode(id, nodeId) { if (!confirm(window.t('ui.admin.packet_bbs.node.delete_confirm', {nodeId}, 'Delete node {nodeId}? This will also end any active session.').replace('{nodeId}', nodeId))) return; fetch(`/admin/api/packet-bbs/nodes/${id}`, { method: 'DELETE' }) .then(r => r.json()) .then(() => loadNodes()); } function killSession(nodeId) { if (!confirm(window.t('ui.admin.packet_bbs.session.kill_confirm', {nodeId}, 'Kill session for {nodeId}?').replace('{nodeId}', nodeId))) return; fetch(`/admin/api/packet-bbs/sessions/${encodeURIComponent(nodeId)}`, { method: 'DELETE' }) .then(() => loadSessions()); } function showSessionDetailModal(nodeId) { activeSessionNodeId = nodeId; document.getElementById('sessionDetailNodeId').textContent = nodeId; document.getElementById('sessionDetailBody').innerHTML = '<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>' + window.t('ui.common.loading', {}, 'Loading...') + '</div>'; sessionDetailModal.show(); fetch(`/admin/api/packet-bbs/sessions/${encodeURIComponent(nodeId)}`) .then(r => r.json()) .then(data => { if (data.error) { document.getElementById('sessionDetailBody').innerHTML = `<div class="alert alert-warning mb-0">${escHtml(data.error)}</div>`; return; } renderSessionDetail(data.session); }) .catch(() => { document.getElementById('sessionDetailBody').innerHTML = '<div class="alert alert-danger mb-0">' + window.t('errors.admin.packet_bbs.request_failed', {}, 'Request failed.') + '</div>'; }); } function refreshSessionDetail() { if (!activeSessionNodeId) return; document.getElementById('sessionDetailBody').innerHTML = '<div class="text-center text-muted py-4"><i class="fas fa-spinner fa-spin me-2"></i>' + window.t('ui.common.loading', {}, 'Loading...') + '</div>'; fetch(`/admin/api/packet-bbs/sessions/${encodeURIComponent(activeSessionNodeId)}`) .then(r => r.json()) .then(data => { if (data.error) { document.getElementById('sessionDetailBody').innerHTML = `<div class="alert alert-warning mb-0">${escHtml(data.error)}</div>`; return; } renderSessionDetail(data.session); }) .catch(() => { document.getElementById('sessionDetailBody').innerHTML = '<div class="alert alert-danger mb-0">' + window.t('errors.admin.packet_bbs.request_failed', {}, 'Request failed.') + '</div>'; }); } function killSessionFromModal() { if (!activeSessionNodeId) return; if (!confirm(window.t('ui.admin.packet_bbs.session.kill_confirm', {nodeId: activeSessionNodeId}, 'Kill session for {nodeId}?').replace('{nodeId}', activeSessionNodeId))) return; fetch(`/admin/api/packet-bbs/sessions/${encodeURIComponent(activeSessionNodeId)}`, { method: 'DELETE' }) .then(() => { sessionDetailModal.hide(); loadSessions(); }); } function renderSessionDetail(s) { const fmt = v => v !== null && v !== undefined && v !== '' ? escHtml(String(v)) : '<span class="text-muted">?</span>'; const fmtDate = v => v ? new Date(v).toLocaleString() : '<span class="text-muted">?</span>'; const fmtJson = v => { if (v === null || v === undefined || v === '' || (typeof v === 'object' && Object.keys(v).length === 0)) { return '<span class="text-muted">?</span>'; } const obj = typeof v === 'string' ? JSON.parse(v) : v; return `<pre class="mb-0 small bg-light p-2 rounded">${escHtml(JSON.stringify(obj, null, 2))}</pre>`; }; const rows = [ [window.t('ui.admin.packet_bbs.session.node_id', {}, 'Node ID'), fmt(s.node_id)], [window.t('ui.admin.packet_bbs.session.user', {}, 'User'), s.username ? fmt(s.username) : '<span class="text-muted">' + window.t('ui.admin.packet_bbs.session.not_logged_in', {}, 'not logged in') + '</span>'], [window.t('ui.admin.packet_bbs.session.menu_state', {}, 'Menu State'), `<code>${fmt(s.menu_state)}</code>`], [window.t('ui.admin.packet_bbs.session.started', {}, 'Session Started'), fmtDate(s.created_at)], [window.t('ui.admin.packet_bbs.session.last_activity', {}, 'Last Activity'), fmtDate(s.last_activity_at)], [window.t('ui.admin.packet_bbs.session.pagination_cursor', {}, 'Pagination Cursor'), fmt(s.pagination_cursor)], [window.t('ui.admin.packet_bbs.session.pagination_context', {}, 'Pagination Context'), fmt(s.pagination_context)], [window.t('ui.admin.packet_bbs.session.compose_type', {}, 'Compose Type'), fmt(s.compose_type)], [window.t('ui.admin.packet_bbs.session.compose_buffer', {}, 'Compose Buffer'), s.compose_buffer ? `<pre class="mb-0 small bg-light p-2 rounded">${escHtml(s.compose_buffer)}</pre>` : '<span class="text-muted">?</span>'], [window.t('ui.admin.packet_bbs.session.compose_meta', {}, 'Compose Meta'), fmtJson(s.compose_meta)], [window.t('ui.admin.packet_bbs.session.state', {}, 'Session State'), fmtJson(s.session_state)], ]; document.getElementById('sessionDetailBody').innerHTML = ` <table class="table table-sm table-bordered mb-0"> <tbody> ${rows.map(([label, value]) => ` <tr> <th class="text-nowrap bg-light" style="width:170px">${label}</th> <td>${value}</td> </tr>`).join('')} </tbody> </table>`; } function flushQueue() { if (!confirm(window.t('ui.admin.packet_bbs.queue.flush_confirm', {}, 'Flush all pending outbound messages?'))) return; fetch('/admin/api/packet-bbs/queue', { method: 'DELETE' }) .then(() => loadQueue()); } let keyModalNodeId = 0; function showKeyModal(nodeDbId, nodeId, hasKey) { keyModalNodeId = nodeDbId; document.getElementById('keyModalNodeId').textContent = nodeId; document.getElementById('keyModalStatus').innerHTML = hasKey ? '<span class="text-success"><i class="fas fa-check-circle me-1"></i>' + window.t('ui.admin.packet_bbs.key.configured', {}, 'API key is configured') + '</span>' : '<span class="text-warning"><i class="fas fa-exclamation-circle me-1"></i>' + window.t('ui.admin.packet_bbs.key.not_set', {}, 'No API key set') + '</span>'; document.getElementById('keyModalResult').classList.add('d-none'); document.getElementById('keyModalRegenBtn').textContent = hasKey ? window.t('ui.admin.packet_bbs.key.regenerate', {}, 'Regenerate Key') : window.t('ui.admin.packet_bbs.key.generate', {}, 'Generate Key'); if (!keyModal) keyModal = new bootstrap.Modal(document.getElementById('nodeKeyModal')); keyModal.show(); } function regenerateKey() { fetch(`/admin/api/packet-bbs/nodes/${keyModalNodeId}/regenerate-key`, { method: 'POST' }) .then(r => r.json()) .then(data => { if (!data.success) { alert(window.t('errors.admin.packet_bbs.key_generate_failed', {}, 'Failed to generate key.')); return; } document.getElementById('keyModalStatus').innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>' + window.t('ui.admin.packet_bbs.key.configured', {}, 'API key is configured') + '</span>'; document.getElementById('keyModalRegenBtn').textContent = window.t('ui.admin.packet_bbs.key.regenerate', {}, 'Regenerate Key'); document.getElementById('keyValue').value = data.key; document.getElementById('keyModalResult').classList.remove('d-none'); loadNodes(); }) .catch(() => alert(window.t('errors.admin.packet_bbs.request_failed', {}, 'Request failed.'))); } function copyKey() { const val = document.getElementById('keyValue').value; navigator.clipboard.writeText(val).then(() => { const btn = document.getElementById('keyCopyBtn'); btn.innerHTML = '<i class="fas fa-check"></i>'; setTimeout(() => { btn.innerHTML = '<i class="fas fa-copy"></i>'; }, 1500); }); } // ---- Contacts ---- function showContactsModal(nodeDbId, nodeId) { activeContactNodeDbId = nodeDbId; document.getElementById('contactsModalNodeId').textContent = nodeId; document.getElementById('contactsBody').innerHTML = '<tr><td colspan="7" class="text-center text-muted py-3"><i class="fas fa-spinner fa-spin me-2"></i>' + window.t('ui.common.loading', {}, 'Loading...') + '</td></tr>'; contactsModal.show(); loadContacts(nodeDbId); } function loadContacts(nodeDbId) { resetBulkSelect(); fetch(`/admin/api/packet-bbs/nodes/${nodeDbId}/contacts`) .then(r => r.json()) .then(data => { const tbody = document.getElementById('contactsBody'); if (!data.contacts || data.contacts.length === 0) { tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-3">No contacts found for this node. The bridge will populate contacts when it syncs.</td></tr>'; return; } tbody.innerHTML = data.contacts.map(c => { const keyDisplay = c.pub_key_full ? `<code title="${escHtml(c.pub_key_full)}">${escHtml(c.pub_key_prefix)}</code>` : `<code>${escHtml(c.pub_key_prefix)}</code>`; const loc = (c.lat && c.lon) ? `<small class="text-muted">${parseFloat(c.lat).toFixed(4)}, ${parseFloat(c.lon).toFixed(4)}</small>` : '<span class="text-muted">?</span>'; const lastSeen = c.last_seen_at ? new Date(c.last_seen_at).toLocaleString() : '<span class="text-muted">?</span>'; return ` <tr> <td><input type="checkbox" class="contact-select" value="${c.id}" onchange="updateBulkDeleteButton()"></td> <td>${keyDisplay}</td> <td>${escHtml(c.name || '?')}</td> <td>${c.adv_type ? `<span class="badge bg-secondary">${escHtml(c.adv_type)}</span>` : '<span class="text-muted">?</span>'}</td> <td>${c.username ? escHtml(c.username) : '<span class="text-muted">unowned</span>'}</td> <td>${loc}</td> <td>${lastSeen}</td> <td> <button class="btn btn-sm btn-outline-secondary me-1" data-contact="${escHtml(JSON.stringify(c))}" onclick="editContact(JSON.parse(this.dataset.contact))"> <i class="fas fa-edit"></i> </button> <button class="btn btn-sm btn-outline-danger" onclick="deleteContact(${c.id})"> <i class="fas fa-trash"></i> </button> </td> </tr>`; }).join(''); }); } function editContact(c) { document.getElementById('contactEditId').value = c.id; document.getElementById('contactEditNodeDbId').value = activeContactNodeDbId; document.getElementById('contactEditKey').textContent = c.pub_key_prefix + (c.pub_key_full ? '?' : ' (prefix only)'); document.getElementById('contactEditName').value = c.name || ''; document.getElementById('contactEditNotes').value = c.notes || ''; document.getElementById('contactEditError').classList.add('d-none'); // Pre-populate owner: use existing owner if any, otherwise default to current admin if (c.user_id && c.username) { setSelectedContactOwner({ id: c.user_id, username: c.username }); } else { setSelectedContactOwner(currentAdminUser && currentAdminUser.id ? currentAdminUser : null); } contactEditModal.show(); } function saveContact() { const id = document.getElementById('contactEditId').value; const nodeDbId = document.getElementById('contactEditNodeDbId').value; const payload = { name: document.getElementById('contactEditName').value.trim(), user_id: document.getElementById('contactEditUserId').value || null, notes: document.getElementById('contactEditNotes').value.trim(), }; fetch(`/admin/api/packet-bbs/contacts/${id}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload), }) .then(r => r.json()) .then(data => { if (data.success) { contactEditModal.hide(); loadContacts(nodeDbId); } else { document.getElementById('contactEditError').textContent = data.error || 'Save failed.'; document.getElementById('contactEditError').classList.remove('d-none'); } }) .catch(() => { document.getElementById('contactEditError').textContent = 'Request failed.'; document.getElementById('contactEditError').classList.remove('d-none'); }); } function deleteContact(id) { if (!confirm(window.t('ui.admin.meshcore.delete_contact_confirm', {}, 'Delete this contact?'))) return; fetch(`/admin/api/packet-bbs/contacts/${id}`, { method: 'DELETE' }) .then(r => r.json()) .then(() => loadContacts(activeContactNodeDbId)); } // ---- Bulk select / delete ---- function resetBulkSelect() { const selectAll = document.getElementById('contactSelectAll'); if (selectAll) selectAll.checked = false; document.getElementById('bulkDeleteBtn').classList.add('d-none'); document.getElementById('bulkDeleteCount').textContent = '0'; } function toggleSelectAll(checked) { document.querySelectorAll('.contact-select').forEach(cb => { cb.checked = checked; }); updateBulkDeleteButton(); } function updateBulkDeleteButton() { const checked = document.querySelectorAll('.contact-select:checked'); const btn = document.getElementById('bulkDeleteBtn'); document.getElementById('bulkDeleteCount').textContent = checked.length; btn.classList.toggle('d-none', checked.length === 0); // Keep select-all in sync const all = document.querySelectorAll('.contact-select'); document.getElementById('contactSelectAll').checked = all.length > 0 && checked.length === all.length; document.getElementById('contactSelectAll').indeterminate = checked.length > 0 && checked.length < all.length; } function deleteSelectedContacts() { const ids = Array.from(document.querySelectorAll('.contact-select:checked')).map(cb => parseInt(cb.value)); if (ids.length === 0) return; const msg = window.t('ui.admin.meshcore.delete_selected_confirm', {count: ids.length}, 'Delete {count} selected contacts?').replace('{count}', ids.length); if (!confirm(msg)) return; fetch('/admin/api/packet-bbs/contacts/bulk-delete', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ids}), }) .then(r => r.json()) .then(() => loadContacts(activeContactNodeDbId)); } // ---- Contact owner autocomplete ---- function searchContactOwner(query) { const search = (query || '').trim(); const results = document.getElementById('contactOwnerResults'); if (search.length < 2) { results.classList.add('d-none'); results.innerHTML = ''; return; } results.classList.remove('d-none'); results.innerHTML = '<button type="button" class="list-group-item text-muted">Searching?</button>'; fetch('/admin/api/users?' + new URLSearchParams({ search, limit: 10 })) .then(r => r.json()) .then(data => { const users = data && data.users ? data.users : []; results.innerHTML = ''; if (!users.length) { results.innerHTML = '<button type="button" class="list-group-item text-muted">No users found</button>'; results.classList.remove('d-none'); return; } users.forEach(u => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'list-group-item list-group-item-action'; btn.innerHTML = `<div class="fw-semibold">${escHtml(u.username || '')}</div> <small class="text-muted">${escHtml(u.real_name || '')}</small>`; btn.addEventListener('mousedown', e => { e.preventDefault(); setSelectedContactOwner(u); results.classList.add('d-none'); }); results.appendChild(btn); }); results.classList.remove('d-none'); }) .catch(() => { results.innerHTML = '<button type="button" class="list-group-item text-danger">Search failed</button>'; }); } function setSelectedContactOwner(user) { selectedContactOwner = user && user.id ? user : null; if (!selectedContactOwner) { document.getElementById('contactEditUserId').value = ''; document.getElementById('contactOwnerSearch').value = ''; return; } document.getElementById('contactEditUserId').value = selectedContactOwner.id; document.getElementById('contactOwnerSearch').value = selectedContactOwner.real_name ? `${selectedContactOwner.username} (${selectedContactOwner.real_name})` : selectedContactOwner.username; } // ---- Shared helpers ---- function showNodeError(msg) { const el = document.getElementById('nodeModalError'); el.textContent = msg; el.classList.remove('d-none'); } function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } </script> {% endblock %} {% block scripts %} <script src="/assets/leaflet/leaflet.js"></script> <script src="/js/map-picker.js"></script> {% endblock %}