Files
meshcore-bot/modules/web_viewer/templates/contacts.html
T
agessaman c6a7355b3c Enhance multibyte path statistics and UI in web viewer
- Added calculations for contacts and incoming packets with multibyte path evidence over the last 7 days in `app.py`, improving data accuracy.
- Introduced new methods for handling multibyte path chunks and counting packets from JSON data, enhancing backend functionality.
- Updated `contacts.html` and `index.html` templates to display multibyte path encoding badges and tooltips, improving user interface clarity.
- Enhanced CSS for path encoding badges to differentiate between multibyte and one-byte paths, ensuring better visual representation.

These changes improve the overall user experience and data representation in the Bot Data Viewer.
2026-04-03 20:27:00 -07:00

2713 lines
117 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Contacts - MeshCore Bot Data Viewer{% endblock %}
{% block extra_css %}
<style>
/* Square hit target for the overflow (⋯) menu trigger */
.contacts-overflow-btn {
width: 2rem;
height: 2.375rem;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
}
#contacts-mobile-stack {
display: flex;
flex-direction: column;
gap: 0.75rem;
overflow-x: hidden;
}
.contact-mobile-card {
background-color: var(--card-bg);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 0.375rem;
padding: 0.75rem 0.85rem;
min-width: 0;
}
.contact-mobile-card .contact-mobile-device {
max-width: 65%;
}
.contact-mobile-card .contact-mobile-device .badge {
font-size: 0.75rem;
white-space: normal;
text-align: right;
line-height: 1.2;
}
.contact-mobile-card .contact-mobile-hops-adverts {
min-width: 0;
}
.contact-mobile-card .contact-mobile-hops-adverts .badge {
font-size: 0.75rem;
line-height: 1.25;
padding: 0.35em 0.55em;
}
/* Contacts table toolbar: one row on mobile, matching control heights */
.contacts-toolbar-filters-wrap {
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
min-width: 0;
max-width: 100%;
}
.contacts-toolbar-filters {
display: flex;
flex-wrap: nowrap;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
width: 100%;
min-height: 2.375rem;
}
@media (min-width: 768px) {
.contacts-toolbar-filters {
justify-content: flex-end;
gap: 0.5rem;
}
}
@media (min-width: 576px) and (max-width: 767.98px) {
.contacts-toolbar-filters {
gap: 0.5rem;
}
}
.contacts-toolbar-filters .form-select-sm,
.contacts-toolbar-filters .input-group-sm,
.contacts-toolbar-filters #contacts-timespan {
flex-shrink: 0;
}
.contacts-toolbar-filters #contacts-timespan {
min-height: 2.375rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.contacts-toolbar-search {
flex: 1 1 7.5rem;
min-width: 0;
max-width: 100%;
}
@media (min-width: 768px) {
.contacts-toolbar-search {
flex: 0 1 300px;
max-width: 300px;
}
}
.contacts-toolbar-filters .contacts-overflow-btn {
flex-shrink: 0;
width: 2.375rem;
height: 2.375rem;
box-sizing: border-box;
}
/* Match search + refresh to the same row height as overflow icon buttons (2.375rem) */
.contacts-toolbar-filters .contacts-toolbar-search.input-group {
min-height: 2.375rem;
align-items: stretch;
}
.contacts-toolbar-filters .contacts-toolbar-search > .input-group-text,
.contacts-toolbar-filters .contacts-toolbar-search > .form-control {
min-height: 2.375rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.contacts-toolbar-filters .contacts-toolbar-search > .input-group-text {
display: flex;
align-items: center;
justify-content: center;
padding-left: 0.65rem;
padding-right: 0.65rem;
}
.contacts-toolbar-filters .contacts-toolbar-search .input-group-text i {
font-size: 1rem;
}
.contacts-toolbar-filters #refresh-contacts {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.375rem;
padding-top: 0.375rem;
padding-bottom: 0.375rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.contacts-toolbar-filters #refresh-contacts i {
font-size: 1rem;
}
@media (max-width: 575.98px) {
.contacts-toolbar-filters #refresh-contacts {
min-width: 2.375rem;
padding-left: 0.5rem;
padding-right: 0.5rem;
}
}
.contacts-mobile-sort-row {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-width: 0;
}
.contacts-mobile-sort-row .flex-grow-1 {
min-width: 0;
}
/* Path encoding (bytes/hop) — neutral, non-judgmental pairing */
.path-encoding-badge {
font-size: 0.65rem;
font-weight: 500;
line-height: 1.2;
padding: 0.2em 0.45em;
}
/* Multibyte: cool cyan (distinct from warm 1-byte) */
.path-encoding-badge-multibyte {
background-color: #cffafe;
color: #0c4a6e;
border: 1px solid #0ea5e9;
}
/* 1-byte: warm stone (hue-shifted from gray so it reads clearly vs cyan) */
.path-encoding-badge-onebyte {
background-color: #faf7f2;
color: #44403c;
border: 1px solid #a8a29e;
}
/* Dark mode: same cool vs warm split, higher saturation on borders */
[data-theme="dark"] .path-encoding-badge-multibyte {
background-color: #134e6a;
color: #bae6fd;
border-color: #38bdf8;
}
[data-theme="dark"] .path-encoding-badge-onebyte {
background-color: #3f3a36;
color: #f5f0eb;
border-color: #a8a29e;
}
.tooltip.multibyte-capable-hint .tooltip-inner {
max-width: min(22rem, 92vw);
text-align: left;
}
.path-encoding-info-btn:hover,
.path-encoding-info-btn:focus {
text-decoration: none;
color: var(--bs-info, #0dcaf0) !important;
}
</style>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-12">
<h1 class="mb-4">
<i class="fas fa-users"></i> Contacts
</h1>
</div>
</div>
<!-- Tracking Statistics -->
<div class="row mb-4">
<!-- Active Contacts -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-users"></i> Active Contacts
</div>
<div class="card-body d-flex align-items-center">
<div class="row text-center w-100">
<div class="col-4">
<h4 id="contacts-24h" class="text-primary">0</h4>
<small class="text-muted">24h</small>
</div>
<div class="col-4">
<h4 id="contacts-7d" class="text-info">0</h4>
<small class="text-muted">7d</small>
</div>
<div class="col-4">
<h4 id="contacts-all" class="text-success">0</h4>
<small class="text-muted">All</small>
</div>
</div>
</div>
</div>
</div>
<!-- Advertisements -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-broadcast-tower"></i> Advertisements
</div>
<div class="card-body d-flex align-items-center">
<div class="row text-center w-100">
<div class="col-4">
<h4 id="adverts-24h" class="text-primary">0</h4>
<small class="text-muted">24h</small>
</div>
<div class="col-4">
<h4 id="adverts-7d" class="text-info">0</h4>
<small class="text-muted">7d</small>
</div>
<div class="col-4">
<h4 id="adverts-all" class="text-success">0</h4>
<small class="text-muted">All</small>
</div>
</div>
</div>
</div>
</div>
<!-- New Devices -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-plus-circle"></i> New Devices
</span>
<small class="text-muted" style="font-size: 0.75em; font-weight: normal;">Last 7 days</small>
</div>
<div class="card-body d-flex align-items-center">
<div class="row text-center w-100">
<div class="col-4">
<h4 id="new-companions" class="text-primary">0</h4>
<small class="text-muted">Companions</small>
</div>
<div class="col-4">
<h4 id="new-repeaters" class="text-info">0</h4>
<small class="text-muted">Repeaters</small>
</div>
<div class="col-4">
<h4 id="new-room-servers" class="text-success">0</h4>
<small class="text-muted">Room Servers</small>
</div>
</div>
</div>
</div>
</div>
<!-- Activity Trends -->
<div class="col-md-3">
<div class="card h-100">
<div class="card-header">
<i class="fas fa-chart-line"></i> Activity Trends (30 Days)
</div>
<div class="card-body">
<canvas id="activityTrendsChart" style="max-height: 250px;"></canvas>
</div>
</div>
</div>
</div>
<!-- Tracking Table -->
<div class="row">
<div class="col-12">
<div class="card" id="contacts-list-panel">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap mb-3 gap-2">
<div class="flex-shrink-0">
<button class="btn btn-sm btn-danger" id="bulk-delete-contacts" style="display: none;" title="Delete selected contacts">
<i class="fas fa-trash"></i> Delete selected (<span id="bulk-delete-count">0</span>)
</button>
</div>
<div class="contacts-toolbar-filters-wrap flex-grow-1 min-w-0 w-100">
<div class="contacts-toolbar-filters">
<label class="mb-0 text-nowrap small align-self-center d-none d-md-flex align-items-center gap-1 flex-shrink-0" for="contacts-timespan">
<i class="fas fa-clock"></i> Heard in:
</label>
<select class="form-select flex-shrink-0 d-none d-md-block" id="contacts-timespan" style="width: auto; min-width: 7rem;">
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="all">All time</option>
</select>
<div class="dropdown d-md-none flex-shrink-0">
<button type="button" class="btn btn-sm btn-outline-secondary contacts-overflow-btn" id="contacts-timespan-mobile-trigger"
data-bs-toggle="dropdown" aria-expanded="false"
aria-haspopup="true" title="Heard in: Last 30 days" aria-label="Heard in: Last 30 days">
<i class="fas fa-clock" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-start" id="contacts-timespan-mobile-menu">
<li><h6 class="dropdown-header mb-0">Heard in</h6></li>
<li><button type="button" class="dropdown-item" data-timespan="24h">Last 24 hours</button></li>
<li><button type="button" class="dropdown-item" data-timespan="7d">Last 7 days</button></li>
<li><button type="button" class="dropdown-item" data-timespan="30d">Last 30 days</button></li>
<li><button type="button" class="dropdown-item" data-timespan="90d">Last 90 days</button></li>
<li><button type="button" class="dropdown-item" data-timespan="all">All time</button></li>
</ul>
</div>
<div class="input-group contacts-toolbar-search">
<span class="input-group-text">
<i class="fas fa-search"></i>
</span>
<input type="text" class="form-control" id="search-contacts" placeholder="Search contacts..." autocomplete="off">
</div>
<button type="button" class="btn btn-primary flex-shrink-0" id="refresh-contacts">
<i class="fas fa-sync"></i><span class="d-none d-sm-inline"> Refresh</span>
</button>
<div class="dropdown flex-shrink-0">
<button type="button" class="btn btn-sm btn-outline-secondary contacts-overflow-btn"
data-bs-toggle="dropdown" aria-expanded="false"
aria-label="More actions" title="More actions">
<i class="fas fa-ellipsis-vertical" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<button type="button" class="dropdown-item text-danger"
data-bs-toggle="modal" data-bs-target="#purgeContactsModal"
title="Delete contacts not heard recently">
<i class="fas fa-broom me-2"></i>Purge Inactive
</button>
</li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Export Contacts</h6></li>
<li><a class="dropdown-item" href="#" onclick="exportData('contacts','csv');return false;"><i class="fas fa-file-csv me-2 text-success"></i>CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportData('contacts','json');return false;"><i class="fas fa-file-code me-2 text-warning"></i>JSON</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Export Paths</h6></li>
<li><a class="dropdown-item" href="#" onclick="exportData('paths','csv');return false;"><i class="fas fa-file-csv me-2 text-success"></i>CSV</a></li>
<li><a class="dropdown-item" href="#" onclick="exportData('paths','json');return false;"><i class="fas fa-file-code me-2 text-warning"></i>JSON</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Time range</h6></li>
<li>
<div class="px-3 py-1">
<select class="form-select form-select-sm" id="export-since">
<option value="24h">Last 24 hours</option>
<option value="7d">Last 7 days</option>
<option value="30d" selected>Last 30 days</option>
<option value="90d">Last 90 days</option>
<option value="all">All time</option>
</select>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="d-md-none w-100 d-flex flex-nowrap align-items-center gap-2 mb-3 contacts-mobile-sort-row">
<div class="form-check mb-0">
<input type="checkbox" class="form-check-input" id="contacts-select-all-mobile" title="Select all on page">
<label class="form-check-label small" for="contacts-select-all-mobile">Select all on page</label>
</div>
<div class="flex-grow-1 min-w-0" style="min-width: 10rem;">
<label class="visually-hidden" for="contacts-mobile-sort">Sort by</label>
<select class="form-select form-select-sm" id="contacts-mobile-sort" title="Sort contacts">
<option value="username|asc">Name (AZ)</option>
<option value="username|desc">Name (ZA)</option>
<option value="device_type|asc">Device type (AZ)</option>
<option value="device_type|desc">Device type (ZA)</option>
<option value="location|asc">Location (AZ)</option>
<option value="location|desc">Location (ZA)</option>
<option value="distance|asc">Distance (low first)</option>
<option value="distance|desc">Distance (high first)</option>
<option value="snr|asc">SNR (low first)</option>
<option value="snr|desc">SNR (high first)</option>
<option value="hop_count|asc">Hops (low first)</option>
<option value="hop_count|desc">Hops (high first)</option>
<option value="first_heard|asc">First heard (oldest first)</option>
<option value="first_heard|desc">First heard (newest first)</option>
<option value="last_seen|asc">Last heard (oldest first)</option>
<option value="last_seen|desc" selected>Last heard (newest first)</option>
<option value="advert_count|asc">Adverts (low first)</option>
<option value="advert_count|desc">Adverts (high first)</option>
</select>
</div>
</div>
<div class="d-none d-md-block table-responsive">
<table class="table table-striped table-hover">
<thead class="table-dark">
<tr>
<th class="text-center" style="width: 2.5em;">
<input type="checkbox" class="form-check-input" id="contacts-select-all" title="Select all on page">
</th>
<th class="sortable" data-sort="username">
Name <i class="fas fa-sort"></i>
</th>
<th class="sortable" data-sort="device_type">
Device Type <i class="fas fa-sort"></i>
</th>
<th class="sortable" data-sort="location">
Location <i class="fas fa-sort"></i>
</th>
<th class="sortable" data-sort="distance">
Distance <i class="fas fa-sort"></i>
</th>
<th class="sortable" data-sort="snr">
SNR <i class="fas fa-sort"></i>
</th>
<th class="sortable" data-sort="hop_count">
<span class="d-inline-flex align-items-center gap-1">
Hops <i class="fas fa-sort"></i>
<button type="button" class="btn btn-link p-0 border-0 align-baseline text-muted path-encoding-info-btn"
id="contacts-list-multibyte-badge-info"
aria-label="How Multibyte and 1-byte only badges are determined"
data-bs-toggle="tooltip" data-bs-placement="top"
onclick="event.stopPropagation();">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</span>
</th>
<th class="sortable" data-sort="first_heard">
First Heard <i class="fas fa-sort"></i>
</th>
<th class="sortable sort-active" data-sort="last_seen">
Last Heard <i class="fas fa-sort-down"></i>
</th>
<th class="sortable" data-sort="advert_count">
Adverts <i class="fas fa-sort"></i>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="contacts-table-body">
<tr>
<td colspan="11" class="text-center">
<div class="loading">Loading contacts data...</div>
</td>
</tr>
</tbody>
</table>
</div>
<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>
</div>
</div>
</div>
<!-- Purge Inactive Contacts Modal -->
<div class="modal fade" id="purgeContactsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-broom text-danger me-2"></i>Purge Inactive Contacts</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Delete contacts that have not been heard within a threshold. This removes them from the database entirely.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Remove contacts not heard in:</label>
<select class="form-select" id="purge-days-select">
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30" selected>30 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
</select>
</div>
<div id="purge-preview-area" class="alert alert-secondary py-2 mb-0" style="min-height:2.5rem;">
<span id="purge-preview-text"><i class="fas fa-spinner fa-spin me-1"></i>Loading preview...</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirm-purge-btn" disabled>
<i class="fas fa-broom me-1"></i><span id="confirm-purge-label">Purge</span>
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
class ModernContactsManager {
constructor() {
this.contactsData = [];
this.filteredData = [];
this.selectedContactIds = new Set();
this.searchTerm = '';
this.sortColumn = 'last_seen';
this.sortDirection = 'desc';
this.activityChart = null; // Chart.js instance for activity trends
this.initializeContacts();
}
async initializeContacts() {
const savedTimespan = localStorage.getItem('contactsTimespan');
const valid = ['24h', '7d', '30d', '90d', 'all'];
const el = document.getElementById('contacts-timespan');
if (el) {
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();
}
async loadContactsData() {
try {
const since = document.getElementById('contacts-timespan').value || '30d';
const response = await fetch('/api/contacts?since=' + encodeURIComponent(since));
const data = await response.json();
if (data.error) {
this.showError('Failed to load contacts data: ' + data.error);
return;
}
this.contactsData = {
tracking_data: data.tracking_data || [],
server_stats: data.server_stats || {}
};
this.filteredData = [...this.contactsData.tracking_data];
this.selectedContactIds.clear();
} catch (error) {
console.error('Error loading contacts data:', error);
this.showError('Failed to load contacts data: ' + error.message);
}
}
setupEventHandlers() {
document.getElementById('refresh-contacts').addEventListener('click', () => {
this.loadContactsData().then(() => {
this.applySearch();
this.applySort();
this.renderContactsData();
this.updateStatistics();
});
});
document.getElementById('search-contacts').addEventListener('input', (e) => {
this.searchTerm = e.target.value.toLowerCase().trim();
this.applySearch();
this.applySort();
this.renderContactsData();
this.updateStatistics();
});
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();
});
});
const mobileTimespanMenu = document.getElementById('contacts-timespan-mobile-menu');
if (mobileTimespanMenu) {
mobileTimespanMenu.addEventListener('click', (e) => {
const item = e.target.closest('[data-timespan]');
if (!item) return;
const sel = document.getElementById('contacts-timespan');
if (!sel) return;
const val = item.getAttribute('data-timespan');
if (sel.value === val) return;
sel.value = val;
sel.dispatchEvent(new Event('change', { bubbles: true }));
});
}
document.getElementById('bulk-delete-contacts').addEventListener('click', () => {
this.showBulkDeleteConfirmation();
});
const listPanel = document.getElementById('contacts-list-panel');
if (listPanel) {
listPanel.addEventListener('change', (e) => {
const t = e.target;
if (t.id === 'contacts-select-all' || t.id === 'contacts-select-all-mobile') {
const checked = t.checked;
this.filteredData.forEach(c => {
if (checked) this.selectedContactIds.add(c.user_id);
else this.selectedContactIds.delete(c.user_id);
});
listPanel.querySelectorAll('.contact-row-checkbox').forEach(cb => { cb.checked = checked; });
this.updateBulkDeleteButton();
this.updateSelectAllCheckbox();
} else if (t.classList.contains('contact-row-checkbox')) {
const userId = t.dataset.userId;
if (t.checked) this.selectedContactIds.add(userId);
else this.selectedContactIds.delete(userId);
this.updateBulkDeleteButton();
this.updateSelectAllCheckbox();
}
});
}
const mobileSort = document.getElementById('contacts-mobile-sort');
if (mobileSort) {
mobileSort.addEventListener('change', () => {
const v = mobileSort.value.split('|');
if (v.length === 2) {
this.sortColumn = v[0];
this.sortDirection = v[1];
this.applySort();
this.renderContactsData();
this.updateSortIcons();
}
});
}
// Add click handlers for sortable columns
document.querySelectorAll('.sortable').forEach(header => {
header.addEventListener('click', () => {
const column = header.dataset.sort;
this.setSort(column);
this.applySort();
this.renderContactsData();
this.updateSortIcons();
});
});
}
setSort(column) {
if (this.sortColumn === column) {
// Toggle direction if same column
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// New column, default to ascending
this.sortColumn = column;
this.sortDirection = 'asc';
}
}
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 `${contact.city}, ${contact.state}`;
} else if (contact.city) {
return 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 => {
icon.className = 'fas fa-sort';
});
// Remove active class from all headers
document.querySelectorAll('.sortable').forEach(header => {
header.classList.remove('sort-active');
});
// Add active class and correct icon to current sort column
const activeHeader = document.querySelector(`[data-sort="${this.sortColumn}"]`);
if (activeHeader) {
activeHeader.classList.add('sort-active');
const icon = activeHeader.querySelector('i');
if (icon) {
icon.className = this.sortDirection === 'asc' ? 'fas fa-sort-up' : 'fas fa-sort-down';
}
}
this.syncMobileSortSelect();
}
syncMobileSortSelect() {
const sel = document.getElementById('contacts-mobile-sort');
if (!sel) return;
const val = `${this.sortColumn}|${this.sortDirection}`;
if ([...sel.options].some(o => o.value === val)) {
sel.value = val;
}
}
syncContactsTimespanMobileUI() {
const sel = document.getElementById('contacts-timespan');
const trigger = document.getElementById('contacts-timespan-mobile-trigger');
if (!sel || !trigger) return;
const opt = sel.options[sel.selectedIndex];
const label = opt ? opt.text : 'Time period';
const tip = `Heard in: ${label}`;
trigger.setAttribute('title', tip);
trigger.setAttribute('aria-label', tip);
document.querySelectorAll('#contacts-timespan-mobile-menu [data-timespan]').forEach((btn) => {
btn.classList.toggle('active', btn.getAttribute('data-timespan') === sel.value);
});
}
/**
* Toolbar and mobile cards sit inside overflow-x scroll/clipping regions; default
* Popper (absolute) menus are clipped. Fixed strategy keeps menus visible.
*/
initContactsDropdowns(root) {
if (typeof bootstrap === 'undefined' || !bootstrap.Dropdown || !root) return;
root.querySelectorAll('[data-bs-toggle="dropdown"]').forEach((toggle) => {
const existing = bootstrap.Dropdown.getInstance(toggle);
if (existing) existing.dispose();
new bootstrap.Dropdown(toggle, {
popperConfig: {
strategy: 'fixed',
},
});
});
}
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) {
const uidEsc = this.escapeHtml(contact.user_id);
const uidJs = contact.user_id.replace(/'/g, "\\'");
const nameJs = (contact.username || 'Unknown').replace(/'/g, "\\'");
const checked = this.selectedContactIds.has(contact.user_id) ? 'checked' : '';
const hasGeo = !!(contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0);
const starLabel = contact.is_starred ? 'Unstar contact' : 'Star contact';
const geoMenuItem = hasGeo
? `<li><button type="button" class="dropdown-item" onclick="contactsManager.geocodeContact('${uidJs}', this)"><i class="fas fa-map-marker-alt me-2 text-primary"></i>Geocode location</button></li>`
: '';
return `
<div class="contact-mobile-card">
<div class="d-flex align-items-start gap-2">
<input type="checkbox" class="form-check-input contact-row-checkbox mt-1 flex-shrink-0" data-user-id="${uidEsc}" ${checked}>
<div class="flex-grow-1 min-w-0">
<div class="d-flex justify-content-between align-items-start gap-2">
<div class="min-w-0">
<div class="fw-bold">${contact.username || 'Unknown'}</div>
<small class="text-muted text-break d-block">${contact.user_id ? contact.user_id.substring(0, 16) + '...' : 'Unknown'}</small>
</div>
<div class="contact-mobile-device text-end flex-shrink-0">${this.formatDeviceType(contact)}</div>
</div>
<div class="small text-muted mt-1">${this.formatLocation(contact)}</div>
<div class="small">${this.formatDistance(contact)} · ${this.formatSignal(contact)}</div>
<div class="small text-muted">${this.formatTimestamp(contact.first_heard)} · ${this.formatTimeAgo(contact.last_seen)}</div>
<div class="contact-mobile-hops-adverts d-flex align-items-center justify-content-between flex-wrap gap-2 mt-2">
<div class="d-flex align-items-center flex-wrap gap-2 min-w-0">
<div class="d-flex flex-column align-items-start gap-1 min-w-0">
<div class="d-flex align-items-center min-w-0">${this.formatHops(contact)}</div>
${this.formatPathEncodingBadge(contact)}
</div>
<div class="d-flex align-items-center gap-1">
<span class="badge bg-success">${contact.advert_count || 0}</span>
<span class="text-muted small">adverts</span>
</div>
</div>
<div class="dropdown flex-shrink-0">
<button type="button" class="btn btn-sm btn-outline-secondary contacts-overflow-btn"
data-bs-toggle="dropdown" aria-expanded="false"
aria-label="Contact actions" title="Contact actions">
<i class="fas fa-ellipsis-vertical" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button type="button" class="dropdown-item" onclick="contactsManager.toggleStar('${uidJs}', this)"><i class="fas fa-star me-2 text-warning"></i>${starLabel}</button></li>
<li><button type="button" class="dropdown-item" onclick="contactsManager.viewAdvertData('${contact.user_id}')"><i class="fas fa-info-circle me-2 text-info"></i>View advertisement data</button></li>
${geoMenuItem}
<li><hr class="dropdown-divider"></li>
<li><button type="button" class="dropdown-item text-danger" onclick="contactsManager.showDeleteConfirmation('${uidJs}', '${nameJs}')"><i class="fas fa-trash me-2"></i>Delete contact</button></li>
</ul>
</div>
</div>
</div>
</div>
</div>
`;
}
renderContactsData() {
const tbody = document.getElementById('contacts-table-body');
const mobileStack = document.getElementById('contacts-mobile-stack');
if (this.filteredData.length === 0) {
const message = this.searchTerm ?
`No contacts match "${this.searchTerm}"` :
'No contacts data available';
tbody.innerHTML = `
<tr>
<td colspan="11" class="text-center text-muted">
${message}
</td>
</tr>
`;
if (mobileStack) {
mobileStack.innerHTML = `<div class="text-center text-muted py-4">${message}</div>`;
}
this.updateBulkDeleteButton();
this.updateSelectAllCheckbox();
return;
}
tbody.innerHTML = this.filteredData.map(contact => `
<tr>
<td class="text-center align-middle">
<input type="checkbox" class="form-check-input contact-row-checkbox" data-user-id="${this.escapeHtml(contact.user_id)}" ${this.selectedContactIds.has(contact.user_id) ? 'checked' : ''}>
</td>
<td>
<strong>${contact.username || 'Unknown'}</strong>
<br>
<small class="text-muted">${contact.user_id ? contact.user_id.substring(0, 16) + '...' : 'Unknown'}</small>
</td>
<td>${this.formatDeviceType(contact)}</td>
<td>${this.formatLocation(contact)}</td>
<td>${this.formatDistance(contact)}</td>
<td>${this.formatSignal(contact)}</td>
<td><div class="d-flex flex-column align-items-start gap-1">${this.formatHops(contact)}${this.formatPathEncodingBadge(contact)}</div></td>
<td>${this.formatTimestamp(contact.first_heard)}</td>
<td>${this.formatTimeAgo(contact.last_seen)}</td>
<td><span class="badge bg-success">${contact.advert_count || 0}</span></td>
<td>
<div class="btn-group" role="group">
<button class="btn btn-sm ${contact.is_starred ? 'btn-warning' : 'btn-outline-warning'}" onclick="contactsManager.toggleStar('${contact.user_id.replace(/'/g, "\\'")}', this)" title="${contact.is_starred ? 'Unstar contact' : 'Star contact'}">
<i class="fas fa-star"></i>
</button>
<button class="btn btn-sm btn-outline-info" onclick="contactsManager.viewAdvertData('${contact.user_id}')" title="View Advertisement Data">
<i class="fas fa-info-circle"></i>
</button>
${contact.latitude && contact.longitude && contact.latitude !== 0 && contact.longitude !== 0 ?
`<button class="btn btn-sm btn-outline-primary" onclick="contactsManager.geocodeContact('${contact.user_id.replace(/'/g, "\\'")}', this)" title="Geocode Location">
<i class="fas fa-map-marker-alt"></i>
</button>` :
''
}
<button class="btn btn-sm btn-outline-danger" onclick="contactsManager.showDeleteConfirmation('${contact.user_id.replace(/'/g, "\\'")}', '${(contact.username || 'Unknown').replace(/'/g, "\\'")}')" title="Delete Contact">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
if (mobileStack) {
mobileStack.innerHTML = this.filteredData.map(contact => this.renderContactCardHtml(contact)).join('');
}
// Setup path tooltips after rendering
this.setupPathTooltips();
this.updateBulkDeleteButton();
this.updateSelectAllCheckbox();
if (mobileStack) {
this.initContactsDropdowns(mobileStack);
}
}
updateBulkDeleteButton() {
const btn = document.getElementById('bulk-delete-contacts');
const countEl = document.getElementById('bulk-delete-count');
if (btn && countEl) {
const n = this.selectedContactIds.size;
countEl.textContent = n;
if (n === 0) {
btn.style.display = 'none';
} else {
btn.style.display = '';
btn.disabled = false;
}
}
}
updateSelectAllCheckbox() {
const selectAll = document.getElementById('contacts-select-all');
const selectAllMobile = document.getElementById('contacts-select-all-mobile');
const visibleIds = new Set(this.filteredData.map(c => c.user_id));
const allSelected = visibleIds.size > 0 && [...visibleIds].every(id => this.selectedContactIds.has(id));
const someSelected = [...visibleIds].some(id => this.selectedContactIds.has(id));
const sync = (el) => {
if (!el) return;
el.checked = allSelected;
el.indeterminate = someSelected && !allSelected;
};
sync(selectAll);
sync(selectAllMobile);
}
updateStatistics() {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
const oneWeekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
// Calculate time-based metrics
const contacts24h = this.filteredData.filter(contact => {
const lastSeen = new Date(contact.last_seen);
return lastSeen >= oneDayAgo;
}).length;
const contacts7d = this.filteredData.filter(contact => {
const lastSeen = new Date(contact.last_seen);
return lastSeen >= oneWeekAgo;
}).length;
const contactsAll = this.filteredData.length;
// Calculate advertisement metrics using daily tracking data
// Note: These should now be provided by the server from the daily_advertisements table
// Fallback to client-side calculation if server data not available
// Try to get server-provided daily stats first
const serverStats = this.contactsData.server_stats || {};
console.log('Contacts data structure:', typeof this.contactsData, 'has server_stats:', 'server_stats' in this.contactsData);
console.log('Server stats received:', serverStats);
const adverts24h = (serverStats.advertisements_24h !== undefined) ? serverStats.advertisements_24h : this.filteredData.filter(contact => {
const lastSeen = new Date(contact.last_seen);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const contactDate = new Date(lastSeen.getFullYear(), lastSeen.getMonth(), lastSeen.getDate());
return contactDate.getTime() === today.getTime();
}).reduce((sum, contact) => sum + (contact.advert_count || 0), 0);
const adverts7d = (serverStats.advertisements_7d !== undefined) ? serverStats.advertisements_7d : this.filteredData.filter(contact => {
const lastSeen = new Date(contact.last_seen);
return lastSeen >= oneWeekAgo && lastSeen < oneDayAgo;
}).reduce((sum, contact) => sum + (contact.advert_count || 0), 0);
const advertsAll = (serverStats.total_advertisements !== undefined) ? serverStats.total_advertisements : this.filteredData.reduce((sum, contact) => sum + (contact.advert_count || 0), 0);
console.log('Final advertisement counts:', {
'24h': adverts24h,
'7d': adverts7d,
'All': advertsAll,
'using_server_stats': (serverStats.advertisements_24h !== undefined) &&
(serverStats.advertisements_7d !== undefined) &&
(serverStats.total_advertisements !== undefined)
});
// Calculate new device metrics (devices first seen in the last 7 days)
// Debug: Log device types to see what we're working with
const deviceTypes = [...new Set(this.filteredData.map(contact => contact.device_type))];
console.log('Available device types:', deviceTypes);
console.log('Sample contact data:', this.filteredData[0]);
// Also check for any devices first heard in the last week regardless of type
const anyNewDevices = this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
return firstHeard >= oneWeekAgo;
});
console.log('Any new devices in last week:', anyNewDevices.length);
console.log('Sample new device:', anyNewDevices[0]);
// Try different possible device type values
const newCompanions = this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
const deviceType = (contact.device_type || '').toLowerCase();
return firstHeard >= oneWeekAgo && (
deviceType === 'companion' ||
deviceType === 'companions' ||
deviceType.includes('companion')
);
}).length;
const newRepeaters = this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
const deviceType = (contact.device_type || '').toLowerCase();
return firstHeard >= oneWeekAgo && (
deviceType === 'repeater' ||
deviceType === 'repeaters' ||
deviceType.includes('repeater')
);
}).length;
const newRoomServers = this.filteredData.filter(contact => {
const firstHeard = new Date(contact.first_heard);
const deviceType = (contact.device_type || '').toLowerCase();
return firstHeard >= oneWeekAgo && (
deviceType === 'room_server' ||
deviceType === 'roomserver' ||
deviceType === 'room_servers' ||
deviceType.includes('room') ||
deviceType.includes('server')
);
}).length;
// Debug: Log the counts
console.log('New companions:', newCompanions);
console.log('New repeaters:', newRepeaters);
console.log('New room servers:', newRoomServers);
// Update contact metrics
document.getElementById('contacts-24h').textContent = contacts24h;
document.getElementById('contacts-7d').textContent = contacts7d;
document.getElementById('contacts-all').textContent = contactsAll;
// Update advertisement metrics
document.getElementById('adverts-24h').textContent = adverts24h;
document.getElementById('adverts-7d').textContent = adverts7d;
document.getElementById('adverts-all').textContent = advertsAll;
// Update new device metrics
document.getElementById('new-companions').textContent = newCompanions;
document.getElementById('new-repeaters').textContent = newRepeaters;
document.getElementById('new-room-servers').textContent = newRoomServers;
// Update activity trends chart
this.updateActivityTrendsChart();
}
updateActivityTrendsChart() {
const serverStats = this.contactsData.server_stats || {};
const dailyDataByRole = serverStats.daily_nodes_30d_by_role || [];
const nodesTodayByRole = serverStats.nodes_24h_by_role || {};
// Destroy existing chart if it exists
if (this.activityChart) {
this.activityChart.destroy();
}
// Prepare data for the last 30 days by role
// Create a map of date -> role counts for quick lookup
const dataMapByRole = {};
dailyDataByRole.forEach(item => {
dataMapByRole[item.date] = {
companion: item.companion || 0,
repeater: item.repeater || 0,
roomserver: item.roomserver || 0,
sensor: item.sensor || 0,
other: item.other || 0
};
});
// Generate labels and data arrays for each role
const labels = [];
const companionData = [];
const repeaterData = [];
const roomserverData = [];
const sensorData = [];
const otherData = [];
const today = new Date();
const todayStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format for today
for (let i = 29; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD format
labels.push(date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }));
// Use today's data from server stats if this is today, otherwise use dataMapByRole
if (dateStr === todayStr) {
companionData.push(nodesTodayByRole.companion || 0);
repeaterData.push(nodesTodayByRole.repeater || 0);
roomserverData.push(nodesTodayByRole.roomserver || 0);
sensorData.push(nodesTodayByRole.sensor || 0);
otherData.push(nodesTodayByRole.other || 0);
} else {
const dayData = dataMapByRole[dateStr] || {};
companionData.push(dayData.companion || 0);
repeaterData.push(dayData.repeater || 0);
roomserverData.push(dayData.roomserver || 0);
sensorData.push(dayData.sensor || 0);
otherData.push(dayData.other || 0);
}
}
// Get canvas context
const ctx = document.getElementById('activityTrendsChart');
if (!ctx) return;
// Create the stacked chart
this.activityChart = new Chart(ctx, {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Companions',
data: companionData,
borderColor: 'rgb(255, 193, 7)', // Yellow/warning color
backgroundColor: 'rgba(255, 193, 7, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Repeaters',
data: repeaterData,
borderColor: 'rgb(13, 110, 253)', // Blue/primary color
backgroundColor: 'rgba(13, 110, 253, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Room Servers',
data: roomserverData,
borderColor: 'rgb(25, 135, 84)', // Green/success color
backgroundColor: 'rgba(25, 135, 84, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Sensors',
data: sensorData,
borderColor: 'rgb(13, 202, 240)', // Light blue/info color
backgroundColor: 'rgba(13, 202, 240, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
},
{
label: 'Other',
data: otherData,
borderColor: 'rgb(108, 117, 125)', // Gray/secondary color
backgroundColor: 'rgba(108, 117, 125, 0.6)',
tension: 0.4,
fill: true,
pointRadius: 0,
pointHoverRadius: 3
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11
}
}
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
footer: function(tooltipItems) {
let total = 0;
tooltipItems.forEach(item => {
total += item.parsed.y || 0;
});
return 'Total: ' + total;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
display: false
},
ticks: {
maxRotation: 45,
minRotation: 45,
font: {
size: 9
}
}
},
y: {
stacked: true,
beginAtZero: true,
ticks: {
precision: 0
},
grid: {
color: 'rgba(0, 0, 0, 0.05)'
}
}
},
interaction: {
mode: 'index',
intersect: false
}
}
});
}
formatLastSeen(lastSeen) {
if (!lastSeen) return 'Never';
const date = new Date(lastSeen);
const now = new Date();
const diffMs = now - date;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths > 0) {
return `${diffMonths}mo ago`;
} else if (diffWeeks > 0) {
return `${diffWeeks}w ago`;
} else if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes}m ago`;
} else {
return 'Just now';
}
}
formatLastMessage(lastMessage) {
if (!lastMessage) return 'Never';
const date = new Date(lastMessage);
const now = new Date();
const diffMs = now - date;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths > 0) {
return `${diffMonths}mo ago`;
} else if (diffWeeks > 0) {
return `${diffWeeks}w ago`;
} else if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes}m ago`;
} else {
return 'Just now';
}
}
getStatusBadge(lastSeen) {
if (!lastSeen) return '<span class="badge bg-secondary">Unknown</span>';
const date = new Date(lastSeen);
const now = new Date();
const diffMs = now - date;
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours < 1) {
return '<span class="badge bg-success">Online</span>';
} else if (diffHours < 24) {
return '<span class="badge bg-warning">Recent</span>';
} else {
return '<span class="badge bg-secondary">Offline</span>';
}
}
formatLocation(contact) {
if (contact.city && contact.state) {
return `${contact.city}, ${contact.state}`;
} else if (contact.city) {
return contact.city;
} else if (contact.latitude && contact.longitude) {
return `${contact.latitude.toFixed(4)}, ${contact.longitude.toFixed(4)}`;
} else {
return '<span class="text-muted">Unknown</span>';
}
}
formatDistance(contact) {
if (contact.distance !== undefined && contact.distance !== null) {
if (contact.distance < 1) {
return `${(contact.distance * 1000).toFixed(0)}m`;
} else {
return `${contact.distance.toFixed(1)}km`;
}
} else {
return '<span class="text-muted">N/A</span>';
}
}
formatDeviceType(contact) {
const deviceType = contact.device_type || 'Unknown';
const role = contact.role || '';
// Color code based on role
let badgeClass = 'bg-secondary'; // Default for unknown
let displayText = deviceType;
if (role.toLowerCase() === 'repeater') {
badgeClass = 'bg-primary'; // Blue for Repeater
} else if (role.toLowerCase() === 'roomserver') {
badgeClass = 'bg-success'; // Green for RoomServer
} else if (role.toLowerCase() === 'companion') {
badgeClass = 'bg-warning'; // Yellow for Companion
} else if (role.toLowerCase() === 'sensor') {
badgeClass = 'bg-info'; // Light blue for Sensor
}
return `<span class="badge ${badgeClass}">${displayText}</span>`;
}
formatSignal(contact) {
if (contact.snr !== null && contact.snr !== undefined) {
const snr = parseFloat(contact.snr);
if (snr > 0) {
return `<span class="badge bg-success">${snr.toFixed(1)} dB</span>`;
} else {
return `<span class="badge bg-warning">${snr.toFixed(1)} dB</span>`;
}
} else {
return '<span class="text-muted">N/A</span>';
}
}
formatHops(contact) {
const hopCount = contact.hop_count || 0;
const outPath = contact.out_path || '';
const outPathLen = contact.out_path_len || -1;
const allPaths = contact.all_paths || [];
const hasMultiplePaths = allPaths.length > 1;
// Escape the path for use in HTML attributes
const escapedPath = outPath.replace(/"/g, '&quot;');
const escapedUserId = contact.user_id.replace(/"/g, '&quot;');
// If we have path data, add tooltip and click functionality
if (outPath && outPathLen > 0) {
// Create a unique ID for this tooltip
const tooltipId = `hops-tooltip-${contact.user_id.substring(0, 16).replace(/[^a-zA-Z0-9]/g, '_')}`;
const pathCountText = hasMultiplePaths ? ` <small>(${allPaths.length} paths)</small>` : '';
const bytesPerHop = contact.out_bytes_per_hop != null && [1, 2, 3].includes(Number(contact.out_bytes_per_hop)) ? Number(contact.out_bytes_per_hop) : '';
return `<span class="badge bg-primary path-badge"
id="${tooltipId}"
data-path-hex="${escapedPath}"
data-path-len="${outPathLen}"
data-bytes-per-hop="${bytesPerHop}"
data-user-id="${escapedUserId}"
onclick="contactsManager.showPathsModal('${escapedUserId}')"
style="cursor: pointer; position: relative;"
title="${hasMultiplePaths ? 'Click to view all paths' : 'Click to view path'}">
${hopCount}${pathCountText}
</span>`;
} else if (outPathLen === 0) {
// Direct connection
return `<span class="badge bg-primary"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Direct connection (0 hops)">
${hopCount}
</span>`;
} else {
// No path data
return `<span class="badge bg-primary">${hopCount}</span>`;
}
}
formatPathEncodingBadge(contact) {
const v = contact.path_encoding_badge;
if (!v) return '';
if (v === 'multibyte') {
return '<span class="badge path-encoding-badge path-encoding-badge-multibyte">Multibyte</span>';
}
if (v === 'one_byte') {
return '<span class="badge path-encoding-badge path-encoding-badge-onebyte">1-byte only</span>';
}
return '';
}
setupPathTooltips() {
// Create a global custom tooltip element if it doesn't exist
let customTooltip = document.getElementById('custom-path-tooltip');
if (!customTooltip) {
customTooltip = document.createElement('div');
customTooltip.id = 'custom-path-tooltip';
customTooltip.className = 'custom-path-tooltip';
customTooltip.style.cssText = 'position: absolute; z-index: 1070; display: none; padding: 0.5rem; background-color: rgba(0, 0, 0, 0.9); color: white; border-radius: 0.25rem; font-size: 0.875rem; max-width: 300px; pointer-events: none;';
document.body.appendChild(customTooltip);
}
// Setup event listeners for path tooltips after rendering
document.querySelectorAll('.path-badge').forEach(badge => {
const pathHex = badge.dataset.pathHex;
if (!pathHex) return;
// Check if tooltip is already set up
if (badge.dataset.tooltipSetup === 'true') return;
badge.dataset.tooltipSetup = 'true';
// Load path data on first hover (for tooltip)
let pathLoaded = false;
let pathContent = 'Loading path...';
const showTooltip = (event) => {
const rect = badge.getBoundingClientRect();
customTooltip.innerHTML = pathContent;
customTooltip.style.display = 'block';
customTooltip.style.left = (rect.left + rect.width / 2 - customTooltip.offsetWidth / 2) + 'px';
customTooltip.style.top = (rect.top - customTooltip.offsetHeight - 8) + 'px';
// Adjust if tooltip goes off screen
const tooltipRect = customTooltip.getBoundingClientRect();
if (tooltipRect.left < 10) {
customTooltip.style.left = '10px';
}
if (tooltipRect.right > window.innerWidth - 10) {
customTooltip.style.left = (window.innerWidth - customTooltip.offsetWidth - 10) + 'px';
}
if (tooltipRect.top < 10) {
customTooltip.style.top = (rect.bottom + 8) + 'px';
}
};
const hideTooltip = () => {
customTooltip.style.display = 'none';
};
// Only show tooltip on hover, click opens modal
badge.addEventListener('mouseenter', async (event) => {
// Don't show tooltip if user is clicking (will open modal)
if (event.target === badge && event.type === 'mouseenter') {
showTooltip(event);
// If path is already loaded, we're done
if (pathLoaded) {
return;
}
// If we're already loading, don't start another request
if (badge.dataset.loading === 'true') {
return;
}
try {
badge.dataset.loading = 'true';
const body = { path_hex: pathHex };
const bytesPerHop = badge.dataset.bytesPerHop;
if (bytesPerHop && ['1', '2', '3'].includes(bytesPerHop)) {
body.bytes_per_hop = parseInt(bytesPerHop, 10);
}
// Fetch decoded path from API
const response = await fetch('/api/decode-path', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to decode path');
}
if (!data.path || !Array.isArray(data.path)) {
throw new Error('Invalid path data format');
}
// Format the path response
const pathLines = data.path.map(node => {
if (node.found) {
// Escape HTML to prevent XSS
const name = this.escapeHtml(node.name);
// Add ❓ emoji if this is a geographic guess (collision)
const guessIndicator = node.geographic_guess ? ' ❓' : '';
return `${node.node_id}: ${name}${guessIndicator}`;
} else {
return `${node.node_id}: Unknown`;
}
});
pathContent = pathLines.length > 0 ? pathLines.join('<br>') : 'No path data';
pathLoaded = true;
badge.dataset.loading = 'false';
// Update tooltip content if still hovering
if (badge.matches(':hover')) {
showTooltip(event);
}
} catch (error) {
console.error('Error loading path tooltip:', error);
pathContent = 'Error loading path: ' + error.message;
badge.dataset.loading = 'false';
// Update tooltip content if still hovering
if (badge.matches(':hover')) {
showTooltip(event);
}
}
}
});
badge.addEventListener('mouseleave', hideTooltip);
badge.addEventListener('mousemove', (event) => {
if (badge.matches(':hover')) {
showTooltip(event);
}
});
});
}
formatPathHex(pathHex, bytesPerHop) {
// Break hex string into chunks for readability. bytesPerHop 1,2,3 → 2,4,6 hex chars per node.
if (!pathHex) return '';
const n = (bytesPerHop && [1, 2, 3].includes(Number(bytesPerHop))) ? (Number(bytesPerHop) * 2) : 2;
const chunks = [];
for (let i = 0; i < pathHex.length; i += n) {
chunks.push(pathHex.slice(i, i + n));
}
return chunks.join(' ') || pathHex;
}
sortPaths(paths, sortOrder) {
// Create a copy to avoid mutating the original array
const sortedPaths = [...paths];
switch (sortOrder) {
case 'length':
// Sort by path_length ascending (shortest first)
sortedPaths.sort((a, b) => {
const aLength = a.path_length || 0;
const bLength = b.path_length || 0;
return aLength - bLength;
});
break;
case 'recency':
// Sort by last_seen descending (most recent first)
sortedPaths.sort((a, b) => {
const aDate = a.last_seen ? new Date(a.last_seen) : new Date(0);
const bDate = b.last_seen ? new Date(b.last_seen) : new Date(0);
return bDate - aDate;
});
break;
case 'frequency':
// Sort by observation_count descending (most observations first)
sortedPaths.sort((a, b) => {
const aCount = a.observation_count || 0;
const bCount = b.observation_count || 0;
return bCount - aCount;
});
break;
default:
// Default to length sorting (shortest first)
sortedPaths.sort((a, b) => {
const aLength = a.path_length || 0;
const bLength = b.path_length || 0;
return aLength - bLength;
});
}
return sortedPaths;
}
renderPathsInModal(paths, primaryPath) {
if (paths.length === 0) {
return '<p class="text-muted">No path data available for this contact.</p>';
}
let pathsHtml = '<div class="list-group paths-list-group">';
for (let i = 0; i < paths.length; i++) {
const path = paths[i];
const isPrimary = path.path_hex === primaryPath;
const primaryBadge = isPrimary ? '<span class="badge bg-success ms-2">Primary</span>' : '';
const bph = path.bytes_per_hop != null && [1, 2, 3].includes(Number(path.bytes_per_hop)) ? Number(path.bytes_per_hop) : null;
const formattedPathHex = this.formatPathHex(path.path_hex, bph);
const hopLabel = bph && path.path_length > 0 ? `${Math.floor(path.path_length / bph)} hops, ` : '';
const bytesPerHopAttr = bph != null ? ` data-path-bph="${bph}"` : '';
pathsHtml += `
<div class="list-group-item path-list-item ${isPrimary ? 'border-success' : ''}" id="path-item-${i}"${bytesPerHopAttr}>
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
Path ${i + 1} ${primaryBadge}
<small class="text-muted">(${hopLabel}${path.path_length} bytes, ${path.observation_count} observations)</small>
</h6>
<p class="mb-1"><code class="path-hex-code">${this.escapeHtml(formattedPathHex)}</code></p>
<small class="text-muted">Last seen: ${this.formatTimeAgo(path.last_seen)}</small>
</div>
<button class="btn btn-sm btn-outline-primary" onclick="contactsManager.decodePathInModal('${String(path.path_hex).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', ${i}, ${bph != null ? bph : 'null'})">
<i class="fas fa-code"></i> Decode
</button>
</div>
<div id="decoded-path-${i}" class="mt-2" style="display: none;"></div>
</div>
`;
}
pathsHtml += '</div>';
return pathsHtml;
}
async showPathsModal(userId) {
// Find the contact data
const contact = this.filteredData.find(c => c.user_id === userId) ||
this.contactsData.tracking_data.find(c => c.user_id === userId);
if (!contact) {
this.showError('Contact data not found');
return;
}
// Store contact and paths for re-rendering
this.currentModalContact = contact;
this.currentModalPaths = contact.all_paths || [];
const primaryPath = contact.out_path || '';
// Load sort preference from localStorage (default to 'length')
const sortOrder = localStorage.getItem('contactsPathSortOrder') || 'length';
// Sort paths according to preference
const sortedPaths = this.sortPaths(this.currentModalPaths, sortOrder);
// Build modal content
const pathsHtml = this.renderPathsInModal(sortedPaths, primaryPath);
// Create modal HTML with dark mode support
const modalHtml = `
<div class="modal fade" id="pathsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content paths-modal-content">
<div class="modal-header paths-modal-header">
<h5 class="modal-title">
<i class="fas fa-route"></i> Paths for ${this.escapeHtml(contact.username || 'Unknown')}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body paths-modal-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<p class="text-muted mb-0">Showing ${sortedPaths.length} path${sortedPaths.length !== 1 ? 's' : ''} from observed_paths table.</p>
<div class="d-flex align-items-center gap-2">
<label for="pathSortOrder" class="mb-0 small text-muted">Sort by:</label>
<select class="form-select form-select-sm" id="pathSortOrder" style="width: auto;">
<option value="length" ${sortOrder === 'length' ? 'selected' : ''}>Length</option>
<option value="recency" ${sortOrder === 'recency' ? 'selected' : ''}>Recency</option>
<option value="frequency" ${sortOrder === 'frequency' ? 'selected' : ''}>Frequency</option>
</select>
</div>
</div>
<div id="paths-list-container">
${pathsHtml}
</div>
</div>
<div class="modal-footer paths-modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('pathsModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Setup sort dropdown event handler
const sortSelect = document.getElementById('pathSortOrder');
if (sortSelect) {
sortSelect.addEventListener('change', (e) => {
const newSortOrder = e.target.value;
// Save preference to localStorage
localStorage.setItem('contactsPathSortOrder', newSortOrder);
// Re-sort paths
const reSortedPaths = this.sortPaths(this.currentModalPaths, newSortOrder);
// Re-render paths
const pathsContainer = document.getElementById('paths-list-container');
if (pathsContainer) {
pathsContainer.innerHTML = this.renderPathsInModal(reSortedPaths, primaryPath);
}
});
}
// Show modal
const modal = new bootstrap.Modal(document.getElementById('pathsModal'));
modal.show();
// Clean up modal when hidden
document.getElementById('pathsModal').addEventListener('hidden.bs.modal', () => {
document.getElementById('pathsModal').remove();
// Clear stored data
this.currentModalContact = null;
this.currentModalPaths = null;
});
}
async decodePathInModal(pathHex, pathIndex, pathBytesPerHop) {
const decodedDiv = document.getElementById(`decoded-path-${pathIndex}`);
const pathItem = document.getElementById(`path-item-${pathIndex}`);
const button = pathItem ? pathItem.querySelector('button') : null;
if (!decodedDiv || !button) return;
// Show loading state
const originalHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Decoding...';
decodedDiv.style.display = 'block';
decodedDiv.innerHTML = '<div class="text-muted"><i class="fas fa-spinner fa-spin"></i> Decoding path...</div>';
try {
// Prefer this path's bytes_per_hop; fall back to contact's out_bytes_per_hop
const bytesPerHop = (pathBytesPerHop != null && [1, 2, 3].includes(Number(pathBytesPerHop)))
? Number(pathBytesPerHop)
: (this.currentModalContact?.out_bytes_per_hop != null && [1, 2, 3].includes(Number(this.currentModalContact.out_bytes_per_hop)) ? Number(this.currentModalContact.out_bytes_per_hop) : null);
const body = { path_hex: pathHex };
if (bytesPerHop != null) {
body.bytes_per_hop = bytesPerHop;
}
const response = await fetch('/api/decode-path', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify(body)
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to decode path');
}
if (!data.path || !Array.isArray(data.path)) {
throw new Error('Invalid path data format');
}
// Format the decoded path
const pathLines = data.path.map(node => {
if (node.found) {
const name = this.escapeHtml(node.name);
const guessIndicator = node.geographic_guess ? ' <span class="badge bg-warning">Guess</span>' : '';
return `<div class="mb-1"><strong>${node.node_id}:</strong> ${name}${guessIndicator}</div>`;
} else {
return `<div class="mb-1 text-muted"><strong>${node.node_id}:</strong> Unknown</div>`;
}
});
decodedDiv.innerHTML = `
<div class="alert alert-info decoded-path-alert mb-0">
<strong>Decoded Path:</strong>
<div class="mt-2 decoded-path-content">${pathLines.join('')}</div>
</div>
`;
} catch (error) {
console.error('Error decoding path:', error);
decodedDiv.innerHTML = `
<div class="alert alert-danger mb-0">
<strong>Error:</strong> ${this.escapeHtml(error.message)}
</div>
`;
} finally {
button.disabled = false;
button.innerHTML = originalHtml;
}
}
formatTimestamp(timestamp) {
if (!timestamp) return '<span class="text-muted">Never</span>';
const date = new Date(timestamp);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
formatTimeAgo(timestamp) {
if (!timestamp) return '<span class="text-muted">Never</span>';
const date = new Date(timestamp);
const now = new Date();
const diffMs = now - date;
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
const diffWeeks = Math.floor(diffDays / 7);
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths > 0) {
return `${diffMonths}mo ago`;
} else if (diffWeeks > 0) {
return `${diffWeeks}w ago`;
} else if (diffDays > 0) {
return `${diffDays}d ago`;
} else if (diffHours > 0) {
return `${diffHours}h ago`;
} else if (diffMinutes > 0) {
return `${diffMinutes}m ago`;
} else {
return '<span class="text-success">Just now</span>';
}
}
viewAdvertData(userId) {
// Find the contact data
const contact = this.filteredData.find(c => c.user_id === userId);
if (!contact) {
alert('Contact data not found');
return;
}
// Create a modal to display the advertisement data
const modalHtml = `
<div class="modal fade" id="advertModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Advertisement Data - ${contact.username || 'Unknown'}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<pre class="bg-light p-3 rounded" style="background-color: var(--bg-secondary); color: var(--text-color);">${JSON.stringify(contact.raw_advert_data_parsed || contact.raw_advert_data || 'No advertisement data available', null, 2)}</pre>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('advertModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Show modal
const modal = new bootstrap.Modal(document.getElementById('advertModal'));
modal.show();
}
async geocodeContact(userId, buttonElement = null) {
// Find the contact data
const contact = this.filteredData.find(c => c.user_id === userId);
if (!contact) {
this.showError('Contact data not found');
return;
}
// Check if contact has valid coordinates
if (!contact.latitude || !contact.longitude || contact.latitude === 0 || contact.longitude === 0) {
this.showError('Contact does not have valid coordinates');
return;
}
// Get the button element - use passed element or find it
const button = buttonElement || document.querySelector(`button[onclick*="${userId.substring(0, 16)}"]`);
if (!button) {
this.showError('Button not found');
return;
}
// Disable button and show loading state
const originalHTML = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
button.classList.remove('btn-outline-primary');
button.classList.add('btn-secondary');
try {
// Call the geocoding API
const response = await fetch('/api/geocode-contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
public_key: userId
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Geocoding failed');
}
// Check if we got any location data
if (!data.location || (!data.location.city && !data.location.state && !data.location.country)) {
throw new Error('Geocoding returned no location data');
}
// Update the contact data in our local array
const contactIndex = this.filteredData.findIndex(c => c.user_id === userId);
if (contactIndex !== -1) {
this.filteredData[contactIndex].city = data.location.city || this.filteredData[contactIndex].city;
this.filteredData[contactIndex].state = data.location.state || this.filteredData[contactIndex].state;
this.filteredData[contactIndex].country = data.location.country || this.filteredData[contactIndex].country;
}
// Also update in the main contacts data
const mainContactIndex = this.contactsData.tracking_data.findIndex(c => c.user_id === userId);
if (mainContactIndex !== -1) {
this.contactsData.tracking_data[mainContactIndex].city = data.location.city || this.contactsData.tracking_data[mainContactIndex].city;
this.contactsData.tracking_data[mainContactIndex].state = data.location.state || this.contactsData.tracking_data[mainContactIndex].state;
this.contactsData.tracking_data[mainContactIndex].country = data.location.country || this.contactsData.tracking_data[mainContactIndex].country;
}
// Re-render the table to show updated location
this.renderContactsData();
// Show success message
this.showSuccess(data.message || 'Location geocoded successfully');
} catch (error) {
console.error('Error geocoding contact:', error);
this.showError('Failed to geocode contact: ' + error.message);
} finally {
// Restore button state
button.disabled = false;
button.innerHTML = originalHTML;
button.classList.remove('btn-secondary');
button.classList.add('btn-outline-primary');
}
}
async toggleStar(userId, buttonElement = null) {
// Find the contact data
const contact = this.filteredData.find(c => c.user_id === userId);
if (!contact) {
this.showError('Contact data not found');
return;
}
// Get the button element - use passed element or find it
const button = buttonElement || document.querySelector(`button[onclick*="toggleStar('${userId.substring(0, 16)}"]`);
if (!button) {
this.showError('Button not found');
return;
}
// Disable button and show loading state
const originalHTML = button.innerHTML;
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
try {
// Call the toggle star API
const response = await fetch('/api/toggle-star-contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
public_key: userId
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to toggle star status');
}
// Update the contact data in our local array
const contactIndex = this.filteredData.findIndex(c => c.user_id === userId);
if (contactIndex !== -1) {
this.filteredData[contactIndex].is_starred = data.is_starred;
}
// Also update in the main contacts data
const mainContactIndex = this.contactsData.tracking_data.findIndex(c => c.user_id === userId);
if (mainContactIndex !== -1) {
this.contactsData.tracking_data[mainContactIndex].is_starred = data.is_starred;
}
// Re-render the table to show updated star status
this.renderContactsData();
// Show success message
const action = data.is_starred ? 'starred' : 'unstarred';
this.showSuccess(`Contact ${action} successfully. Path command will ${data.is_starred ? 'strongly prefer' : 'no longer prefer'} this repeater.`);
} catch (error) {
console.error('Error toggling star status:', error);
this.showError('Failed to toggle star status: ' + error.message);
} finally {
// Restore button state
button.disabled = false;
button.innerHTML = originalHTML;
}
}
showDeleteConfirmation(userId, username) {
// Find the contact data
const contact = this.filteredData.find(c => c.user_id === userId);
if (!contact) {
this.showError('Contact data not found');
return;
}
// Create a modal for delete confirmation
const modalHtml = `
<div class="modal fade" id="deleteContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">
<i class="fas fa-exclamation-triangle"></i> Delete Contact
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-3">
<strong>Are you sure you want to delete this contact?</strong>
</p>
<div class="alert alert-warning">
<strong>Contact Information:</strong><br>
<strong>Name:</strong> ${this.escapeHtml(username || 'Unknown')}<br>
<strong>Public Key:</strong> <code>${userId.substring(0, 16)}...</code>
</div>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> This will permanently delete all tracking data for this contact, including:
<ul class="mb-0 mt-2">
<li>Contact history and statistics</li>
<li>Location information</li>
<li>Advertisement data</li>
<li>Daily statistics</li>
</ul>
<p class="mb-0 mt-2">
<strong>This action cannot be undone.</strong> The contact is not banned and may reappear if heard again.
</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="fas fa-trash"></i> Delete Contact
</button>
</div>
</div>
</div>
</div>
`;
// Remove existing modal if any
const existingModal = document.getElementById('deleteContactModal');
if (existingModal) {
existingModal.remove();
}
// Add modal to page
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Get the modal element
const modalElement = document.getElementById('deleteContactModal');
const modal = new bootstrap.Modal(modalElement);
// Set up delete button handler
const confirmBtn = document.getElementById('confirmDeleteBtn');
confirmBtn.addEventListener('click', () => {
this.deleteContact(userId, username, modalElement);
});
// Show modal
modal.show();
// Clean up modal when hidden
modalElement.addEventListener('hidden.bs.modal', () => {
modalElement.remove();
});
}
async deleteContact(userId, username, modalElement) {
// Disable the delete button and show loading state
const confirmBtn = document.getElementById('confirmDeleteBtn');
const originalHTML = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
try {
// Call the delete API
const response = await fetch('/api/delete-contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({
public_key: userId
})
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to delete contact');
}
// Close the modal
const modal = bootstrap.Modal.getInstance(modalElement);
if (modal) {
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();
// Show success message
this.showSuccess(`Contact "${username || 'Unknown'}" has been deleted successfully.`);
} catch (error) {
console.error('Error deleting contact:', error);
this.showError('Failed to delete contact: ' + error.message);
// Restore button state
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalHTML;
}
}
showBulkDeleteConfirmation() {
const count = this.selectedContactIds.size;
if (count === 0) return;
const contactList = this.filteredData
.filter(c => this.selectedContactIds.has(c.user_id))
.map(c => (c.username || 'Unknown') + (c.user_id ? ' (' + c.user_id.substring(0, 12) + '...)' : ''));
const listPreview = contactList.length <= 5
? contactList.join(', ')
: contactList.slice(0, 5).join(', ') + ' and ' + (contactList.length - 5) + ' more';
const modalHtml = `
<div class="modal fade" id="bulkDeleteContactModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title">
<i class="fas fa-exclamation-triangle"></i> Delete ${count} Contact${count !== 1 ? 's' : ''}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="mb-3">
<strong>Are you sure you want to delete ${count} selected contact${count !== 1 ? 's' : ''}?</strong>
</p>
<div class="alert alert-warning">
<strong>Selected:</strong> ${this.escapeHtml(listPreview)}
</div>
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> This will permanently delete all tracking data for the selected contact${count !== 1 ? 's' : ''}, including:
<ul class="mb-0 mt-2">
<li>Contact history and statistics</li>
<li>Location information</li>
<li>Advertisement data</li>
<li>Daily statistics</li>
</ul>
<p class="mb-0 mt-2">
<strong>This action cannot be undone.</strong> Contacts are not banned and may reappear if heard again.
</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmBulkDeleteBtn">
<i class="fas fa-trash"></i> Delete ${count} contact${count !== 1 ? 's' : ''}
</button>
</div>
</div>
</div>
</div>
`;
const existingModal = document.getElementById('bulkDeleteContactModal');
if (existingModal) existingModal.remove();
document.body.insertAdjacentHTML('beforeend', modalHtml);
const modalElement = document.getElementById('bulkDeleteContactModal');
const modal = new bootstrap.Modal(modalElement);
document.getElementById('confirmBulkDeleteBtn').addEventListener('click', () => {
this.deleteSelectedContacts(modalElement);
});
modal.show();
modalElement.addEventListener('hidden.bs.modal', () => modalElement.remove());
}
async deleteSelectedContacts(modalElement) {
const confirmBtn = document.getElementById('confirmBulkDeleteBtn');
const originalHTML = confirmBtn.innerHTML;
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
const toDelete = [...this.selectedContactIds];
let successCount = 0;
let failCount = 0;
try {
for (const userId of toDelete) {
try {
const response = await fetch('/api/delete-contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ public_key: userId })
});
const data = await response.json();
if (response.ok && data.success) successCount++;
else failCount++;
} catch {
failCount++;
}
}
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();
if (failCount === 0) {
this.showSuccess(`${successCount} contact${successCount !== 1 ? 's' : ''} deleted successfully.`);
} else {
this.showError(`${successCount} deleted, ${failCount} failed.`);
}
} catch (error) {
console.error('Error during bulk delete:', error);
this.showError('Failed to delete contacts: ' + error.message);
confirmBtn.disabled = false;
confirmBtn.innerHTML = originalHTML;
}
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
showError(message) {
const errorDiv = document.createElement('div');
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
errorDiv.setAttribute('role', 'alert');
errorDiv.innerHTML = `
<strong>Error:</strong> ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
const content = document.querySelector('.container-fluid');
if (content) {
content.insertBefore(errorDiv, content.firstChild);
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
}
showSuccess(message) {
const successDiv = document.createElement('div');
successDiv.className = 'alert alert-success alert-dismissible fade show';
successDiv.setAttribute('role', 'alert');
successDiv.innerHTML = `
<strong>Success:</strong> ${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
const content = document.querySelector('.container-fluid');
if (content) {
content.insertBefore(successDiv, content.firstChild);
setTimeout(() => {
if (successDiv.parentNode) {
successDiv.parentNode.removeChild(successDiv);
}
}, 5000);
}
}
// ── Purge Inactive Contacts ──────────────────────────────────────────────
async loadPurgePreview(days) {
const previewText = document.getElementById('purge-preview-text');
const confirmBtn = document.getElementById('confirm-purge-btn');
const confirmLabel = document.getElementById('confirm-purge-label');
if (!previewText) return;
previewText.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Loading...';
confirmBtn.disabled = true;
try {
const resp = await fetch(`/api/contacts/purge-preview?days=${days}`);
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to load preview');
if (data.count === 0) {
previewText.innerHTML = `<i class="fas fa-check-circle text-success me-1"></i>No contacts found that are older than <strong>${days} days</strong>.`;
confirmBtn.disabled = true;
} else {
const sampleNames = (data.samples || []).map(s => `<em>${s.name}</em>`).join(', ');
const more = data.count > 5 ? ` and ${data.count - 5} more` : '';
previewText.innerHTML = `<i class="fas fa-exclamation-triangle text-warning me-1"></i><strong>${data.count}</strong> contact(s) will be deleted: ${sampleNames}${more}.`;
confirmLabel.textContent = `Purge ${data.count} contact(s)`;
confirmBtn.disabled = false;
}
} catch (err) {
previewText.innerHTML = `<i class="fas fa-times-circle text-danger me-1"></i>Error: ${err.message}`;
confirmBtn.disabled = true;
}
}
async executePurge(days) {
const confirmBtn = document.getElementById('confirm-purge-btn');
const confirmLabel = document.getElementById('confirm-purge-label');
const originalLabel = confirmLabel.textContent;
confirmBtn.disabled = true;
confirmLabel.textContent = 'Purging...';
try {
const resp = await fetch('/api/contacts/purge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ days })
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Purge failed');
bootstrap.Modal.getInstance(document.getElementById('purgeContactsModal')).hide();
this.showSuccess(data.message || `Purged ${data.deleted} contact(s)`);
await this.loadContactsData();
} catch (err) {
this.showError('Purge failed: ' + err.message);
confirmLabel.textContent = originalLabel;
confirmBtn.disabled = false;
}
}
setupPurgeModal() {
const modal = document.getElementById('purgeContactsModal');
const select = document.getElementById('purge-days-select');
const confirmBtn = document.getElementById('confirm-purge-btn');
if (!modal) return;
modal.addEventListener('show.bs.modal', () => {
this.loadPurgePreview(parseInt(select.value));
});
select.addEventListener('change', () => {
this.loadPurgePreview(parseInt(select.value));
});
confirmBtn.addEventListener('click', () => {
this.executePurge(parseInt(select.value));
});
}
}
function exportData(dataset, fmt) {
const since = document.getElementById('export-since').value || '30d';
const url = `/api/export/${dataset}?format=${fmt}&since=${since}`;
const a = document.createElement('a');
a.href = url;
a.download = '';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function initContactsMultibyteBadgeTooltip() {
if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return;
const listTip =
'Multibyte vs 1-byte only: multibyte if stored path encoding (out bytes per hop) is 2 or 3, ' +
'any loaded advert path shows multibyte hops (23 bytes per hop), ' +
'or (repeaters / room servers) their public key prefix matches a hop on a multibyte path. ' +
'Loaded paths can include more history than the 7-day dashboard chart.';
const el = document.getElementById('contacts-list-multibyte-badge-info');
if (el) {
const existing = bootstrap.Tooltip.getInstance(el);
if (existing) existing.dispose();
new bootstrap.Tooltip(el, {
title: listTip,
html: false,
customClass: 'multibyte-capable-hint',
});
}
}
// Initialize contacts manager when page loads
document.addEventListener('DOMContentLoaded', () => {
window.contactsManager = new ModernContactsManager();
window.contactsManager.setupPurgeModal();
initContactsMultibyteBadgeTooltip();
});
</script>
<style>
/* Sortable column styling - subtle and clean */
.sortable {
cursor: pointer;
user-select: none;
position: relative;
transition: all 0.2s ease;
}
.sortable i {
margin-left: 5px;
opacity: 0.5;
transition: opacity 0.2s ease;
font-size: 0.8em;
}
.sortable:hover i {
opacity: 0.8;
}
.sort-active i {
opacity: 1;
color: #0d6efd !important;
}
/* Dark mode table styling with better contrast */
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(odd) > td {
background-color: #3a3a3a; /* Slightly lighter than even rows, but not too light */
}
[data-theme="dark"] .table-striped > tbody > tr:nth-of-type(even) > td {
background-color: #2d2d2d; /* Darker gray (card-bg) */
}
[data-theme="dark"] .table-hover > tbody > tr:hover > td {
background-color: #404040 !important; /* Slightly lighter for hover, but maintains good contrast */
}
[data-theme="dark"] .table-dark {
background-color: var(--bg-tertiary);
color: var(--text-color);
}
[data-theme="dark"] .table-dark th {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .table td {
border-color: var(--border-color);
color: var(--text-color);
}
/* Ensure all text in table cells is light in dark mode */
[data-theme="dark"] .table td,
[data-theme="dark"] .table td * {
color: var(--text-color) !important;
}
/* Node names (strong tags) should be light and prominent */
[data-theme="dark"] .table td strong {
color: #ffffff !important;
font-weight: 600;
}
/* Ensure small text is also light */
[data-theme="dark"] .table td small {
color: var(--text-muted) !important;
}
/* Dark mode input group styling */
[data-theme="dark"] .input-group-text {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .input-group .form-control {
background-color: var(--bg-tertiary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .input-group .form-control:focus {
background-color: var(--bg-tertiary);
border-color: #007bff;
color: var(--text-color);
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
/* Custom path tooltip styling */
.custom-path-tooltip {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
line-height: 1.5;
}
[data-theme="dark"] .custom-path-tooltip {
background-color: rgba(40, 40, 40, 0.95) !important;
color: var(--text-color) !important;
border: 1px solid var(--border-color);
}
/* Path modal dark mode styling */
[data-theme="dark"] .paths-modal-content {
background-color: var(--card-bg);
color: var(--text-color);
}
[data-theme="dark"] .paths-modal-header {
background-color: var(--card-header-bg);
border-bottom-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .paths-modal-body {
background-color: var(--card-bg);
color: var(--text-color);
}
[data-theme="dark"] .paths-modal-footer {
background-color: var(--card-header-bg);
border-top-color: var(--border-color);
}
[data-theme="dark"] .paths-list-group .path-list-item {
background-color: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-color);
}
[data-theme="dark"] .paths-list-group .path-list-item:hover {
background-color: var(--bg-tertiary);
}
[data-theme="dark"] .path-hex-code {
background-color: var(--bg-tertiary);
color: var(--text-color);
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: 'Courier New', monospace;
font-size: 0.9em;
}
[data-theme="dark"] .decoded-path-alert {
background-color: rgba(13, 110, 253, 0.2) !important;
border-color: rgba(13, 110, 253, 0.3) !important;
color: var(--text-color) !important;
}
[data-theme="dark"] .decoded-path-content {
color: var(--text-color);
}
</style>
{% endblock %}