feat(contacts): implement pagination, search, and sorting for contacts API

- Enhanced the `/api/contacts` endpoint to support pagination, allowing users to specify `page` and `page_size` parameters.
- Added search functionality to filter contacts based on a search term.
- Implemented sorting options for the contacts list, enabling users to sort by various fields.
- Updated the frontend to reflect these changes, including a new pagination UI and improved loading states.
- Introduced caching for multibyte hop chunks to optimize performance when retrieving contact badge evidence.
- Added tests to ensure the new features work as expected and maintain existing functionality.
This commit is contained in:
agessaman committed 2026-07-28 12:36:56 -07:00
1 parent 8ab5708b72
commit 092be2d5bf
9 files changed
+1043 -189

No files matched your search

+218 -32
View File
@@ -256,6 +256,13 @@ class BotDataViewer:
self._db_last_used = 0
self._db_timeout = 300 # 5 minutes connection timeout
# The contacts list needs all-time multibyte hop-prefix evidence for its
# capability badge. Cache that derived set between requests and invalidate
# it when the underlying multibyte-path population changes.
self._contacts_badge_cache_lock = threading.Lock()
self._contacts_badge_cache_signature = None
self._contacts_badge_cache_chunks: set[str] = set()
# Load configuration
self.config = self._load_config(config_path)
self.config_path = config_path # kept for config.ini write-back endpoints
@@ -2084,12 +2091,34 @@ class BotDataViewer:
@self.app.route('/api/contacts')
def api_contacts():
"""Get contact data. Optional query param: since=24h|7d|30d|90d|all (default 30d)."""
"""Get filtered contact data, optionally paginated for the interactive list."""
try:
since = request.args.get('since', '30d')
if since not in ('24h', '7d', '30d', '90d', 'all'):
since = '30d'
contacts = self._get_tracking_data(since=since)
paginate = 'page' in request.args or 'page_size' in request.args
page = None
page_size = None
if paginate:
try:
page = max(1, int(request.args.get('page', '1')))
except (TypeError, ValueError):
page = 1
try:
page_size = max(1, min(200, int(request.args.get('page_size', '100'))))
except (TypeError, ValueError):
page_size = 100
search = request.args.get('search', '').strip()[:100]
sort = request.args.get('sort', 'last_seen')
direction = request.args.get('direction', 'desc').lower()
contacts = self._get_tracking_data(
since=since,
page=page,
page_size=page_size,
search=search,
sort=sort,
direction=direction,
)
return jsonify(contacts)
except Exception as e:
self.logger.error(f"Error getting contacts: {e}")
@@ -5203,7 +5232,8 @@ class BotDataViewer:
cursor.execute(
f"""
SELECT DISTINCT path_hex, bytes_per_hop FROM observed_paths
WHERE bytes_per_hop IN (2, 3) AND path_hex IS NOT NULL AND length(path_hex) > 0
WHERE bytes_per_hop >= 2 AND bytes_per_hop <= 3
AND path_hex IS NOT NULL AND length(path_hex) > 0
{extra}
"""
)
@@ -5221,6 +5251,35 @@ class BotDataViewer:
self.logger.debug(f"Could not load multibyte hop chunks: {e}")
return chunks
def _get_cached_contact_multibyte_hop_chunks(self, cursor) -> set[str]:
"""Return all-time contact badge evidence without rebuilding it per page request."""
try:
# last_seen changes when an existing path is observed again; count changes on
# inserts and retention deletes. Together they cheaply invalidate the cache while
# using the multibyte covering index rather than the wide observed_paths table.
cursor.execute(
"""
SELECT MAX(last_seen), COUNT(*) FROM observed_paths
WHERE bytes_per_hop >= 2 AND bytes_per_hop <= 3
"""
)
signature_row = cursor.fetchone()
signature = tuple(signature_row) if signature_row else (None, 0)
except Exception as e:
self.logger.debug(f"Could not fingerprint multibyte hop chunks: {e}")
return self._collect_multibyte_hop_chunks(cursor)
lock = getattr(self, '_contacts_badge_cache_lock', None)
if lock is None:
lock = self._contacts_badge_cache_lock = threading.Lock()
with lock:
if getattr(self, '_contacts_badge_cache_signature', None) == signature:
return self._contacts_badge_cache_chunks
chunks = self._collect_multibyte_hop_chunks(cursor)
self._contacts_badge_cache_signature = signature
self._contacts_badge_cache_chunks = chunks
return chunks
@staticmethod
def _bucket_hop_chunks(multibyte_hop_chunks: set[str]) -> dict[int, set[str]]:
"""Bucket hop-prefix chunks by length (4 or 6) for O(1) prefix matching.
@@ -5881,12 +5940,23 @@ class BotDataViewer:
if conn:
conn.close()
def _get_tracking_data(self, since='30d', include_detail=False):
def _get_tracking_data(
self,
since='30d',
include_detail=False,
page: int | None = None,
page_size: int | None = None,
search: str = '',
sort: str = 'last_seen',
direction: str = 'desc',
):
"""Get contact tracking data. since: 24h, 7d, 30d, 90d, or all (heard in that window).
include_detail=False (the interactive /api/contacts list) omits per-contact ``all_paths``
and ``raw_advert_data`` to keep the payload small; the UI fetches those on demand via
/api/contact-detail. include_detail=True (the export endpoint) keeps the full fields.
/api/contact-detail. The interactive route supplies ``page`` and ``page_size`` so path
enrichment is limited to visible contacts. include_detail=True (the export endpoint)
keeps the legacy full-result behavior and full fields.
"""
conn = None
try:
@@ -5908,11 +5978,75 @@ class BotDataViewer:
'30d': "'-30 days'",
'90d': "'-90 days'",
}
where_parts = []
where_params: list[Any] = []
if since in datetime_offsets:
where_clause = f" WHERE c.last_heard >= datetime('now', 'localtime', {datetime_offsets[since]})"
else: # 'all'
where_clause = ''
params = ()
where_parts.append(
f"c.last_heard >= datetime('now', 'localtime', {datetime_offsets[since]})"
)
search = (search or '').strip().lower()[:100]
if search and not include_detail:
# Match the former client-side behavior: public keys are prefix-only,
# while names, roles, device types, and locations match anywhere.
escaped = search.replace('\\', '\\\\').replace('%', '\\%').replace('_', '\\_')
where_parts.append(
"("
"LOWER(COALESCE(c.public_key, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.name, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.role, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.device_type, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.city, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.state, '')) LIKE ? ESCAPE '\\' OR "
"LOWER(COALESCE(c.country, '')) LIKE ? ESCAPE '\\'"
")"
)
where_params.extend([f'{escaped}%'] + [f'%{escaped}%'] * 6)
where_clause = (' WHERE ' + ' AND '.join(where_parts)) if where_parts else ''
pagination = None
filtered_stats = None
if page is not None and page_size is not None and not include_detail:
page_size = max(1, min(200, int(page_size)))
page = max(1, int(page))
cursor.execute(
"""
SELECT
COUNT(*) AS total_items,
SUM(CASE WHEN c.last_heard >= datetime('now', 'localtime', '-24 hours') THEN 1 ELSE 0 END) AS contacts_24h,
SUM(CASE WHEN c.last_heard >= datetime('now', 'localtime', '-7 days') THEN 1 ELSE 0 END) AS contacts_7d,
SUM(CASE WHEN c.first_heard >= datetime('now', 'localtime', '-7 days')
AND LOWER(COALESCE(c.device_type, '')) LIKE '%companion%' THEN 1 ELSE 0 END) AS new_companions,
SUM(CASE WHEN c.first_heard >= datetime('now', 'localtime', '-7 days')
AND LOWER(COALESCE(c.device_type, '')) LIKE '%repeater%' THEN 1 ELSE 0 END) AS new_repeaters,
SUM(CASE WHEN c.first_heard >= datetime('now', 'localtime', '-7 days')
AND (LOWER(COALESCE(c.device_type, '')) LIKE '%room%'
OR LOWER(COALESCE(c.device_type, '')) LIKE '%server%') THEN 1 ELSE 0 END) AS new_room_servers
FROM complete_contact_tracking c
""" + where_clause,
tuple(where_params),
)
aggregate = cursor.fetchone()
total_items = int(aggregate['total_items'] or 0)
total_pages = max(1, (total_items + page_size - 1) // page_size)
page = min(page, total_pages)
pagination = {
'page': page,
'page_size': page_size,
'total_items': total_items,
'total_pages': total_pages,
'has_previous': page > 1,
'has_next': page < total_pages,
}
filtered_stats = {
'contacts_24h': int(aggregate['contacts_24h'] or 0),
'contacts_7d': int(aggregate['contacts_7d'] or 0),
'contacts_total': total_items,
'new_companions': int(aggregate['new_companions'] or 0),
'new_repeaters': int(aggregate['new_repeaters'] or 0),
'new_room_servers': int(aggregate['new_room_servers'] or 0),
}
# Fetch contacts directly (no join/group-by). The recent paths per contact are
# loaded in a second query below and assembled in Python. This avoids materializing
@@ -5920,6 +6054,44 @@ class BotDataViewer:
# column (incl. the raw_advert_data blob) on every request. last_advert_timestamp is
# the per-contact value, so it matches the old MAX(...) over a single contact's rows.
detail_cols = "c.raw_advert_data," if include_detail else ""
sort_expressions = {
'username': "LOWER(COALESCE(c.name, ''))",
'device_type': "LOWER(COALESCE(c.device_type, ''))",
'location': (
"LOWER(CASE "
"WHEN c.city IS NOT NULL AND c.city != '' AND c.state IS NOT NULL AND c.state != '' "
"THEN c.city || ', ' || c.state "
"WHEN c.city IS NOT NULL AND c.city != '' THEN c.city "
"WHEN c.latitude IS NOT NULL AND c.longitude IS NOT NULL "
"AND c.latitude != 0 AND c.longitude != 0 THEN printf('%s, %s', c.latitude, c.longitude) "
"ELSE '' END)"
),
'snr': 'COALESCE(c.snr, 0)',
'hop_count': 'COALESCE(c.hop_count, 0)',
'first_heard': "COALESCE(c.first_heard, '')",
'last_seen': "COALESCE(c.last_heard, '')",
'advert_count': 'COALESCE(c.advert_count, 0)',
}
sort = sort if sort in (*sort_expressions.keys(), 'distance') else 'last_seen'
direction = 'asc' if direction == 'asc' else 'desc'
if sort == 'distance':
if bot_lat is None or bot_lon is None:
sort_expression = '0'
else:
conn.create_function('contacts_distance_km', 2, lambda lat, lon: (
self._calculate_distance(bot_lat, bot_lon, lat, lon)
if lat is not None and lon is not None else 0
))
sort_expression = 'contacts_distance_km(c.latitude, c.longitude)'
else:
sort_expression = sort_expressions[sort]
query_params = list(where_params)
limit_clause = ''
if pagination is not None:
limit_clause = ' LIMIT ? OFFSET ?'
query_params.extend([page_size, (page - 1) * page_size])
cursor.execute("""
SELECT
c.public_key, c.name, c.role, c.device_type,
@@ -5932,31 +6104,41 @@ class BotDataViewer:
c.last_advert_timestamp as last_message
FROM complete_contact_tracking c
""" + where_clause + """
ORDER BY c.last_heard DESC
""", params)
ORDER BY """ + sort_expression + f" {direction.upper()}, c.public_key ASC" + limit_clause,
tuple(query_params),
)
main_rows = cursor.fetchall()
# Fetch the 50 most recent advert paths per contact and group them in Python keyed by
# public_key. With idx_observed_paths_advert_pk_seen this runs as an ordered covering
# index scan (no temp B-tree). We don't filter to the windowed keys here: the assembly
# loop only looks up paths for contacts in main_rows, and adding a JOIN to the key set
# pushes the planner off the covering index into a much slower nested-loop plan.
cursor.execute("""
WITH recent_paths AS (
SELECT public_key, path_hex, path_length, bytes_per_hop,
observation_count, last_seen,
ROW_NUMBER() OVER (PARTITION BY public_key ORDER BY last_seen DESC) as rn
FROM observed_paths
WHERE packet_type = 'advert' AND public_key IS NOT NULL
)
SELECT public_key, path_hex, path_length, bytes_per_hop, observation_count, last_seen
FROM recent_paths WHERE rn <= 50
ORDER BY public_key, last_seen DESC
""")
paths_by_key = {}
for prow in cursor.fetchall():
path_rows = []
if main_rows:
path_params: list[Any] = []
page_key_clause = ''
if pagination is not None:
# The interactive list enriches only the visible page. At most 200 keys are
# supplied, staying comfortably below SQLite's parameter limit and turning
# the former all-history window scan into targeted index lookups.
page_keys = [row['public_key'] for row in main_rows]
placeholders = ','.join('?' for _ in page_keys)
page_key_clause = f' AND public_key IN ({placeholders})'
path_params.extend(page_keys)
cursor.execute("""
WITH recent_paths AS (
SELECT public_key, path_hex, path_length, bytes_per_hop,
observation_count, last_seen,
ROW_NUMBER() OVER (PARTITION BY public_key ORDER BY last_seen DESC) as rn
FROM observed_paths
WHERE packet_type = 'advert' AND public_key IS NOT NULL
""" + page_key_clause + """
)
SELECT public_key, path_hex, path_length, bytes_per_hop, observation_count, last_seen
FROM recent_paths WHERE rn <= 50
ORDER BY public_key, last_seen DESC
""", tuple(path_params))
path_rows = cursor.fetchall()
for prow in path_rows:
if not prow['path_hex']: # Skip empty paths
continue
bph = None
@@ -5975,7 +6157,7 @@ class BotDataViewer:
'last_seen': prow['last_seen'] if prow['last_seen'] is not None else None
})
multibyte_hop_chunks = self._collect_multibyte_hop_chunks(cursor)
multibyte_hop_chunks = self._get_cached_contact_multibyte_hop_chunks(cursor)
chunk_buckets = self._bucket_hop_chunks(multibyte_hop_chunks)
tracking = []
@@ -6216,10 +6398,14 @@ class BotDataViewer:
except Exception as e:
self.logger.debug(f"Could not get server stats: {e}")
return {
result = {
'tracking_data': tracking,
'server_stats': server_stats
}
if pagination is not None:
result['pagination'] = pagination
result['filtered_stats'] = filtered_stats
return result
except Exception as e:
self.logger.error(f"Error getting tracking data: {e}")
return {'error': str(e)}
@@ -66,7 +66,7 @@
("POST", "/api/optimize-database","Run VACUUM + ANALYZE on the SQLite database"),
]),
("Contacts", "fa-users", [
("GET", "/api/contacts", "List all contacts with last-seen and position"),
("GET", "/api/contacts", "List contacts; supports paging, search, and sorting"),
("GET", "/api/export/contacts", "Export contacts as CSV"),
("POST", "/api/geocode-contact", "Reverse-geocode a contact's GPS position"),
("POST", "/api/toggle-star-contact", "Star or unstar a contact"),
+135 -154
View File
@@ -496,6 +496,18 @@
<div class="d-md-none" id="contacts-mobile-stack" aria-live="polite">
<div class="loading text-center py-3">Loading contacts data...</div>
</div>
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3"
id="contacts-pagination" aria-label="Contacts pagination">
<small class="text-muted" id="contacts-page-summary">Loading contacts…</small>
<div class="btn-group btn-group-sm" role="group" aria-label="Contact pages">
<button type="button" class="btn btn-outline-secondary" id="contacts-page-previous" disabled>
<i class="fas fa-chevron-left" aria-hidden="true"></i> Previous
</button>
<button type="button" class="btn btn-outline-secondary" id="contacts-page-next" disabled>
Next <i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
</div>
</div>
@@ -540,12 +552,22 @@
<script nonce="{{ g.csp_nonce }}">
class ModernContactsManager {
constructor() {
this.contactsData = [];
this.contactsData = { tracking_data: [], server_stats: {} };
this.filteredData = [];
this.selectedContactIds = new Set();
this.searchTerm = '';
this.sortColumn = 'last_seen';
this.sortDirection = 'desc';
this.currentPage = 1;
this.pageSize = 100;
this.pagination = {
page: 1, page_size: 100, total_items: 0, total_pages: 1,
has_previous: false, has_next: false,
};
this.filteredStats = {};
this.requestController = null;
this.searchTimer = null;
this.mobileLayout = window.matchMedia('(max-width: 767.98px)');
this.activityChart = null; // Chart.js instance for activity trends
this.initializeContacts();
}
@@ -558,20 +580,36 @@ class ModernContactsManager {
el.value = (savedTimespan && valid.includes(savedTimespan)) ? savedTimespan : '30d';
}
this.syncContactsTimespanMobileUI();
await this.loadContactsData();
this.setupEventHandlers();
this.applySort();
this.updateSortIcons();
this.renderContactsData();
const toolbarWrap = document.querySelector('.contacts-toolbar-filters-wrap');
if (toolbarWrap) this.initContactsDropdowns(toolbarWrap);
this.updateStatistics();
if (this.mobileLayout.addEventListener) {
this.mobileLayout.addEventListener('change', () => this.renderContactsData());
}
await this.loadContactsData();
}
async loadContactsData() {
async loadContactsData({ resetPage = false, clearSelection = false } = {}) {
if (resetPage) this.currentPage = 1;
if (clearSelection) this.selectedContactIds.clear();
if (this.requestController) this.requestController.abort();
const controller = new AbortController();
this.requestController = controller;
this.setLoadingState(true);
try {
const since = document.getElementById('contacts-timespan').value || '30d';
const response = await fetch('/api/contacts?since=' + encodeURIComponent(since));
const params = new URLSearchParams({
since,
page: String(this.currentPage),
page_size: String(this.pageSize),
search: this.searchTerm,
sort: this.sortColumn,
direction: this.sortDirection,
});
const response = await fetch('/api/contacts?' + params.toString(), {
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
if (data.error) {
@@ -584,11 +622,45 @@ class ModernContactsManager {
server_stats: data.server_stats || {}
};
this.filteredData = [...this.contactsData.tracking_data];
this.selectedContactIds.clear();
this.pagination = data.pagination || {
page: 1,
page_size: this.filteredData.length || this.pageSize,
total_items: this.filteredData.length,
total_pages: 1,
has_previous: false,
has_next: false,
};
this.currentPage = this.pagination.page;
this.filteredStats = data.filtered_stats || {};
this.updateSortIcons();
this.renderContactsData();
this.updatePagination();
this.updateStatistics();
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error loading contacts data:', error);
this.showError('Failed to load contacts data: ' + error.message);
} finally {
if (this.requestController === controller) {
this.requestController = null;
this.setLoadingState(false);
}
}
}
setLoadingState(loading) {
const refresh = document.getElementById('refresh-contacts');
if (refresh) refresh.disabled = loading;
const summary = document.getElementById('contacts-page-summary');
if (loading && summary) summary.textContent = 'Loading contacts…';
const previous = document.getElementById('contacts-page-previous');
const next = document.getElementById('contacts-page-next');
if (loading) {
if (previous) previous.disabled = true;
if (next) next.disabled = true;
} else {
this.updatePagination();
}
}
@@ -602,32 +674,22 @@ class ModernContactsManager {
document.getElementById('contacts-list-multibyte-badge-info')
?.addEventListener('click', event => event.stopPropagation());
document.getElementById('refresh-contacts').addEventListener('click', () => {
this.loadContactsData().then(() => {
this.applySearch();
this.applySort();
this.renderContactsData();
this.updateStatistics();
});
this.loadContactsData();
});
document.getElementById('search-contacts').addEventListener('input', (e) => {
this.searchTerm = e.target.value.toLowerCase().trim();
this.applySearch();
this.applySort();
this.renderContactsData();
this.updateStatistics();
window.clearTimeout(this.searchTimer);
this.searchTimer = window.setTimeout(() => {
this.loadContactsData({ resetPage: true, clearSelection: true });
}, 300);
});
document.getElementById('contacts-timespan').addEventListener('change', () => {
const since = document.getElementById('contacts-timespan').value;
localStorage.setItem('contactsTimespan', since);
this.syncContactsTimespanMobileUI();
this.loadContactsData().then(() => {
this.applySearch();
this.applySort();
this.renderContactsData();
this.updateStatistics();
});
this.loadContactsData({ resetPage: true, clearSelection: true });
});
const mobileTimespanMenu = document.getElementById('contacts-timespan-mobile-menu');
@@ -691,9 +753,8 @@ class ModernContactsManager {
if (v.length === 2) {
this.sortColumn = v[0];
this.sortDirection = v[1];
this.applySort();
this.renderContactsData();
this.updateSortIcons();
this.loadContactsData({ resetPage: true, clearSelection: true });
}
});
}
@@ -703,11 +764,21 @@ class ModernContactsManager {
header.addEventListener('click', () => {
const column = header.dataset.sort;
this.setSort(column);
this.applySort();
this.renderContactsData();
this.updateSortIcons();
this.loadContactsData({ resetPage: true, clearSelection: true });
});
});
document.getElementById('contacts-page-previous').addEventListener('click', () => {
if (!this.pagination.has_previous) return;
this.currentPage -= 1;
this.loadContactsData();
});
document.getElementById('contacts-page-next').addEventListener('click', () => {
if (!this.pagination.has_next) return;
this.currentPage += 1;
this.loadContactsData();
});
}
setSort(column) {
@@ -721,75 +792,6 @@ class ModernContactsManager {
}
}
applySort() {
this.filteredData.sort((a, b) => {
let aValue = this.getSortValue(a, this.sortColumn);
let bValue = this.getSortValue(b, this.sortColumn);
// Handle null/undefined values
if (aValue === null || aValue === undefined) aValue = '';
if (bValue === null || bValue === undefined) bValue = '';
// Handle different data types
if (this.sortColumn === 'last_seen' || this.sortColumn === 'first_heard') {
// Date sorting
aValue = new Date(aValue);
bValue = new Date(bValue);
} else if (this.sortColumn === 'snr' || this.sortColumn === 'hop_count' || this.sortColumn === 'advert_count' || this.sortColumn === 'distance') {
// Numeric sorting
aValue = parseFloat(aValue) || 0;
bValue = parseFloat(bValue) || 0;
} else {
// String sorting
aValue = String(aValue).toLowerCase();
bValue = String(bValue).toLowerCase();
}
let comparison = 0;
if (aValue < bValue) comparison = -1;
else if (aValue > bValue) comparison = 1;
return this.sortDirection === 'desc' ? -comparison : comparison;
});
}
getSortValue(contact, column) {
switch (column) {
case 'username':
return contact.username || '';
case 'device_type':
return contact.device_type || '';
case 'location':
return this.getLocationString(contact);
case 'snr':
return contact.snr;
case 'hop_count':
return contact.hop_count;
case 'first_heard':
return contact.first_heard;
case 'last_seen':
return contact.last_seen;
case 'advert_count':
return contact.advert_count;
case 'distance':
return contact.distance || 0;
default:
return '';
}
}
getLocationString(contact) {
if (contact.city && contact.state) {
return `${this.escapeHtml(contact.city)}, ${this.escapeHtml(contact.state)}`;
} else if (contact.city) {
return this.escapeHtml(contact.city);
} else if (contact.latitude && contact.longitude) {
return `${contact.latitude}, ${contact.longitude}`;
} else {
return '';
}
}
updateSortIcons() {
// Remove all sort icons
document.querySelectorAll('.sortable i').forEach(icon => {
@@ -853,36 +855,6 @@ class ModernContactsManager {
});
}
applySearch() {
if (!this.searchTerm) {
this.filteredData = [...this.contactsData.tracking_data];
return;
}
this.filteredData = this.contactsData.tracking_data.filter(contact => {
// Search in all text fields (case-insensitive)
const searchableFields = [
contact.username || '',
contact.role || '',
contact.device_type || '',
contact.city || '',
contact.state || '',
contact.country || ''
];
// For public key, only match from the beginning
const publicKey = contact.user_id || '';
const publicKeyMatch = publicKey.toLowerCase().startsWith(this.searchTerm);
// For other fields, match anywhere in the string
const otherFieldsMatch = searchableFields.some(field =>
field.toLowerCase().includes(this.searchTerm)
);
return publicKeyMatch || otherFieldsMatch;
});
}
renderContactCardHtml(contact, contactIndex) {
const checked = this.selectedContactIds.has(contact.user_id) ? 'checked' : '';
const hasGeo = !!(contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0);
@@ -963,7 +935,8 @@ class ModernContactsManager {
return;
}
tbody.innerHTML = this.filteredData.map((contact, contactIndex) => `
if (!this.mobileLayout.matches) {
tbody.innerHTML = this.filteredData.map((contact, contactIndex) => `
<tr>
<td class="text-center align-middle">
<input type="checkbox" class="form-check-input contact-row-checkbox" data-contact-index="${contactIndex}" ${this.selectedContactIds.has(contact.user_id) ? 'checked' : ''}>
@@ -1001,9 +974,10 @@ class ModernContactsManager {
</div>
</td>
</tr>
`).join('');
if (mobileStack) {
`).join('');
if (mobileStack) mobileStack.replaceChildren();
} else if (mobileStack) {
tbody.replaceChildren();
mobileStack.innerHTML = this.filteredData.map((contact, contactIndex) => this.renderContactCardHtml(contact, contactIndex)).join('');
}
@@ -1011,10 +985,28 @@ class ModernContactsManager {
this.setupPathTooltips();
this.updateBulkDeleteButton();
this.updateSelectAllCheckbox();
if (mobileStack) {
if (mobileStack && this.mobileLayout.matches) {
this.initContactsDropdowns(mobileStack);
}
}
updatePagination() {
const summary = document.getElementById('contacts-page-summary');
const previous = document.getElementById('contacts-page-previous');
const next = document.getElementById('contacts-page-next');
const total = Number(this.pagination.total_items) || 0;
const pageSize = Number(this.pagination.page_size) || this.pageSize;
const page = Number(this.pagination.page) || 1;
const start = total === 0 ? 0 : ((page - 1) * pageSize) + 1;
const end = Math.min(total, start + this.filteredData.length - 1);
if (summary) {
summary.textContent = total === 0
? 'No contacts'
: `${start.toLocaleString()}–${end.toLocaleString()} of ${total.toLocaleString()} contacts`;
}
if (previous) previous.disabled = !this.pagination.has_previous;
if (next) next.disabled = !this.pagination.has_next;
}
updateBulkDeleteButton() {
const btn = document.getElementById('bulk-delete-contacts');
@@ -1052,17 +1044,17 @@ class ModernContactsManager {
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Calculate time-based metrics
const contacts24h = this.filteredData.filter(contact => {
const contacts24h = this.filteredStats.contacts_24h ?? this.filteredData.filter(contact => {
const lastSeen = new Date(contact.last_seen);
return lastSeen >= oneDayAgo;
}).length;
const contacts7d = this.filteredData.filter(contact => {
const contacts7d = this.filteredStats.contacts_7d ?? this.filteredData.filter(contact => {
const lastSeen = new Date(contact.last_seen);
return lastSeen >= oneWeekAgo;
}).length;
const contactsAll = this.filteredData.length;
const contactsAll = this.filteredStats.contacts_total ?? this.filteredData.length;
// Calculate advertisement metrics using daily tracking data
// Note: These should now be provided by the server from the daily_advertisements table
@@ -1111,7 +1103,7 @@ class ModernContactsManager {
console.log('Sample new device:', anyNewDevices[0]);
// Try different possible device type values
const newCompanions = this.filteredData.filter(contact => {
const newCompanions = this.filteredStats.new_companions ?? this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
const deviceType = (contact.device_type || '').toLowerCase();
return firstHeard >= oneWeekAgo && (
@@ -1121,7 +1113,7 @@ class ModernContactsManager {
);
}).length;
const newRepeaters = this.filteredData.filter(contact => {
const newRepeaters = this.filteredStats.new_repeaters ?? this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
const deviceType = (contact.device_type || '').toLowerCase();
return firstHeard >= oneWeekAgo && (
@@ -1131,7 +1123,7 @@ class ModernContactsManager {
);
}).length;
const newRoomServers = this.filteredData.filter(contact => {
const newRoomServers = this.filteredStats.new_room_servers ?? this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
const deviceType = (contact.device_type || '').toLowerCase();
return firstHeard >= oneWeekAgo && (
@@ -2325,13 +2317,8 @@ class ModernContactsManager {
modal.hide();
}
// Remove contact from local data
this.contactsData.tracking_data = this.contactsData.tracking_data.filter(c => c.user_id !== userId);
this.filteredData = this.filteredData.filter(c => c.user_id !== userId);
// Re-render the table
this.renderContactsData();
this.updateStatistics();
this.selectedContactIds.delete(userId);
await this.loadContactsData();
// Show success message
this.showSuccess(`Contact "${this.escapeHtml(username || 'Unknown')}" has been deleted successfully.`);
@@ -2445,14 +2432,8 @@ class ModernContactsManager {
const modal = bootstrap.Modal.getInstance(modalElement);
if (modal) modal.hide();
toDelete.forEach(userId => {
this.contactsData.tracking_data = this.contactsData.tracking_data.filter(c => c.user_id !== userId);
this.filteredData = this.filteredData.filter(c => c.user_id !== userId);
this.selectedContactIds.delete(userId);
});
this.renderContactsData();
this.updateStatistics();
toDelete.forEach(userId => this.selectedContactIds.delete(userId));
await this.loadContactsData();
if (failCount === 0) {
this.showSuccess(`${successCount} contact${successCount !== 1 ? 's' : ''} deleted successfully.`);