Merge branch 'dev' into main

- Integrate pending contacts refactoring with JSON format
- Resolve conflict in special commands handler
- Keep node_discover functionality from main
- Add filtering and batch approval from dev
This commit is contained in:
MarekWo
2026-01-03 10:27:54 +01:00
6 changed files with 485 additions and 61 deletions

View File

@@ -391,19 +391,35 @@ By default, new contacts attempting to connect are automatically added to your c
#### Pending Contacts
When manual approval is enabled, new contacts appear in the Pending Contacts list for review:
When manual approval is enabled, new contacts appear in the Pending Contacts list for review with enriched contact information:
**Approve a contact:**
1. View the contact name and truncated public key
2. Click "Copy Full Key" to copy the complete public key (useful for verification)
3. Click "Approve" to add the contact to your contacts list
4. The contact is moved from pending to regular contacts
**View contact details:**
- Contact name with emoji (if present)
- Type badge (CLI, REP, ROOM, SENS) with color coding:
- CLI (blue): Regular clients
- REP (green): Repeaters
- ROOM (cyan): Room servers
- SENS (yellow): Sensors
- Public key prefix (first 12 characters)
- Last seen timestamp (when available)
- Map button (when GPS coordinates are available)
**Note:** Always use the full public key for approval (not name or prefix). This ensures compatibility with all contact types (CLI, ROOM, REP, SENS).
**Filter contacts:**
- By type: Use checkboxes to show only specific contact types (default: CLI only)
- By name or key: Search by partial contact name or public key prefix
**Refresh pending list:**
- Click the "Refresh" button to check for new pending contacts
- The page automatically loads pending contacts when first opened
**Approve contacts:**
- **Single approval:** Click "Approve" on individual contacts
- **Batch approval:** Click "Add Filtered" to approve all filtered contacts at once
- Confirmation modal shows list of contacts to be approved
- Progress indicator during batch approval
**Other actions:**
- Click "Map" button to view contact location on Google Maps (when GPS data available)
- Click "Copy Key" to copy full public key to clipboard
- Click "Refresh" to reload pending contacts list
**Note:** Always use the full public key for approval (not name or prefix). This ensures compatibility with all contact types.
#### Existing Contacts

View File

@@ -704,9 +704,20 @@ def get_pending_contacts() -> Tuple[bool, List[Dict], str]:
Returns:
Tuple of (success, pending_contacts_list, error_message)
Each contact dict: {
'name': str,
'public_key': str
Each contact dict contains:
{
'name': str (adv_name from contact_info),
'public_key': str (full 64-char hex key),
'public_key_prefix': str (first 12 chars for display),
'type': int (1=CLI, 2=REP, 3=ROOM, 4=SENS),
'type_label': str (CLI/REP/ROOM/SENS),
'adv_lat': float (GPS latitude),
'adv_lon': float (GPS longitude),
'last_advert': int (Unix timestamp),
'lastmod': int (Unix timestamp),
'out_path_len': int,
'out_path': str,
'path_or_mode': str (computed: 'Flood' or path string)
}
"""
try:
@@ -725,6 +736,29 @@ def get_pending_contacts() -> Tuple[bool, List[Dict], str]:
return False, [], error
pending = data.get('pending', [])
# Add computed fields (same pattern as get_contacts_with_last_seen)
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
for contact in pending:
# Public key prefix (first 12 chars for display)
public_key = contact.get('public_key', '')
contact['public_key_prefix'] = public_key[:12] if len(public_key) >= 12 else public_key
# Type label
contact_type = contact.get('type', 1)
contact['type_label'] = type_labels.get(contact_type, 'UNKNOWN')
# Path or mode display
out_path_len = contact.get('out_path_len', -1)
out_path = contact.get('out_path', '')
if out_path_len == -1:
contact['path_or_mode'] = 'Flood'
elif out_path:
contact['path_or_mode'] = out_path
else:
contact['path_or_mode'] = f'Path len: {out_path_len}'
return True, pending, ""
except requests.exceptions.Timeout:

View File

@@ -1642,13 +1642,23 @@ def get_pending_contacts_api():
Get list of contacts awaiting manual approval.
Returns:
JSON with pending contacts list:
JSON with pending contacts list with enriched contact data:
{
"success": true,
"pending": [
{
"name": "Skyllancer",
"public_key": "f9ef123abc..."
"name": "KRK - WD 🔌",
"public_key": "2d86b4a747b6565ad1...",
"public_key_prefix": "2d86b4a747b6",
"type": 2,
"type_label": "REP",
"adv_lat": 50.02377,
"adv_lon": 19.96038,
"last_advert": 1715889153,
"lastmod": 1716372319,
"out_path_len": -1,
"out_path": "",
"path_or_mode": "Flood"
},
...
],

View File

@@ -41,6 +41,7 @@ window.navigateTo = function(url) {
let currentPage = null; // 'manage', 'pending', 'existing'
let manualApprovalEnabled = false;
let pendingContacts = [];
let filteredPendingContacts = []; // Filtered pending contacts (for pending page filtering)
let existingContacts = [];
let filteredContacts = [];
let contactToDelete = null;
@@ -392,6 +393,40 @@ function attachPendingEventListeners() {
loadPendingContacts();
});
}
// Search input - filter on typing
const searchInput = document.getElementById('pendingSearchInput');
if (searchInput) {
searchInput.addEventListener('input', () => {
applyPendingFilters();
});
}
// Type filter checkboxes - filter on change
['typeFilterCLI', 'typeFilterREP', 'typeFilterROOM', 'typeFilterSENS'].forEach(id => {
const checkbox = document.getElementById(id);
if (checkbox) {
checkbox.addEventListener('change', () => {
applyPendingFilters();
});
}
});
// Add Filtered button - show batch approval modal
const addFilteredBtn = document.getElementById('addFilteredBtn');
if (addFilteredBtn) {
addFilteredBtn.addEventListener('click', () => {
showBatchApprovalModal();
});
}
// Confirm Batch Approval button - approve all filtered contacts
const confirmBatchBtn = document.getElementById('confirmBatchApprovalBtn');
if (confirmBatchBtn) {
confirmBatchBtn.addEventListener('click', () => {
batchApproveContacts();
});
}
}
// =============================================================================
@@ -566,9 +601,11 @@ async function loadPendingContacts() {
if (pendingContacts.length === 0) {
// Show empty state
if (emptyEl) emptyEl.style.display = 'block';
if (countBadge) countBadge.style.display = 'none';
} else {
// Render pending contacts list
renderPendingList(pendingContacts);
// Initialize filtered list and apply filters (default: CLI only)
filteredPendingContacts = [...pendingContacts];
applyPendingFilters();
// Update count badge (in navbar)
if (countBadge) {
@@ -601,6 +638,19 @@ function renderPendingList(contacts) {
listEl.innerHTML = '';
// Show "no filtered results" message if filters eliminate all contacts
if (contacts.length === 0 && pendingContacts.length > 0) {
const emptyDiv = document.createElement('div');
emptyDiv.className = 'empty-state';
emptyDiv.innerHTML = `
<i class="bi bi-funnel"></i>
<p class="mb-0">No contacts match filters</p>
<small class="text-muted">Try changing your filter criteria</small>
`;
listEl.appendChild(emptyDiv);
return;
}
contacts.forEach((contact, index) => {
const card = createContactCard(contact, index);
listEl.appendChild(card);
@@ -612,21 +662,57 @@ function createContactCard(contact, index) {
card.className = 'pending-contact-card';
card.id = `contact-${index}`;
// Contact name
// Contact info row (name + type badge)
const infoRow = document.createElement('div');
infoRow.className = 'contact-info-row';
const nameDiv = document.createElement('div');
nameDiv.className = 'contact-name';
nameDiv.className = 'contact-name flex-grow-1';
nameDiv.textContent = contact.name;
// Public key (truncated)
const typeBadge = document.createElement('span');
typeBadge.className = 'badge type-badge';
typeBadge.textContent = contact.type_label || 'CLI';
// Color-code by type (same as existing contacts)
switch (contact.type_label) {
case 'CLI':
typeBadge.classList.add('bg-primary');
break;
case 'REP':
typeBadge.classList.add('bg-success');
break;
case 'ROOM':
typeBadge.classList.add('bg-info');
break;
case 'SENS':
typeBadge.classList.add('bg-warning', 'text-dark');
break;
default:
typeBadge.classList.add('bg-secondary');
}
infoRow.appendChild(nameDiv);
infoRow.appendChild(typeBadge);
// Public key row (use prefix for display)
const keyDiv = document.createElement('div');
keyDiv.className = 'contact-key';
const truncatedKey = contact.public_key.substring(0, 16) + '...';
keyDiv.textContent = truncatedKey;
keyDiv.title = contact.public_key; // Full key on hover
keyDiv.textContent = contact.public_key_prefix || contact.public_key.substring(0, 12);
keyDiv.title = 'Public Key Prefix';
// Last advert (optional - show if available)
let lastAdvertDiv = null;
if (contact.last_advert) {
lastAdvertDiv = document.createElement('div');
lastAdvertDiv.className = 'text-muted small';
const relativeTime = formatRelativeTime(contact.last_advert);
lastAdvertDiv.textContent = `Last seen: ${relativeTime}`;
}
// Action buttons
const actionsDiv = document.createElement('div');
actionsDiv.className = 'd-flex gap-2 flex-wrap';
actionsDiv.className = 'd-flex gap-2 flex-wrap mt-2';
// Approve button
const approveBtn = document.createElement('button');
@@ -634,17 +720,29 @@ function createContactCard(contact, index) {
approveBtn.innerHTML = '<i class="bi bi-check-circle"></i> Approve';
approveBtn.onclick = () => approveContact(contact, index);
actionsDiv.appendChild(approveBtn);
// Map button (only if GPS coordinates available)
if (contact.adv_lat && contact.adv_lon && (contact.adv_lat !== 0 || contact.adv_lon !== 0)) {
const mapBtn = document.createElement('button');
mapBtn.className = 'btn btn-outline-primary btn-action';
mapBtn.innerHTML = '<i class="bi bi-geo-alt"></i> Map';
mapBtn.onclick = () => openGoogleMaps(contact.adv_lat, contact.adv_lon);
actionsDiv.appendChild(mapBtn);
}
// Copy key button
const copyBtn = document.createElement('button');
copyBtn.className = 'btn btn-outline-secondary btn-action';
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy Full Key';
copyBtn.innerHTML = '<i class="bi bi-clipboard"></i> Copy Key';
copyBtn.onclick = () => copyPublicKey(contact.public_key, copyBtn);
actionsDiv.appendChild(approveBtn);
actionsDiv.appendChild(copyBtn);
card.appendChild(nameDiv);
// Assemble card
card.appendChild(infoRow);
card.appendChild(keyDiv);
if (lastAdvertDiv) card.appendChild(lastAdvertDiv);
card.appendChild(actionsDiv);
return card;
@@ -729,6 +827,169 @@ function copyPublicKey(publicKey, buttonEl) {
});
}
// =============================================================================
// Pending Page - Filtering and Batch Approval
// =============================================================================
function applyPendingFilters() {
const searchInput = document.getElementById('pendingSearchInput');
const searchTerm = searchInput ? searchInput.value.toLowerCase() : '';
// Get selected types
const selectedTypes = [];
if (document.getElementById('typeFilterCLI')?.checked) selectedTypes.push('CLI');
if (document.getElementById('typeFilterREP')?.checked) selectedTypes.push('REP');
if (document.getElementById('typeFilterROOM')?.checked) selectedTypes.push('ROOM');
if (document.getElementById('typeFilterSENS')?.checked) selectedTypes.push('SENS');
// Filter contacts
filteredPendingContacts = pendingContacts.filter(contact => {
// Type filter
if (selectedTypes.length > 0 && !selectedTypes.includes(contact.type_label)) {
return false;
}
// Search filter (name or public_key_prefix)
if (searchTerm) {
const nameMatch = contact.name.toLowerCase().includes(searchTerm);
const keyMatch = (contact.public_key_prefix || contact.public_key).toLowerCase().includes(searchTerm);
return nameMatch || keyMatch;
}
return true;
});
// Update filtered count badge
const countBadge = document.getElementById('filteredCountBadge');
if (countBadge) {
countBadge.textContent = filteredPendingContacts.length;
}
// Render filtered list
renderPendingList(filteredPendingContacts);
}
function showBatchApprovalModal() {
if (filteredPendingContacts.length === 0) {
showToast('No contacts to approve', 'warning');
return;
}
const modal = new bootstrap.Modal(document.getElementById('batchApprovalModal'));
const countEl = document.getElementById('batchApprovalCount');
const listEl = document.getElementById('batchApprovalList');
// Update count
if (countEl) countEl.textContent = filteredPendingContacts.length;
// Populate list
if (listEl) {
listEl.innerHTML = '';
filteredPendingContacts.forEach(contact => {
const item = document.createElement('div');
item.className = 'list-group-item d-flex justify-content-between align-items-center';
const nameSpan = document.createElement('span');
nameSpan.textContent = contact.name;
const typeBadge = document.createElement('span');
typeBadge.className = 'badge';
typeBadge.textContent = contact.type_label;
switch (contact.type_label) {
case 'CLI':
typeBadge.classList.add('bg-primary');
break;
case 'REP':
typeBadge.classList.add('bg-success');
break;
case 'ROOM':
typeBadge.classList.add('bg-info');
break;
case 'SENS':
typeBadge.classList.add('bg-warning', 'text-dark');
break;
default:
typeBadge.classList.add('bg-secondary');
}
item.appendChild(nameSpan);
item.appendChild(typeBadge);
listEl.appendChild(item);
});
}
modal.show();
}
async function batchApproveContacts() {
const modal = bootstrap.Modal.getInstance(document.getElementById('batchApprovalModal'));
const confirmBtn = document.getElementById('confirmBatchApprovalBtn');
if (confirmBtn) confirmBtn.disabled = true;
let successCount = 0;
let failedCount = 0;
const failures = [];
// Approve contacts one by one (sequential HTTP requests)
for (let i = 0; i < filteredPendingContacts.length; i++) {
const contact = filteredPendingContacts[i];
// Update button with progress
if (confirmBtn) {
confirmBtn.innerHTML = `<i class="bi bi-hourglass-split"></i> Approving ${i + 1}/${filteredPendingContacts.length}...`;
}
try {
const response = await fetch('/api/contacts/pending/approve', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
public_key: contact.public_key
})
});
const data = await response.json();
if (data.success) {
successCount++;
} else {
failedCount++;
failures.push({ name: contact.name, error: data.error });
}
} catch (error) {
failedCount++;
failures.push({ name: contact.name, error: error.message });
}
}
// Close modal
if (modal) modal.hide();
// Show result
if (successCount > 0 && failedCount === 0) {
showToast(`Successfully approved ${successCount} contact${successCount !== 1 ? 's' : ''}`, 'success');
} else if (successCount > 0 && failedCount > 0) {
showToast(`Approved ${successCount}, failed ${failedCount}. Check console for details.`, 'warning');
console.error('Failed approvals:', failures);
} else {
showToast(`Failed to approve contacts. Check console for details.`, 'danger');
console.error('Failed approvals:', failures);
}
// Reload pending list
loadPendingContacts();
// Re-enable button
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.innerHTML = '<i class="bi bi-check-circle-fill"></i> Approve All';
}
}
// =============================================================================
// Toast Notifications
// =============================================================================

View File

@@ -22,6 +22,60 @@
</button>
</div>
<!-- Filter Toolbar -->
<div class="mb-3">
<div class="card">
<div class="card-body p-3">
<h6 class="mb-3"><i class="bi bi-funnel"></i> Filters</h6>
<!-- Name Search -->
<div class="mb-3">
<input type="text" class="form-control" id="pendingSearchInput"
placeholder="Search by name or public key...">
</div>
<!-- Type Filter Checkboxes -->
<div class="mb-3">
<label class="form-label small text-muted">Contact Types:</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="typeFilterCLI" value="CLI" checked>
<label class="form-check-label" for="typeFilterCLI">
<span class="badge bg-primary">CLI</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="typeFilterREP" value="REP">
<label class="form-check-label" for="typeFilterREP">
<span class="badge bg-success">REP</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="typeFilterROOM" value="ROOM">
<label class="form-check-label" for="typeFilterROOM">
<span class="badge bg-info">ROOM</span>
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="typeFilterSENS" value="SENS">
<label class="form-check-label" for="typeFilterSENS">
<span class="badge bg-warning text-dark">SENS</span>
</label>
</div>
</div>
</div>
<!-- Batch Approval Button -->
<div class="d-flex gap-2">
<button class="btn btn-success flex-grow-1" id="addFilteredBtn">
<i class="bi bi-check-circle-fill"></i> Add Filtered
<span class="badge bg-light text-dark ms-2" id="filteredCountBadge">0</span>
</button>
</div>
</div>
</div>
</div>
<!-- Page Description -->
<div class="mb-3">
<p class="text-muted small mb-0">
@@ -56,4 +110,36 @@
<span id="pendingErrorMessage">Failed to load pending contacts</span>
</div>
</div>
<!-- Batch Approval Confirmation Modal -->
<div class="modal fade" id="batchApprovalModal" tabindex="-1" aria-labelledby="batchApprovalModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-success text-white">
<h5 class="modal-title" id="batchApprovalModalLabel">
<i class="bi bi-check-circle"></i> Confirm Batch Approval
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-2">You are about to approve <strong id="batchApprovalCount">0</strong> contacts:</p>
<!-- List of contacts to approve -->
<div class="list-group mb-3" id="batchApprovalList" style="max-height: 300px; overflow-y: auto;">
<!-- Populated dynamically by JavaScript -->
</div>
<div class="alert alert-info mb-0">
<i class="bi bi-info-circle"></i> These contacts will be added to your device and can receive/send messages.
</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-success" id="confirmBatchApprovalBtn">
<i class="bi bi-check-circle-fill"></i> Approve All
</button>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -538,10 +538,19 @@ def get_pending_contacts():
{
"success": true,
"pending": [
{"name": "Skyllancer", "public_key": "f9ef..."},
{"name": "KRA Reksio mob2🐕", "public_key": "41d5..."}
{
"name": "KRK - WD 🔌",
"public_key": "2d86b4a7...",
"type": 2,
"adv_lat": 50.02377,
"adv_lon": 19.96038,
"last_advert": 1715889153,
"lastmod": 1716372319,
"out_path_len": -1,
"out_path": ""
}
],
"raw_stdout": "..."
"count": 1
}
"""
try:
@@ -553,8 +562,8 @@ def get_pending_contacts():
'pending': []
}), 503
# Execute pending_contacts command
result = meshcli_session.execute_command(['pending_contacts'], timeout=DEFAULT_TIMEOUT)
# Execute .pending_contacts command (JSON format)
result = meshcli_session.execute_command(['.pending_contacts'], timeout=DEFAULT_TIMEOUT)
if not result['success']:
return jsonify({
@@ -564,44 +573,52 @@ def get_pending_contacts():
'raw_stdout': result.get('stdout', '')
}), 200
# Parse stdout
# Parse JSON stdout using brace-matching (handles prettified multi-line JSON)
stdout = result.get('stdout', '').strip()
pending = []
if stdout:
for line in stdout.split('\n'):
line = line.strip()
# Use brace-matching to extract complete JSON objects
depth = 0
start_idx = None
# Skip empty lines
if not line:
continue
for i, char in enumerate(stdout):
if char == '{':
if depth == 0:
start_idx = i
depth += 1
elif char == '}':
depth -= 1
if depth == 0 and start_idx is not None:
json_str = stdout[start_idx:i+1]
try:
# Parse the JSON object (nested dict structure)
parsed = json.loads(json_str)
# Skip JSON lines (adverts, messages, or other JSON output from meshcli)
if line.startswith('{') or line.startswith('['):
continue
# Skip meshcli prompt lines (e.g., "MarWoj|*")
if line.endswith('|*'):
continue
# Parse lines with format: "Name: <hex_public_key>"
if ':' in line:
parts = line.split(':', 1)
if len(parts) == 2:
name = parts[0].strip()
public_key = parts[1].strip().replace(' ', '') # Remove spaces from hex
# Additional validation: pubkey should be hex characters only
if name and public_key and all(c in '0123456789abcdefABCDEF' for c in public_key):
pending.append({
'name': name,
'public_key': public_key
})
# Extract contacts from nested structure
# Format: {public_key_hash: {public_key, type, adv_name, ...}}
if isinstance(parsed, dict):
for key_hash, contact_data in parsed.items():
if isinstance(contact_data, dict) and 'public_key' in contact_data:
pending.append({
'name': contact_data.get('adv_name', 'Unknown'),
'public_key': contact_data.get('public_key', ''),
'type': contact_data.get('type', 1),
'adv_lat': contact_data.get('adv_lat', 0.0),
'adv_lon': contact_data.get('adv_lon', 0.0),
'last_advert': contact_data.get('last_advert', 0),
'lastmod': contact_data.get('lastmod', 0),
'out_path_len': contact_data.get('out_path_len', -1),
'out_path': contact_data.get('out_path', '')
})
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse pending contact JSON: {e}")
start_idx = None
return jsonify({
'success': True,
'pending': pending,
'raw_stdout': stdout
'count': len(pending)
}), 200
except Exception as e: