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
2026-07-28 12:36:56 -07:00
parent 8ab5708b72
commit 092be2d5bf
9 changed files with 1043 additions and 189 deletions
+3 -1
View File
@@ -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
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.`);
+2 -1
View File
@@ -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",
+322
View File
@@ -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);
});
+26
View File
@@ -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
+302
View File
@@ -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",
+34
View File
@@ -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_*