diff --git a/README.md b/README.md index e35224f..3ffe454 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/meshcore/cli.py b/app/meshcore/cli.py index aaf3b5e..3adea6e 100644 --- a/app/meshcore/cli.py +++ b/app/meshcore/cli.py @@ -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: diff --git a/app/routes/api.py b/app/routes/api.py index 462a69b..c2fa592 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -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" }, ... ], diff --git a/app/static/js/contacts.js b/app/static/js/contacts.js index 2554e60..9440fa7 100644 --- a/app/static/js/contacts.js +++ b/app/static/js/contacts.js @@ -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 = ` + +

No contacts match filters

+ Try changing your filter criteria + `; + 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 = ' 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 = ' 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 = ' Copy Full Key'; + copyBtn.innerHTML = ' 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 = ` 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 = ' Approve All'; + } +} + // ============================================================================= // Toast Notifications // ============================================================================= diff --git a/app/templates/contacts-pending.html b/app/templates/contacts-pending.html index 5869800..606137b 100644 --- a/app/templates/contacts-pending.html +++ b/app/templates/contacts-pending.html @@ -22,6 +22,60 @@ + +
+
+
+
Filters
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+
+
+

@@ -56,4 +110,36 @@ Failed to load pending contacts

+ + + {% endblock %} diff --git a/meshcore-bridge/bridge.py b/meshcore-bridge/bridge.py index 1a55c9c..ae967ca 100644 --- a/meshcore-bridge/bridge.py +++ b/meshcore-bridge/bridge.py @@ -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: " - 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: