mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-05-12 10:34:53 +00:00
c6a7355b3c
- 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.
2713 lines
117 KiB
HTML
2713 lines
117 KiB
HTML
{% 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 (A–Z)</option>
|
||
<option value="username|desc">Name (Z–A)</option>
|
||
<option value="device_type|asc">Device type (A–Z)</option>
|
||
<option value="device_type|desc">Device type (Z–A)</option>
|
||
<option value="location|asc">Location (A–Z)</option>
|
||
<option value="location|desc">Location (Z–A)</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, '"');
|
||
const escapedUserId = contact.user_id.replace(/"/g, '"');
|
||
|
||
// 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 (2–3 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 %}
|