mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-08-07 01:03:15 +02: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:
+37
-3
@@ -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:
|
||||
|
||||
+13
-3
@@ -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"
|
||||
},
|
||||
...
|
||||
],
|
||||
|
||||
+273
-12
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user