{% 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 <key></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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
</script>
{% endblock %}
{% block scripts %}
<script src="/assets/leaflet/leaflet.js"></script>
<script src="/js/map-picker.js"></script>
{% endblock %}
|