mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-01 08:19:30 +00:00
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:
+3
-1
@@ -193,7 +193,9 @@ proxy_set_header X-Forwarded-Proto $scheme;
|
||||
The viewer also provides JSON API endpoints:
|
||||
|
||||
- `GET /api/stats` - Database statistics
|
||||
- `GET /api/contacts` - Repeater contacts data
|
||||
- `GET /api/contacts` - Repeater contacts data. The contacts page uses optional
|
||||
`page`, `page_size` (maximum 200), `search`, `sort`, and `direction` parameters;
|
||||
callers that omit pagination retain the legacy full-list response.
|
||||
- `GET /api/tracking` - Contact tracking data
|
||||
|
||||
Example usage:
|
||||
|
||||
+218
-32
@@ -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"),
|
||||
|
||||
@@ -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.`);
|
||||
|
||||
+2
-1
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"lint:html": "htmlhint \"modules/web_viewer/templates/**/*.html\"",
|
||||
"lint:js": "eslint \"modules/web_viewer/templates/**/*.html\"",
|
||||
"lint:frontend": "npm run lint:html && npm run lint:js"
|
||||
"lint:frontend": "npm run lint:html && npm run lint:js",
|
||||
"test:contacts": "node --test tests/js/contacts_manager.test.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eslint": "^8.57.0",
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const templateUrl = new URL(
|
||||
'../../modules/web_viewer/templates/contacts.html',
|
||||
import.meta.url,
|
||||
);
|
||||
const templateSource = fs.readFileSync(templateUrl, 'utf8');
|
||||
const classStart = templateSource.indexOf('class ModernContactsManager');
|
||||
const classEnd = templateSource.indexOf('\nfunction exportData', classStart);
|
||||
assert.ok(classStart >= 0 && classEnd > classStart, 'contacts manager class not found');
|
||||
const classSource = templateSource.slice(classStart, classEnd);
|
||||
|
||||
function fakeElement(overrides = {}) {
|
||||
return {
|
||||
disabled: false,
|
||||
textContent: '',
|
||||
innerHTML: '',
|
||||
value: '',
|
||||
listeners: {},
|
||||
addEventListener(type, listener) {
|
||||
this.listeners[type] = listener;
|
||||
},
|
||||
replaceChildren() {
|
||||
this.innerHTML = '';
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function loadManagerClass() {
|
||||
const elements = new Map();
|
||||
const context = vm.createContext({
|
||||
AbortController,
|
||||
URLSearchParams,
|
||||
Chart: function Chart() {},
|
||||
bootstrap: {
|
||||
Dropdown: class Dropdown {},
|
||||
Modal: {
|
||||
getInstance() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
Tooltip: class Tooltip {},
|
||||
},
|
||||
console,
|
||||
document: {
|
||||
body: fakeElement(),
|
||||
getElementById(id) {
|
||||
return elements.get(id) || null;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
fetch: async () => {
|
||||
throw new Error('unexpected fetch');
|
||||
},
|
||||
window: {
|
||||
clearTimeout,
|
||||
localStorage: {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
},
|
||||
matchMedia() {
|
||||
return { matches: false, addEventListener() {} };
|
||||
},
|
||||
setTimeout,
|
||||
},
|
||||
});
|
||||
vm.runInContext(
|
||||
`${classSource}\nglobalThis.ModernContactsManager = ModernContactsManager;`,
|
||||
context,
|
||||
);
|
||||
return { context, elements, Manager: context.ModernContactsManager };
|
||||
}
|
||||
|
||||
function bareManager(Manager) {
|
||||
const manager = Object.create(Manager.prototype);
|
||||
Object.assign(manager, {
|
||||
contactsData: { tracking_data: [], server_stats: {} },
|
||||
filteredData: [],
|
||||
selectedContactIds: new Set(),
|
||||
searchTerm: '',
|
||||
sortColumn: 'last_seen',
|
||||
sortDirection: 'desc',
|
||||
currentPage: 1,
|
||||
pageSize: 100,
|
||||
pagination: {
|
||||
page: 1,
|
||||
page_size: 100,
|
||||
total_items: 0,
|
||||
total_pages: 1,
|
||||
has_previous: false,
|
||||
has_next: false,
|
||||
},
|
||||
filteredStats: {},
|
||||
requestController: null,
|
||||
searchTimer: null,
|
||||
mobileLayout: { matches: false },
|
||||
activityChart: null,
|
||||
});
|
||||
return manager;
|
||||
}
|
||||
|
||||
function successfulResponse(data) {
|
||||
return {
|
||||
ok: true,
|
||||
async json() {
|
||||
return data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('loadContactsData sends bounded server-side list parameters', async () => {
|
||||
const { context, elements, Manager } = loadManagerClass();
|
||||
elements.set('contacts-timespan', fakeElement({ value: '30d' }));
|
||||
const manager = bareManager(Manager);
|
||||
manager.currentPage = 3;
|
||||
manager.searchTerm = 'alpha';
|
||||
manager.sortColumn = 'distance';
|
||||
manager.sortDirection = 'asc';
|
||||
manager.setLoadingState = () => {};
|
||||
manager.updateSortIcons = () => {};
|
||||
manager.renderContactsData = () => {};
|
||||
manager.updatePagination = () => {};
|
||||
manager.updateStatistics = () => {};
|
||||
|
||||
let request;
|
||||
context.fetch = async (url, options) => {
|
||||
request = { url, options };
|
||||
return successfulResponse({
|
||||
tracking_data: [{ user_id: 'a1' }],
|
||||
server_stats: {},
|
||||
filtered_stats: { contacts_total: 201 },
|
||||
pagination: {
|
||||
page: 3,
|
||||
page_size: 100,
|
||||
total_items: 201,
|
||||
total_pages: 3,
|
||||
has_previous: true,
|
||||
has_next: false,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await manager.loadContactsData();
|
||||
const url = new URL(request.url, 'http://viewer.local');
|
||||
assert.equal(url.pathname, '/api/contacts');
|
||||
assert.equal(url.searchParams.get('since'), '30d');
|
||||
assert.equal(url.searchParams.get('page'), '3');
|
||||
assert.equal(url.searchParams.get('page_size'), '100');
|
||||
assert.equal(url.searchParams.get('search'), 'alpha');
|
||||
assert.equal(url.searchParams.get('sort'), 'distance');
|
||||
assert.equal(url.searchParams.get('direction'), 'asc');
|
||||
assert.equal(manager.filteredData.length, 1);
|
||||
assert.equal(manager.pagination.total_items, 201);
|
||||
});
|
||||
|
||||
test('a newer contacts request aborts the stale request', async () => {
|
||||
const { context, elements, Manager } = loadManagerClass();
|
||||
elements.set('contacts-timespan', fakeElement({ value: '30d' }));
|
||||
const manager = bareManager(Manager);
|
||||
manager.setLoadingState = () => {};
|
||||
manager.updateSortIcons = () => {};
|
||||
manager.renderContactsData = () => {};
|
||||
manager.updatePagination = () => {};
|
||||
manager.updateStatistics = () => {};
|
||||
|
||||
const signals = [];
|
||||
let callCount = 0;
|
||||
context.fetch = (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
callCount += 1;
|
||||
if (callCount === 1) {
|
||||
return new Promise((resolve, reject) => {
|
||||
signal.addEventListener('abort', () => {
|
||||
const error = new Error('aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
return Promise.resolve(successfulResponse({
|
||||
tracking_data: [],
|
||||
server_stats: {},
|
||||
filtered_stats: {},
|
||||
pagination: manager.pagination,
|
||||
}));
|
||||
};
|
||||
|
||||
const stale = manager.loadContactsData();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
const current = manager.loadContactsData();
|
||||
await Promise.all([stale, current]);
|
||||
assert.equal(signals.length, 2);
|
||||
assert.equal(signals[0].aborted, true);
|
||||
assert.equal(signals[1].aborted, false);
|
||||
});
|
||||
|
||||
test('search is debounced before reloading the first page', () => {
|
||||
const { context, elements, Manager } = loadManagerClass();
|
||||
const requiredIds = [
|
||||
'contacts-list-multibyte-badge-info',
|
||||
'refresh-contacts',
|
||||
'search-contacts',
|
||||
'contacts-timespan',
|
||||
'contacts-timespan-mobile-menu',
|
||||
'bulk-delete-contacts',
|
||||
'contacts-list-panel',
|
||||
'contacts-mobile-sort',
|
||||
'contacts-page-previous',
|
||||
'contacts-page-next',
|
||||
];
|
||||
requiredIds.forEach(id => elements.set(id, fakeElement()));
|
||||
const manager = bareManager(Manager);
|
||||
const reloads = [];
|
||||
manager.loadContactsData = options => {
|
||||
reloads.push(options);
|
||||
};
|
||||
manager.syncContactsTimespanMobileUI = () => {};
|
||||
manager.showBulkDeleteConfirmation = () => {};
|
||||
|
||||
let scheduled;
|
||||
context.window.clearTimeout = () => {};
|
||||
context.window.setTimeout = (callback, delay) => {
|
||||
scheduled = { callback, delay };
|
||||
return 1;
|
||||
};
|
||||
|
||||
manager.setupEventHandlers();
|
||||
elements.get('search-contacts').listeners.input({
|
||||
target: { value: ' Alpha ' },
|
||||
});
|
||||
assert.equal(manager.searchTerm, 'alpha');
|
||||
assert.equal(reloads.length, 0);
|
||||
assert.equal(scheduled.delay, 300);
|
||||
scheduled.callback();
|
||||
assert.equal(reloads.length, 1);
|
||||
assert.equal(reloads[0].resetPage, true);
|
||||
assert.equal(reloads[0].clearSelection, true);
|
||||
});
|
||||
|
||||
test('renderContactsData builds only the active responsive layout', () => {
|
||||
const { elements, Manager } = loadManagerClass();
|
||||
const tbody = fakeElement();
|
||||
const mobile = fakeElement();
|
||||
elements.set('contacts-table-body', tbody);
|
||||
elements.set('contacts-mobile-stack', mobile);
|
||||
const manager = bareManager(Manager);
|
||||
manager.filteredData = [
|
||||
{ user_id: 'a1', username: 'Alpha', advert_count: 1 },
|
||||
{ user_id: 'b2', username: 'Bravo', advert_count: 2 },
|
||||
];
|
||||
manager.escapeHtml = value => String(value);
|
||||
manager.formatDeviceType = () => '';
|
||||
manager.formatLocation = () => '';
|
||||
manager.formatDistance = () => '';
|
||||
manager.formatSignal = () => '';
|
||||
manager.formatHops = () => '';
|
||||
manager.formatPathEncodingBadge = () => '';
|
||||
manager.formatTimestamp = () => '';
|
||||
manager.formatTimeAgo = () => '';
|
||||
manager.setupPathTooltips = () => {};
|
||||
manager.updateBulkDeleteButton = () => {};
|
||||
manager.updateSelectAllCheckbox = () => {};
|
||||
manager.initContactsDropdowns = () => {};
|
||||
|
||||
manager.mobileLayout = { matches: false };
|
||||
manager.renderContactsData();
|
||||
assert.equal((tbody.innerHTML.match(/<tr>/g) || []).length, 2);
|
||||
assert.equal(mobile.innerHTML, '');
|
||||
|
||||
manager.mobileLayout = { matches: true };
|
||||
manager.renderContactsData();
|
||||
assert.equal(tbody.innerHTML, '');
|
||||
assert.equal(
|
||||
(mobile.innerHTML.match(/class="contact-mobile-card"/g) || []).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test('successful delete reloads the current server page', async () => {
|
||||
const { context, elements, Manager } = loadManagerClass();
|
||||
elements.set(
|
||||
'confirmDeleteBtn',
|
||||
fakeElement({ innerHTML: '<i class="fas fa-trash"></i>' }),
|
||||
);
|
||||
const manager = bareManager(Manager);
|
||||
manager.selectedContactIds.add('deadbeef');
|
||||
let reloads = 0;
|
||||
manager.loadContactsData = async () => {
|
||||
reloads += 1;
|
||||
};
|
||||
manager.showSuccess = () => {};
|
||||
manager.showError = error => {
|
||||
throw new Error(error);
|
||||
};
|
||||
manager.escapeHtml = value => String(value);
|
||||
context.fetch = async () => successfulResponse({ success: true });
|
||||
let hidden = false;
|
||||
context.bootstrap.Modal.getInstance = () => ({
|
||||
hide() {
|
||||
hidden = true;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.deleteContact('deadbeef', 'Deleted Contact', fakeElement());
|
||||
assert.equal(hidden, true);
|
||||
assert.equal(manager.selectedContactIds.has('deadbeef'), false);
|
||||
assert.equal(reloads, 1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Run dependency-free browser-side regression tests for the contacts manager."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_contacts_manager_frontend_behaviors() -> None:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("Node.js is required for contacts frontend behavior tests")
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
[node, "--test", "tests/js/contacts_manager.test.mjs"],
|
||||
cwd=project_root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
@@ -353,6 +353,7 @@ class TestContactRoutes:
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert isinstance(data, dict)
|
||||
assert "pagination" not in data
|
||||
|
||||
def test_api_contacts_since_7d(self, client):
|
||||
resp = client.get("/api/contacts?since=7d")
|
||||
@@ -366,6 +367,307 @@ class TestContactRoutes:
|
||||
resp = client.get("/api/contacts?since=forever")
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_api_contacts_paginates_searches_and_sorts(self, client, viewer):
|
||||
contacts = [
|
||||
("f001" * 16, "PerfContact Charlie"),
|
||||
("f002" * 16, "PerfContact Alpha"),
|
||||
("f003" * 16, "PerfContact Bravo"),
|
||||
]
|
||||
for public_key, name in contacts:
|
||||
_insert_contact(viewer, public_key, name)
|
||||
|
||||
first = client.get(
|
||||
"/api/contacts?since=all&page=1&page_size=2&search=PerfContact"
|
||||
"&sort=username&direction=asc"
|
||||
).get_json()
|
||||
assert "filtered_stats" in first
|
||||
assert first["pagination"] == {
|
||||
"page": 1,
|
||||
"page_size": 2,
|
||||
"total_items": 3,
|
||||
"total_pages": 2,
|
||||
"has_previous": False,
|
||||
"has_next": True,
|
||||
}
|
||||
assert [row["username"] for row in first["tracking_data"]] == [
|
||||
"PerfContact Alpha",
|
||||
"PerfContact Bravo",
|
||||
]
|
||||
|
||||
second = client.get(
|
||||
"/api/contacts?since=all&page=2&page_size=2&search=PerfContact"
|
||||
"&sort=username&direction=asc"
|
||||
).get_json()
|
||||
assert [row["username"] for row in second["tracking_data"]] == [
|
||||
"PerfContact Charlie"
|
||||
]
|
||||
assert second["pagination"]["has_previous"] is True
|
||||
assert second["pagination"]["has_next"] is False
|
||||
|
||||
def test_api_contacts_caps_page_size_and_normalizes_sort(self, client):
|
||||
data = client.get(
|
||||
"/api/contacts?page=not-a-number&page_size=9999&sort=not-a-column"
|
||||
"&direction=not-a-direction"
|
||||
).get_json()
|
||||
assert data["pagination"]["page"] == 1
|
||||
assert data["pagination"]["page_size"] == 200
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("sort", "ascending_names"),
|
||||
[
|
||||
("username", ["SortMatrix A", "SortMatrix B", "SortMatrix C"]),
|
||||
("device_type", ["SortMatrix B", "SortMatrix C", "SortMatrix A"]),
|
||||
("location", ["SortMatrix B", "SortMatrix C", "SortMatrix A"]),
|
||||
("distance", ["SortMatrix A", "SortMatrix C", "SortMatrix B"]),
|
||||
("snr", ["SortMatrix B", "SortMatrix C", "SortMatrix A"]),
|
||||
("hop_count", ["SortMatrix B", "SortMatrix C", "SortMatrix A"]),
|
||||
("first_heard", ["SortMatrix B", "SortMatrix C", "SortMatrix A"]),
|
||||
("last_seen", ["SortMatrix C", "SortMatrix A", "SortMatrix B"]),
|
||||
("advert_count", ["SortMatrix B", "SortMatrix C", "SortMatrix A"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("direction", ["asc", "desc"])
|
||||
def test_api_contacts_preserves_global_sort_semantics(
|
||||
self, client, viewer, monkeypatch, sort, ascending_names, direction
|
||||
):
|
||||
with closing(sqlite3.connect(viewer.db_path)) as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM complete_contact_tracking WHERE name LIKE 'SortMatrix %'"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
rows = [
|
||||
(
|
||||
"d101" * 16, "SortMatrix A", "Zulu", "Zurich", 30.0, 3,
|
||||
"2026-01-03 00:00:00", "2026-02-02 00:00:00", 30, 0.1, 0.0,
|
||||
),
|
||||
(
|
||||
"d102" * 16, "SortMatrix B", "Alpha", "Austin", 10.0, 1,
|
||||
"2026-01-01 00:00:00", "2026-02-03 00:00:00", 10, 2.0, 0.0,
|
||||
),
|
||||
(
|
||||
"d103" * 16, "SortMatrix C", "Middle", "Boston", 20.0, 2,
|
||||
"2026-01-02 00:00:00", "2026-02-01 00:00:00", 20, 1.0, 0.0,
|
||||
),
|
||||
]
|
||||
for row in rows:
|
||||
_insert_contact(viewer, row[0], row[1])
|
||||
with closing(sqlite3.connect(viewer.db_path)) as conn:
|
||||
for row in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE complete_contact_tracking
|
||||
SET device_type = ?, city = ?, snr = ?, hop_count = ?,
|
||||
first_heard = ?, last_heard = ?, advert_count = ?,
|
||||
latitude = ?, longitude = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(*row[2:], row[0]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
original_getfloat = viewer.config.getfloat
|
||||
|
||||
def getfloat(section, option, *, fallback=None):
|
||||
if section == "Bot" and option in {"bot_latitude", "bot_longitude"}:
|
||||
return 0.0
|
||||
return original_getfloat(section, option, fallback=fallback)
|
||||
|
||||
monkeypatch.setattr(viewer.config, "getfloat", getfloat)
|
||||
data = client.get(
|
||||
"/api/contacts",
|
||||
query_string={
|
||||
"since": "all",
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"search": "SortMatrix",
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
},
|
||||
).get_json()
|
||||
expected = ascending_names if direction == "asc" else list(reversed(ascending_names))
|
||||
assert [row["username"] for row in data["tracking_data"]] == expected
|
||||
|
||||
@pytest.mark.parametrize("sort", ["location", "distance", "snr", "hop_count"])
|
||||
def test_api_contacts_sorts_missing_values_as_zero_or_empty(
|
||||
self, client, viewer, monkeypatch, sort
|
||||
):
|
||||
original_getfloat = viewer.config.getfloat
|
||||
|
||||
def getfloat(section, option, *, fallback=None):
|
||||
if section == "Bot" and option in {"bot_latitude", "bot_longitude"}:
|
||||
return 0.0
|
||||
return original_getfloat(section, option, fallback=fallback)
|
||||
|
||||
monkeypatch.setattr(viewer.config, "getfloat", getfloat)
|
||||
|
||||
with closing(sqlite3.connect(viewer.db_path)) as conn:
|
||||
conn.execute(
|
||||
"DELETE FROM complete_contact_tracking WHERE name LIKE 'SortNull %'"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
rows = [
|
||||
("d201" * 16, "SortNull Missing", None, None, None, None),
|
||||
("d202" * 16, "SortNull Low", "Alpha", 1.0, 1.0, 1),
|
||||
("d203" * 16, "SortNull High", "Zulu", 2.0, 2.0, 2),
|
||||
]
|
||||
for public_key, name, *_ in rows:
|
||||
_insert_contact(viewer, public_key, name)
|
||||
with closing(sqlite3.connect(viewer.db_path)) as conn:
|
||||
for public_key, _name, city, latitude, snr, hop_count in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE complete_contact_tracking
|
||||
SET city = ?, latitude = ?, longitude = ?, snr = ?, hop_count = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(city, latitude, latitude, snr, hop_count, public_key),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
data = client.get(
|
||||
"/api/contacts",
|
||||
query_string={
|
||||
"since": "all",
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"search": "SortNull",
|
||||
"sort": sort,
|
||||
"direction": "asc",
|
||||
},
|
||||
).get_json()
|
||||
assert [row["username"] for row in data["tracking_data"]][0] == "SortNull Missing"
|
||||
|
||||
def test_api_contacts_empty_and_out_of_range_pages(self, client, viewer):
|
||||
empty = client.get(
|
||||
"/api/contacts",
|
||||
query_string={"page": 999, "page_size": 2, "search": "NoSuchContact"},
|
||||
).get_json()
|
||||
assert empty["tracking_data"] == []
|
||||
assert empty["pagination"] == {
|
||||
"page": 1,
|
||||
"page_size": 2,
|
||||
"total_items": 0,
|
||||
"total_pages": 1,
|
||||
"has_previous": False,
|
||||
"has_next": False,
|
||||
}
|
||||
|
||||
for index in range(3):
|
||||
_insert_contact(
|
||||
viewer,
|
||||
f"d30{index}" * 16,
|
||||
f"ClampMatrix {index}",
|
||||
)
|
||||
clamped = client.get(
|
||||
"/api/contacts",
|
||||
query_string={
|
||||
"since": "all",
|
||||
"page": 999,
|
||||
"page_size": 2,
|
||||
"search": "ClampMatrix",
|
||||
"sort": "username",
|
||||
"direction": "asc",
|
||||
},
|
||||
).get_json()
|
||||
assert clamped["pagination"]["page"] == 2
|
||||
assert clamped["pagination"]["total_pages"] == 2
|
||||
assert [row["username"] for row in clamped["tracking_data"]] == [
|
||||
"ClampMatrix 2"
|
||||
]
|
||||
|
||||
def test_api_contacts_search_treats_sql_wildcards_literally(self, client, viewer):
|
||||
_insert_contact(viewer, "d401" * 16, "WildcardMatrix 100%")
|
||||
_insert_contact(viewer, "d402" * 16, "WildcardMatrix 100X")
|
||||
data = client.get(
|
||||
"/api/contacts",
|
||||
query_string={
|
||||
"since": "all",
|
||||
"page": 1,
|
||||
"page_size": 10,
|
||||
"search": "100%",
|
||||
},
|
||||
).get_json()
|
||||
assert [row["username"] for row in data["tracking_data"]] == [
|
||||
"WildcardMatrix 100%"
|
||||
]
|
||||
|
||||
def test_api_contacts_filtered_stats_cover_all_matching_rows(self, client, viewer):
|
||||
rows = [
|
||||
("d501" * 16, "StatsMatrix Companion", "Companion Device", "-1 hour", "-1 day"),
|
||||
("d502" * 16, "StatsMatrix Repeater", "Repeater", "-2 days", "-2 days"),
|
||||
("d503" * 16, "StatsMatrix Room", "Room Server", "-10 days", "-10 days"),
|
||||
]
|
||||
for public_key, name, *_ in rows:
|
||||
_insert_contact(viewer, public_key, name)
|
||||
with closing(sqlite3.connect(viewer.db_path)) as conn:
|
||||
for public_key, _name, device_type, last_offset, first_offset in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE complete_contact_tracking
|
||||
SET device_type = ?,
|
||||
last_heard = datetime('now', 'localtime', ?),
|
||||
first_heard = datetime('now', 'localtime', ?)
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(device_type, last_offset, first_offset, public_key),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
data = client.get(
|
||||
"/api/contacts",
|
||||
query_string={
|
||||
"since": "30d",
|
||||
"page": 1,
|
||||
"page_size": 1,
|
||||
"search": "StatsMatrix",
|
||||
},
|
||||
).get_json()
|
||||
assert len(data["tracking_data"]) == 1
|
||||
assert data["filtered_stats"] == {
|
||||
"contacts_24h": 1,
|
||||
"contacts_7d": 2,
|
||||
"contacts_total": 3,
|
||||
"new_companions": 1,
|
||||
"new_repeaters": 1,
|
||||
"new_room_servers": 0,
|
||||
}
|
||||
|
||||
def test_api_contacts_scopes_path_query_to_visible_page(
|
||||
self, client, viewer, monkeypatch
|
||||
):
|
||||
_insert_contact(viewer, "f101" * 16, "PerfScoped One")
|
||||
_insert_contact(viewer, "f102" * 16, "PerfScoped Two")
|
||||
statements = []
|
||||
original_get_connection = viewer._get_db_connection
|
||||
|
||||
def traced_connection():
|
||||
conn = original_get_connection()
|
||||
conn.set_trace_callback(statements.append)
|
||||
return conn
|
||||
|
||||
monkeypatch.setattr(viewer, "_get_db_connection", traced_connection)
|
||||
data = client.get(
|
||||
"/api/contacts?since=all&page=1&page_size=1&search=PerfScoped"
|
||||
).get_json()
|
||||
|
||||
assert len(data["tracking_data"]) == 1
|
||||
recent_path_queries = [
|
||||
sql for sql in statements
|
||||
if "WITH recent_paths AS" in sql
|
||||
]
|
||||
assert len(recent_path_queries) == 1
|
||||
assert "public_key IN (" in recent_path_queries[0]
|
||||
|
||||
def test_contacts_page_uses_bounded_server_loading(self, client):
|
||||
html = client.get("/contacts").get_data(as_text=True)
|
||||
assert 'id="contacts-pagination"' in html
|
||||
assert 'id="contacts-page-previous"' in html
|
||||
assert 'id="contacts-page-next"' in html
|
||||
assert "page_size: String(this.pageSize)" in html
|
||||
assert "new AbortController()" in html
|
||||
|
||||
def test_toggle_star_missing_public_key(self, client):
|
||||
resp = client.post(
|
||||
"/api/toggle-star-contact",
|
||||
|
||||
@@ -975,6 +975,40 @@ class TestApiContacts:
|
||||
assert 'tracking_data' in data
|
||||
assert 'server_stats' in data
|
||||
|
||||
def test_contact_badge_multibyte_chunks_are_cached_and_invalidated(
|
||||
self, viewer_with_db
|
||||
):
|
||||
with viewer_with_db._with_db_connection() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO observed_paths
|
||||
(public_key, from_prefix, to_prefix, path_hex, path_length,
|
||||
bytes_per_hop, packet_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'advert')
|
||||
""",
|
||||
("aa" * 32, "aabb", "ccdd", "aabbccdd", 4, 2),
|
||||
)
|
||||
conn.commit()
|
||||
cursor = conn.cursor()
|
||||
first = viewer_with_db._get_cached_contact_multibyte_hop_chunks(cursor)
|
||||
second = viewer_with_db._get_cached_contact_multibyte_hop_chunks(cursor)
|
||||
assert first is second
|
||||
assert {"aabb", "ccdd"}.issubset(first)
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO observed_paths
|
||||
(public_key, from_prefix, to_prefix, path_hex, path_length,
|
||||
bytes_per_hop, packet_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 'advert')
|
||||
""",
|
||||
("bb" * 32, "112233", "445566", "112233445566", 6, 3),
|
||||
)
|
||||
conn.commit()
|
||||
third = viewer_with_db._get_cached_contact_multibyte_hop_chunks(cursor)
|
||||
assert third is not first
|
||||
assert {"112233", "445566"}.issubset(third)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# api_channel_*
|
||||
|
||||
Reference in New Issue
Block a user