mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-03-28 17:42:45 +01:00
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:
36
README.md
36
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
...
|
||||
],
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user