feat(contacts): Redesign cleanup tool with advanced filtering

Replace hours-based cleanup with multi-criteria contact filtering system:
- Add name filter (partial match, case-insensitive)
- Add contact type selection (CLI, REP, ROOM, SENS)
- Add date field selector (last_advert vs lastmod)
- Add days of inactivity filter (0 = ignore)
- Add path length filter (> X, 0 = ignore)
- Implement preview modal before deletion
- Support all contact types (CLI, REP, ROOM, SENS)

Backend changes:
- Add POST /api/contacts/preview-cleanup endpoint
- Refactor POST /api/contacts/cleanup with new filter logic
- Implement _filter_contacts_by_criteria helper function
- Use last_advert (reliable) instead of lastmod (unreliable)

Frontend changes:
- Move cleanup section below Existing Contacts
- Add collapsible Advanced Filters section
- Replace handleCleanupInactive with handleCleanupPreview/Confirm
- Add confirmation modal with contact list preview
- Display deletion results (success/failure counts)

Documentation updates:
- Update API endpoints in CLAUDE.md
- Update README.md with new cleanup instructions
- Remove deprecated MC_INACTIVE_HOURS from docs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
MarekWo
2026-01-01 14:25:29 +01:00
parent b17542e0f1
commit cd65125b40
4 changed files with 588 additions and 73 deletions

View File

@@ -28,7 +28,7 @@ A lightweight web interface for meshcore-cli, providing browser-based access to
- **Smart filtering:** Search by name/key, filter by contact type (CLI, REP, ROOM, SENS)
- **Activity indicators:** Visual status icons (🟢 active, 🟡 recent, 🔴 inactive) based on last advertisement
- **GPS location:** View contact location on Google Maps (when GPS coordinates available)
- **Cleanup tool:** Remove inactive contacts with configurable threshold (moved from Settings)
- **Advanced cleanup tool:** Filter and remove contacts by name, type (CLI/REP/ROOM/SENS), inactivity period, and path length with preview before deletion
- 📦 **Message archiving** - Automatic daily archiving with browse-by-date selector
-**Efficient polling** - Lightweight update checks every 10s, UI refreshes only when needed
- 📡 **Network commands** - Send advertisement (advert) or flood advertisement (floodadv) for network management
@@ -111,7 +111,6 @@ All configuration is done via environment variables in the `.env` file.
| `MC_SERIAL_PORT` | Serial device path (use /dev/serial/by-id/ for stability) | `/dev/ttyUSB0` |
| `MC_DEVICE_NAME` | Device name (for .msgs and .adverts.jsonl files) | `MeshCore` |
| `MC_CONFIG_DIR` | Configuration directory (shared between containers) | `./data/meshcore` |
| `MC_INACTIVE_HOURS` | Inactivity threshold for contact cleanup (hours) | `48` |
| `MC_ARCHIVE_DIR` | Archive directory path | `./data/archive` |
| `MC_ARCHIVE_ENABLED` | Enable automatic archiving | `true` |
| `MC_ARCHIVE_RETENTION_DAYS` | Days to show in live view | `7` |
@@ -477,11 +476,24 @@ docker compose logs -f meshcore-bridge
### Managing Contacts (Cleanup)
Access the settings panel to clean up inactive contacts:
1. Click the settings icon
2. Adjust the inactivity threshold (default: 48 hours)
3. Click "Clean Inactive Contacts"
4. Confirm the action
The advanced cleanup tool allows you to filter and remove contacts based on multiple criteria:
1. Navigate to **Contact Management** page (from slide-out menu)
2. Scroll to **Cleanup Contacts** section (below Existing Contacts)
3. Configure filters:
- **Name Filter:** Enter partial contact name to search (optional)
- **Advanced Filters** (collapsible):
- **Contact Types:** Select which types to include (CLI, REP, ROOM, SENS)
- **Date Field:** Choose between "Last Advert" (recommended) or "Last Modified"
- **Days of Inactivity:** Contacts inactive for more than X days (0 = ignore)
- **Path Length >:** Contacts with path length greater than X (0 = ignore)
4. Click **Preview Cleanup** to see matching contacts
5. Review the list and confirm deletion
**Example use cases:**
- Remove all REP contacts inactive for 30+ days: Select REP, set days to 30
- Clean specific contact names: Enter partial name (e.g., "test")
- Remove distant contacts: Set path length > 5
### Network Commands

View File

@@ -256,42 +256,299 @@ def get_contacts():
}), 500
@api_bp.route('/contacts/cleanup', methods=['POST'])
def cleanup_contacts():
def _filter_contacts_by_criteria(contacts: list, criteria: dict) -> list:
"""
Clean up inactive contacts.
Filter contacts based on cleanup criteria.
JSON body:
hours (int): Inactivity threshold in hours (optional, default from config)
Args:
contacts: List of contact dicts from /api/contacts/detailed
criteria: Filter criteria:
- name_filter (str): Partial name match (empty = ignore)
- types (list[int]): Contact types to include [1,2,3,4]
- date_field (str): "last_advert" or "lastmod"
- days (int): Days of inactivity (0 = ignore)
- path_len (int): Minimum path length, >X (0 = ignore)
Returns:
JSON with cleanup result
List of contacts matching criteria
"""
name_filter = criteria.get('name_filter', '').strip().lower()
selected_types = criteria.get('types', [1, 2, 3, 4])
date_field = criteria.get('date_field', 'last_advert')
days = criteria.get('days', 0)
path_len = criteria.get('path_len', 0)
# Calculate timestamp threshold for days filter
current_time = int(time.time())
days_threshold = days * 86400 # Convert days to seconds
filtered = []
for contact in contacts:
# Filter by type
if contact.get('type') not in selected_types:
continue
# Filter by name (partial match, case-insensitive)
if name_filter:
contact_name = contact.get('name', '').lower()
if name_filter not in contact_name:
continue
# Filter by date (days of inactivity)
if days > 0:
timestamp = contact.get(date_field, 0)
if timestamp == 0:
# No timestamp - consider as inactive
pass
else:
# Check if inactive for more than specified days
age_seconds = current_time - timestamp
if age_seconds <= days_threshold:
# Still active within threshold
continue
# Filter by path length (> path_len)
if path_len > 0:
contact_path_len = contact.get('out_path_len', -1)
if contact_path_len <= path_len:
continue
# Contact matches all criteria
filtered.append(contact)
return filtered
@api_bp.route('/contacts/preview-cleanup', methods=['POST'])
def preview_cleanup_contacts():
"""
Preview contacts that will be deleted based on filter criteria.
JSON body:
{
"name_filter": "", # Partial name match (empty = ignore)
"types": [1, 2, 3, 4], # Contact types (1=CLI, 2=REP, 3=ROOM, 4=SENS)
"date_field": "last_advert", # "last_advert" or "lastmod"
"days": 2, # Days of inactivity (0 = ignore)
"path_len": 0 # Path length > X (0 = ignore)
}
Returns:
JSON with preview of contacts to be deleted:
{
"success": true,
"contacts": [...],
"count": 15
}
"""
try:
data = request.get_json() or {}
hours = data.get('hours', config.MC_INACTIVE_HOURS)
if not isinstance(hours, int) or hours < 1:
# Validate criteria
criteria = {
'name_filter': data.get('name_filter', ''),
'types': data.get('types', [1, 2, 3, 4]),
'date_field': data.get('date_field', 'last_advert'),
'days': data.get('days', 0),
'path_len': data.get('path_len', 0)
}
# Validate types
if not isinstance(criteria['types'], list) or not all(t in [1, 2, 3, 4] for t in criteria['types']):
return jsonify({
'success': False,
'error': 'Invalid hours value (must be positive integer)'
'error': 'Invalid types (must be list of 1, 2, 3, 4)'
}), 400
# Execute cleanup command
success, message = cli.clean_inactive_contacts(hours)
if success:
return jsonify({
'success': True,
'message': f'Cleanup completed: {message}',
'hours': hours
}), 200
else:
# Validate date_field
if criteria['date_field'] not in ['last_advert', 'lastmod']:
return jsonify({
'success': False,
'error': message
'error': 'Invalid date_field (must be "last_advert" or "lastmod")'
}), 400
# Validate numeric fields
if not isinstance(criteria['days'], int) or criteria['days'] < 0:
return jsonify({
'success': False,
'error': 'Invalid days (must be non-negative integer)'
}), 400
if not isinstance(criteria['path_len'], int) or criteria['path_len'] < 0:
return jsonify({
'success': False,
'error': 'Invalid path_len (must be non-negative integer)'
}), 400
# Get all contacts
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
if not success_detailed:
return jsonify({
'success': False,
'error': error_detailed or 'Failed to get contacts'
}), 500
# Convert to list format (same as /api/contacts/detailed)
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
contacts = []
for public_key, details in contacts_detailed.items():
out_path_len = details.get('out_path_len', -1)
contacts.append({
'public_key': public_key,
'name': details.get('adv_name', ''),
'type': details.get('type'),
'type_label': type_labels.get(details.get('type'), 'UNKNOWN'),
'last_advert': details.get('last_advert'),
'lastmod': details.get('lastmod'),
'out_path_len': out_path_len,
'out_path': details.get('out_path', ''),
'adv_lat': details.get('adv_lat'),
'adv_lon': details.get('adv_lon')
})
# Filter contacts
filtered_contacts = _filter_contacts_by_criteria(contacts, criteria)
return jsonify({
'success': True,
'contacts': filtered_contacts,
'count': len(filtered_contacts)
}), 200
except Exception as e:
logger.error(f"Error previewing cleanup: {e}")
return jsonify({
'success': False,
'error': str(e)
}), 500
@api_bp.route('/contacts/cleanup', methods=['POST'])
def cleanup_contacts():
"""
Clean up contacts based on filter criteria.
JSON body:
{
"name_filter": "", # Partial name match (empty = ignore)
"types": [1, 2, 3, 4], # Contact types (1=CLI, 2=REP, 3=ROOM, 4=SENS)
"date_field": "last_advert", # "last_advert" or "lastmod"
"days": 2, # Days of inactivity (0 = ignore)
"path_len": 0 # Path length > X (0 = ignore)
}
Returns:
JSON with cleanup result:
{
"success": true,
"deleted_count": 15,
"failed_count": 2,
"failures": [
{"name": "Contact1", "error": "..."},
...
]
}
"""
try:
data = request.get_json() or {}
# Validate criteria (same as preview)
criteria = {
'name_filter': data.get('name_filter', ''),
'types': data.get('types', [1, 2, 3, 4]),
'date_field': data.get('date_field', 'last_advert'),
'days': data.get('days', 0),
'path_len': data.get('path_len', 0)
}
# Validate types
if not isinstance(criteria['types'], list) or not all(t in [1, 2, 3, 4] for t in criteria['types']):
return jsonify({
'success': False,
'error': 'Invalid types (must be list of 1, 2, 3, 4)'
}), 400
# Validate date_field
if criteria['date_field'] not in ['last_advert', 'lastmod']:
return jsonify({
'success': False,
'error': 'Invalid date_field (must be "last_advert" or "lastmod")'
}), 400
# Validate numeric fields
if not isinstance(criteria['days'], int) or criteria['days'] < 0:
return jsonify({
'success': False,
'error': 'Invalid days (must be non-negative integer)'
}), 400
if not isinstance(criteria['path_len'], int) or criteria['path_len'] < 0:
return jsonify({
'success': False,
'error': 'Invalid path_len (must be non-negative integer)'
}), 400
# Get all contacts
success_detailed, contacts_detailed, error_detailed = cli.get_contacts_with_last_seen()
if not success_detailed:
return jsonify({
'success': False,
'error': error_detailed or 'Failed to get contacts'
}), 500
# Convert to list format
type_labels = {1: 'CLI', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
contacts = []
for public_key, details in contacts_detailed.items():
out_path_len = details.get('out_path_len', -1)
contacts.append({
'public_key': public_key,
'name': details.get('adv_name', ''),
'type': details.get('type'),
'type_label': type_labels.get(details.get('type'), 'UNKNOWN'),
'last_advert': details.get('last_advert'),
'lastmod': details.get('lastmod'),
'out_path_len': out_path_len
})
# Filter contacts to delete
filtered_contacts = _filter_contacts_by_criteria(contacts, criteria)
if len(filtered_contacts) == 0:
return jsonify({
'success': True,
'message': 'No contacts matched the criteria',
'deleted_count': 0,
'failed_count': 0,
'failures': []
}), 200
# Delete contacts one by one, track failures
deleted_count = 0
failed_count = 0
failures = []
for contact in filtered_contacts:
contact_name = contact['name']
success, message = cli.delete_contact(contact_name)
if success:
deleted_count += 1
else:
failed_count += 1
failures.append({
'name': contact_name,
'error': message
})
return jsonify({
'success': True,
'message': f'Cleanup completed: {deleted_count} deleted, {failed_count} failed',
'deleted_count': deleted_count,
'failed_count': failed_count,
'failures': failures
}), 200
except Exception as e:
logger.error(f"Error cleaning contacts: {e}")
return jsonify({

View File

@@ -96,10 +96,16 @@ function attachManageEventListeners() {
approvalSwitch.addEventListener('change', handleApprovalToggle);
}
// Cleanup button
const cleanupBtn = document.getElementById('cleanupBtn');
if (cleanupBtn) {
cleanupBtn.addEventListener('click', handleCleanupInactive);
// Cleanup preview button
const cleanupPreviewBtn = document.getElementById('cleanupPreviewBtn');
if (cleanupPreviewBtn) {
cleanupPreviewBtn.addEventListener('click', handleCleanupPreview);
}
// Cleanup confirm button (in modal)
const confirmCleanupBtn = document.getElementById('confirmCleanupBtn');
if (confirmCleanupBtn) {
confirmCleanupBtn.addEventListener('click', handleCleanupConfirm);
}
}
@@ -142,28 +148,160 @@ async function loadContactCounts() {
}
}
async function handleCleanupInactive() {
const hoursInput = document.getElementById('inactiveHours');
const cleanupBtn = document.getElementById('cleanupBtn');
// Global variable to store preview contacts
let cleanupPreviewContacts = [];
if (!hoursInput || !cleanupBtn) return;
function collectCleanupCriteria() {
/**
* Collect cleanup filter criteria from form inputs.
*
* Returns:
* Object with criteria: {name_filter, types, date_field, days, path_len}
*/
// Name filter
const nameFilter = document.getElementById('cleanupNameFilter')?.value?.trim() || '';
const hours = parseInt(hoursInput.value);
// Selected types (checked checkboxes)
const typeCheckboxes = document.querySelectorAll('.cleanup-type-filter:checked');
const types = Array.from(typeCheckboxes).map(cb => parseInt(cb.value));
if (isNaN(hours) || hours < 1) {
showToast('Please enter a valid number of hours', 'warning');
// Date field (radio button)
const dateFieldRadio = document.querySelector('input[name="cleanupDateField"]:checked');
const dateField = dateFieldRadio?.value || 'last_advert';
// Days of inactivity
const days = parseInt(document.getElementById('cleanupDays')?.value) || 0;
// Path length
const pathLen = parseInt(document.getElementById('cleanupPathLen')?.value) || 0;
return {
name_filter: nameFilter,
types: types,
date_field: dateField,
days: days,
path_len: pathLen
};
}
async function handleCleanupPreview() {
const previewBtn = document.getElementById('cleanupPreviewBtn');
if (!previewBtn) return;
// Collect filter criteria
const criteria = collectCleanupCriteria();
// Validate: at least one type must be selected
if (criteria.types.length === 0) {
showToast('Please select at least one contact type', 'warning');
return;
}
// Confirm action
if (!confirm(`This will remove all contacts inactive for more than ${hours} hours. Continue?`)) {
return;
// Disable button during preview
const originalHTML = previewBtn.innerHTML;
previewBtn.disabled = true;
previewBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Loading...';
try {
const response = await fetch('/api/contacts/preview-cleanup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(criteria)
});
const data = await response.json();
if (data.success) {
cleanupPreviewContacts = data.contacts || [];
if (cleanupPreviewContacts.length === 0) {
showToast('No contacts match the selected criteria', 'info');
return;
}
// Populate modal with preview
populateCleanupModal(cleanupPreviewContacts);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('cleanupConfirmModal'));
modal.show();
} else {
showToast('Preview failed: ' + (data.error || 'Unknown error'), 'danger');
}
} catch (error) {
console.error('Error during cleanup preview:', error);
showToast('Network error during preview', 'danger');
} finally {
// Re-enable button
previewBtn.disabled = false;
previewBtn.innerHTML = originalHTML;
}
}
function populateCleanupModal(contacts) {
/**
* Populate cleanup confirmation modal with list of contacts.
*/
const countEl = document.getElementById('cleanupContactCount');
const listEl = document.getElementById('cleanupContactList');
if (countEl) {
countEl.textContent = contacts.length;
}
// Disable button during operation
const originalHTML = cleanupBtn.innerHTML;
cleanupBtn.disabled = true;
cleanupBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Cleaning...';
if (listEl) {
listEl.innerHTML = '';
contacts.forEach(contact => {
const item = document.createElement('div');
item.className = 'list-group-item';
// Format last seen time
const lastSeenTimestamp = contact.last_advert || 0;
const lastSeenText = lastSeenTimestamp > 0 ? formatRelativeTime(lastSeenTimestamp) : 'Never';
item.innerHTML = `
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${escapeHtml(contact.name)}</strong>
<br>
<small class="text-muted">
Type: <span class="badge bg-secondary">${contact.type_label}</span>
| Last advert: ${lastSeenText}
</small>
</div>
</div>
`;
listEl.appendChild(item);
});
}
}
function escapeHtml(text) {
/**
* Escape HTML special characters to prevent XSS.
*/
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async function handleCleanupConfirm() {
const confirmBtn = document.getElementById('confirmCleanupBtn');
const modal = bootstrap.Modal.getInstance(document.getElementById('cleanupConfirmModal'));
if (!confirmBtn) return;
// Collect criteria again (in case user changed filters)
const criteria = collectCleanupCriteria();
// Disable button during cleanup
const originalHTML = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<i class="bi bi-hourglass-split"></i> Deleting...';
try {
const response = await fetch('/api/contacts/cleanup', {
@@ -171,17 +309,34 @@ async function handleCleanupInactive() {
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
inactive_hours: hours
})
body: JSON.stringify(criteria)
});
const data = await response.json();
if (data.success) {
showToast(data.message || 'Cleanup completed successfully', 'success');
// Hide modal first
if (modal) modal.hide();
// Show success message
let message = `Cleanup completed: ${data.deleted_count} deleted`;
if (data.failed_count > 0) {
message += `, ${data.failed_count} failed`;
}
showToast(message, data.failed_count > 0 ? 'warning' : 'success');
// Show failures if any
if (data.failures && data.failures.length > 0) {
console.error('Cleanup failures:', data.failures);
// Optionally show detailed failure list to user
}
// Reload contact counts
loadContactCounts();
// Clear preview
cleanupPreviewContacts = [];
} else {
showToast('Cleanup failed: ' + (data.error || 'Unknown error'), 'danger');
}
@@ -190,8 +345,8 @@ async function handleCleanupInactive() {
showToast('Network error during cleanup', 'danger');
} finally {
// Re-enable button
cleanupBtn.disabled = false;
cleanupBtn.innerHTML = originalHTML;
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalHTML;
}
}

View File

@@ -37,28 +37,6 @@
title="When enabled, new contacts must be manually approved before they can communicate with your node"></i>
</div>
<!-- Cleanup Inactive Contacts Section -->
<div class="cleanup-section">
<h6>
<i class="bi bi-trash"></i>
<span>Cleanup Inactive Contacts</span>
<i class="bi bi-info-circle info-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Remove contacts that haven't sent an advertisement in a specified time period"></i>
</h6>
<div class="mb-3">
<label for="inactiveHours" class="form-label">Remove contacts inactive for:</label>
<div class="input-group">
<input type="number" class="form-control" id="inactiveHours" value="48" min="1">
<span class="input-group-text">hours</span>
</div>
</div>
<button class="btn btn-danger" id="cleanupBtn">
<i class="bi bi-trash"></i> Clean Inactive Contacts
</button>
</div>
<!-- Navigation Section -->
<div class="mb-4">
<h5 class="mb-3">
@@ -87,5 +65,118 @@
</span>
</div>
</div>
<!-- Cleanup Contacts Section -->
<div class="cleanup-section">
<h6>
<i class="bi bi-trash"></i>
<span>Cleanup Contacts</span>
<i class="bi bi-info-circle info-icon"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Remove contacts based on various filter criteria"></i>
</h6>
<!-- Basic Filters (Always visible) -->
<div class="mb-3">
<label for="cleanupNameFilter" class="form-label">Name Filter (optional):</label>
<input type="text" class="form-control" id="cleanupNameFilter" placeholder="Enter partial name to search...">
</div>
<!-- Advanced Filters (Collapsible) -->
<div class="mb-3">
<button class="btn btn-sm btn-outline-secondary w-100" type="button" data-bs-toggle="collapse" data-bs-target="#advancedFilters" aria-expanded="false" aria-controls="advancedFilters">
<i class="bi bi-sliders"></i> Advanced Filters
</button>
</div>
<div class="collapse" id="advancedFilters">
<!-- Contact Types -->
<div class="mb-3">
<label class="form-label">Contact Types:</label>
<div class="d-flex flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input cleanup-type-filter" type="checkbox" value="1" id="cleanupTypeCLI" checked>
<label class="form-check-label" for="cleanupTypeCLI">CLI</label>
</div>
<div class="form-check">
<input class="form-check-input cleanup-type-filter" type="checkbox" value="2" id="cleanupTypeREP" checked>
<label class="form-check-label" for="cleanupTypeREP">REP</label>
</div>
<div class="form-check">
<input class="form-check-input cleanup-type-filter" type="checkbox" value="3" id="cleanupTypeROOM" checked>
<label class="form-check-label" for="cleanupTypeROOM">ROOM</label>
</div>
<div class="form-check">
<input class="form-check-input cleanup-type-filter" type="checkbox" value="4" id="cleanupTypeSENS" checked>
<label class="form-check-label" for="cleanupTypeSENS">SENS</label>
</div>
</div>
</div>
<!-- Date Field -->
<div class="mb-3">
<label class="form-label">Date Field:</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="cleanupDateField" id="cleanupDateLastAdvert" value="last_advert" checked>
<label class="form-check-label" for="cleanupDateLastAdvert">Last Advert</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="cleanupDateField" id="cleanupDateLastMod" value="lastmod">
<label class="form-check-label" for="cleanupDateLastMod">Last Modified</label>
</div>
</div>
</div>
<!-- Days of Inactivity -->
<div class="mb-3">
<label for="cleanupDays" class="form-label">Days of Inactivity (0 = ignore):</label>
<input type="number" class="form-control" id="cleanupDays" value="2" min="0">
<small class="form-text text-muted">Contacts inactive for more than this many days will be selected</small>
</div>
<!-- Path Length -->
<div class="mb-3">
<label for="cleanupPathLen" class="form-label">Path Length &gt; (0 = ignore):</label>
<input type="number" class="form-control" id="cleanupPathLen" value="0" min="0">
<small class="form-text text-muted">Select contacts with path length greater than this value</small>
</div>
</div>
<button class="btn btn-warning" id="cleanupPreviewBtn">
<i class="bi bi-eye"></i> Preview Cleanup
</button>
</div>
</div>
<!-- Cleanup Confirmation Modal -->
<div class="modal fade" id="cleanupConfirmModal" tabindex="-1" aria-labelledby="cleanupConfirmModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header bg-warning">
<h5 class="modal-title" id="cleanupConfirmModalLabel">
<i class="bi bi-exclamation-triangle"></i> Confirm Contact Cleanup
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="fw-bold">The following <span id="cleanupContactCount">0</span> contact(s) will be permanently deleted:</p>
<div id="cleanupContactList" class="list-group mb-3" style="max-height: 300px; overflow-y: auto;">
<!-- Contact list will be populated here -->
</div>
<div class="alert alert-warning mb-0">
<i class="bi bi-exclamation-circle"></i>
<strong>Warning:</strong> This action cannot be undone!
</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-danger" id="confirmCleanupBtn">
<i class="bi bi-trash"></i> Delete Contacts
</button>
</div>
</div>
</div>
</div>
{% endblock %}