mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-01 16:48:26 +00:00
Removes 26 console.log calls, including contacts.html dumping whole data structures and sample contact records on every render. Variables and callback parameters that existed only to feed those logs go with them (deviceTypes, anyNewDevices, an availableEdges debug block, and a realtime status handler whose body was nothing but a log), so no new no-unused-vars warnings are introduced — the count drops 60 to 59. console.warn and console.error are kept. One console.log survives in realtime.html: the decoder key-count line is a one-time startup message with real diagnostic value, and removing it would mean deleting two counters and their increments inside a loop. Also normalizes the mesh page's user-visible "Neighbours" strings to match the project's American spelling. The option value stays "neighbors", so evidence-mode filtering is untouched.
2845 lines
127 KiB
HTML
2845 lines
127 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,
|
||
.contacts-toolbar-filters #contacts-path-bytes {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.contacts-toolbar-filters #contacts-timespan,
|
||
.contacts-toolbar-filters #contacts-path-bytes {
|
||
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 flex-shrink-0">
|
||
<button type="button" class="btn btn-outline-secondary" id="contacts-filters-trigger"
|
||
data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false"
|
||
title="Filter contacts">
|
||
<i class="fas fa-filter" aria-hidden="true"></i> Filters
|
||
</button>
|
||
<div class="dropdown-menu dropdown-menu-end p-3" style="min-width: 15rem;">
|
||
<div class="mb-2">
|
||
<label class="form-label small mb-1" for="contacts-device-role">Device role</label>
|
||
<select class="form-select form-select-sm contacts-list-filter" id="contacts-device-role">
|
||
<option value="">Any role</option><option value="companion">Companion</option>
|
||
<option value="repeater">Repeater</option><option value="roomserver">Room server</option>
|
||
<option value="sensor">Sensor</option><option value="other">Other / unknown</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-2">
|
||
<label class="form-label small mb-1" for="contacts-path-bytes">Path bytes per hop</label>
|
||
<select class="form-select form-select-sm contacts-list-filter" id="contacts-path-bytes" title="Uses the widest path encoding observed for each contact">
|
||
<option value="">Any bytes/hop</option><option value="1">1 byte/hop</option>
|
||
<option value="2">2 bytes/hop</option><option value="3">3 bytes/hop</option>
|
||
<option value="unknown">Unknown bytes/hop</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-2">
|
||
<label class="form-label small mb-1" for="contacts-hop-filter">Hops</label>
|
||
<select class="form-select form-select-sm contacts-list-filter" id="contacts-hop-filter">
|
||
<option value="">Any hop count</option><option value="0">Direct only</option>
|
||
<option value="1">1+ hops</option><option value="2">2+ hops</option><option value="3">3+ hops</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-2">
|
||
<label class="form-label small mb-1" for="contacts-location-filter">Location</label>
|
||
<select class="form-select form-select-sm contacts-list-filter" id="contacts-location-filter">
|
||
<option value="">Any location</option><option value="known">Location known</option><option value="unknown">Location unknown</option>
|
||
</select>
|
||
</div>
|
||
<div class="mb-3">
|
||
<label class="form-label small mb-1" for="contacts-starred-filter">Starred</label>
|
||
<select class="form-select form-select-sm contacts-list-filter" id="contacts-starred-filter">
|
||
<option value="">Any star state</option><option value="yes">Starred</option><option value="no">Not starred</option>
|
||
</select>
|
||
</div>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary w-100" id="contacts-clear-filters">Clear filters</button>
|
||
</div>
|
||
</div>
|
||
<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 contacts-export-action" href="#" data-export-kind="contacts" data-export-format="csv"><i class="fas fa-file-csv me-2 text-success"></i>CSV</a></li>
|
||
<li><a class="dropdown-item contacts-export-action" href="#" data-export-kind="contacts" data-export-format="json"><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 contacts-export-action" href="#" data-export-kind="paths" data-export-format="csv"><i class="fas fa-file-csv me-2 text-success"></i>CSV</a></li>
|
||
<li><a class="dropdown-item contacts-export-action" href="#" data-export-kind="paths" data-export-format="json"><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="path_bytes|asc">Bytes/hop (low first)</option>
|
||
<option value="path_bytes|desc">Bytes/hop (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 sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="device_type">
|
||
Device Type <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="location">
|
||
Location <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="distance">
|
||
Distance <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="snr">
|
||
SNR <i class="fas fa-sort sort-icon" aria-hidden="true"></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 sort-icon" aria-hidden="true"></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">
|
||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||
</button>
|
||
</span>
|
||
</th>
|
||
<th class="sortable" data-sort="path_bytes">
|
||
Bytes/hop <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="first_heard">
|
||
First Heard <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable sort-active" data-sort="last_seen">
|
||
Last Heard <i class="fas fa-sort-down sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th class="sortable" data-sort="advert_count">
|
||
Adverts <i class="fas fa-sort sort-icon" aria-hidden="true"></i>
|
||
</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="contacts-table-body">
|
||
<tr>
|
||
<td colspan="12" 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 class="d-flex justify-content-between align-items-center flex-wrap gap-2 mt-3"
|
||
id="contacts-pagination" aria-label="Contacts pagination">
|
||
<small class="text-muted" id="contacts-page-summary">Loading contacts…</small>
|
||
<div class="btn-group btn-group-sm" role="group" aria-label="Contact pages">
|
||
<button type="button" class="btn btn-outline-secondary" id="contacts-page-previous" disabled>
|
||
<i class="fas fa-chevron-left" aria-hidden="true"></i> Previous
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="contacts-page-next" disabled>
|
||
Next <i class="fas fa-chevron-right" aria-hidden="true"></i>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</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 nonce="{{ g.csp_nonce }}">
|
||
class ModernContactsManager {
|
||
constructor() {
|
||
this.contactsData = { tracking_data: [], server_stats: {} };
|
||
this.filteredData = [];
|
||
this.selectedContactIds = new Set();
|
||
this.searchTerm = '';
|
||
this.sortColumn = 'last_seen';
|
||
this.sortDirection = 'desc';
|
||
this.currentPage = 1;
|
||
this.pageSize = 100;
|
||
this.pagination = {
|
||
page: 1, page_size: 100, total_items: 0, total_pages: 1,
|
||
has_previous: false, has_next: false,
|
||
};
|
||
this.filteredStats = {};
|
||
this.requestController = null;
|
||
this.searchTimer = null;
|
||
this.mobileLayout = window.matchMedia('(max-width: 767.98px)');
|
||
this.activityChart = null; // Chart.js instance for activity trends
|
||
this.initializeContacts();
|
||
}
|
||
|
||
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();
|
||
this.setupEventHandlers();
|
||
this.updateFiltersButton();
|
||
const toolbarWrap = document.querySelector('.contacts-toolbar-filters-wrap');
|
||
if (toolbarWrap) this.initContactsDropdowns(toolbarWrap);
|
||
if (this.mobileLayout.addEventListener) {
|
||
this.mobileLayout.addEventListener('change', () => this.renderContactsData());
|
||
}
|
||
await this.loadContactsData();
|
||
}
|
||
|
||
async loadContactsData({ resetPage = false, clearSelection = false } = {}) {
|
||
if (resetPage) this.currentPage = 1;
|
||
if (clearSelection) this.selectedContactIds.clear();
|
||
if (this.requestController) this.requestController.abort();
|
||
const controller = new AbortController();
|
||
this.requestController = controller;
|
||
this.setLoadingState(true);
|
||
try {
|
||
const since = document.getElementById('contacts-timespan').value || '30d';
|
||
const params = new URLSearchParams({
|
||
since,
|
||
page: String(this.currentPage),
|
||
page_size: String(this.pageSize),
|
||
search: this.searchTerm,
|
||
path_bytes: document.getElementById('contacts-path-bytes').value,
|
||
device_role: document.getElementById('contacts-device-role').value,
|
||
hop_filter: document.getElementById('contacts-hop-filter').value,
|
||
location_filter: document.getElementById('contacts-location-filter').value,
|
||
starred: document.getElementById('contacts-starred-filter').value,
|
||
sort: this.sortColumn,
|
||
direction: this.sortDirection,
|
||
});
|
||
const response = await fetch('/api/contacts?' + params.toString(), {
|
||
signal: controller.signal,
|
||
});
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const data = await response.json();
|
||
|
||
if (data.error) {
|
||
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.pagination = data.pagination || {
|
||
page: 1,
|
||
page_size: this.filteredData.length || this.pageSize,
|
||
total_items: this.filteredData.length,
|
||
total_pages: 1,
|
||
has_previous: false,
|
||
has_next: false,
|
||
};
|
||
this.currentPage = this.pagination.page;
|
||
this.filteredStats = data.filtered_stats || {};
|
||
this.updateSortIcons();
|
||
this.renderContactsData();
|
||
this.updatePagination();
|
||
this.updateStatistics();
|
||
|
||
} catch (error) {
|
||
if (error.name === 'AbortError') return;
|
||
console.error('Error loading contacts data:', error);
|
||
this.showError('Failed to load contacts data: ' + error.message);
|
||
} finally {
|
||
if (this.requestController === controller) {
|
||
this.requestController = null;
|
||
this.setLoadingState(false);
|
||
}
|
||
}
|
||
}
|
||
|
||
setLoadingState(loading) {
|
||
const refresh = document.getElementById('refresh-contacts');
|
||
if (refresh) refresh.disabled = loading;
|
||
const summary = document.getElementById('contacts-page-summary');
|
||
if (loading && summary) summary.textContent = 'Loading contacts…';
|
||
const previous = document.getElementById('contacts-page-previous');
|
||
const next = document.getElementById('contacts-page-next');
|
||
if (loading) {
|
||
if (previous) previous.disabled = true;
|
||
if (next) next.disabled = true;
|
||
} else {
|
||
this.updatePagination();
|
||
}
|
||
}
|
||
|
||
setupEventHandlers() {
|
||
document.querySelectorAll('.contacts-export-action').forEach(link => {
|
||
link.addEventListener('click', (event) => {
|
||
event.preventDefault();
|
||
exportData(link.dataset.exportKind, link.dataset.exportFormat);
|
||
});
|
||
});
|
||
document.getElementById('contacts-list-multibyte-badge-info')
|
||
?.addEventListener('click', event => event.stopPropagation());
|
||
document.getElementById('refresh-contacts').addEventListener('click', () => {
|
||
this.loadContactsData();
|
||
});
|
||
|
||
document.getElementById('search-contacts').addEventListener('input', (e) => {
|
||
this.searchTerm = e.target.value.toLowerCase().trim();
|
||
window.clearTimeout(this.searchTimer);
|
||
this.searchTimer = window.setTimeout(() => {
|
||
this.loadContactsData({ resetPage: true, clearSelection: true });
|
||
}, 300);
|
||
});
|
||
|
||
document.getElementById('contacts-timespan').addEventListener('change', () => {
|
||
const since = document.getElementById('contacts-timespan').value;
|
||
localStorage.setItem('contactsTimespan', since);
|
||
this.syncContactsTimespanMobileUI();
|
||
this.loadContactsData({ resetPage: true, clearSelection: true });
|
||
});
|
||
|
||
document.querySelectorAll('.contacts-list-filter').forEach(filter => {
|
||
filter.addEventListener('change', () => {
|
||
this.updateFiltersButton();
|
||
this.loadContactsData({ resetPage: true, clearSelection: true });
|
||
});
|
||
});
|
||
document.getElementById('contacts-clear-filters').addEventListener('click', () => {
|
||
document.querySelectorAll('.contacts-list-filter').forEach(filter => { filter.value = ''; });
|
||
this.updateFiltersButton();
|
||
this.loadContactsData({ resetPage: true, clearSelection: true });
|
||
});
|
||
|
||
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 contact = this.filteredData[Number(t.dataset.contactIndex)];
|
||
if (!contact) return;
|
||
const userId = contact.user_id;
|
||
if (t.checked) this.selectedContactIds.add(userId);
|
||
else this.selectedContactIds.delete(userId);
|
||
this.updateBulkDeleteButton();
|
||
this.updateSelectAllCheckbox();
|
||
}
|
||
});
|
||
listPanel.addEventListener('click', (event) => {
|
||
const action = event.target.closest('[data-contact-action]');
|
||
if (!action) return;
|
||
const contact = this.filteredData[Number(action.dataset.contactIndex)];
|
||
if (!contact) return;
|
||
const kind = action.dataset.contactAction;
|
||
if (kind === 'star') this.toggleStar(contact.user_id, action);
|
||
else if (kind === 'advert') this.viewAdvertData(contact.user_id);
|
||
else if (kind === 'geocode') this.geocodeContact(contact.user_id, action);
|
||
else if (kind === 'delete') this.showDeleteConfirmation(contact.user_id);
|
||
});
|
||
}
|
||
|
||
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.updateSortIcons();
|
||
this.loadContactsData({ resetPage: true, clearSelection: true });
|
||
}
|
||
});
|
||
}
|
||
|
||
// Add click handlers for sortable columns
|
||
document.querySelectorAll('.sortable').forEach(header => {
|
||
header.addEventListener('click', () => {
|
||
const column = header.dataset.sort;
|
||
this.setSort(column);
|
||
this.updateSortIcons();
|
||
this.loadContactsData({ resetPage: true, clearSelection: true });
|
||
});
|
||
});
|
||
|
||
document.getElementById('contacts-page-previous').addEventListener('click', () => {
|
||
if (!this.pagination.has_previous) return;
|
||
this.currentPage -= 1;
|
||
this.loadContactsData();
|
||
});
|
||
document.getElementById('contacts-page-next').addEventListener('click', () => {
|
||
if (!this.pagination.has_next) return;
|
||
this.currentPage += 1;
|
||
this.loadContactsData();
|
||
});
|
||
}
|
||
|
||
setSort(column) {
|
||
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';
|
||
}
|
||
}
|
||
|
||
updateSortIcons() {
|
||
// Only touch dedicated sort chevrons — never the hops path-encoding (i) tip.
|
||
document.querySelectorAll('.sortable .sort-icon').forEach(icon => {
|
||
icon.className = 'fas fa-sort sort-icon';
|
||
});
|
||
|
||
// 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('.sort-icon');
|
||
if (icon) {
|
||
icon.className = this.sortDirection === 'asc'
|
||
? 'fas fa-sort-up sort-icon'
|
||
: 'fas fa-sort-down sort-icon';
|
||
}
|
||
}
|
||
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',
|
||
},
|
||
});
|
||
});
|
||
}
|
||
|
||
renderContactCardHtml(contact, contactIndex) {
|
||
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" data-contact-action="geocode" data-contact-index="${contactIndex}"><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-contact-index="${contactIndex}" ${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">${this.escapeHtml(contact.username || 'Unknown')}</div>
|
||
<small class="text-muted text-break d-block">${this.escapeHtml(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>
|
||
<div class="d-flex align-items-center gap-1"><span class="text-muted small">Bytes/hop:</span>${this.formatPathBytes(contact)}</div>
|
||
${this.formatPathEncodingBadge(contact)}
|
||
</div>
|
||
<div class="d-flex align-items-center gap-1">
|
||
<span class="badge bg-success">${Number(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" data-contact-action="star" data-contact-index="${contactIndex}"><i class="fas fa-star me-2 text-warning"></i>${starLabel}</button></li>
|
||
<li><button type="button" class="dropdown-item" data-contact-action="advert" data-contact-index="${contactIndex}"><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" data-contact-action="delete" data-contact-index="${contactIndex}"><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';
|
||
const row = document.createElement('tr');
|
||
const cell = document.createElement('td');
|
||
cell.colSpan = 12;
|
||
cell.className = 'text-center text-muted';
|
||
cell.textContent = message;
|
||
row.appendChild(cell);
|
||
tbody.replaceChildren(row);
|
||
if (mobileStack) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'text-center text-muted py-4';
|
||
empty.textContent = message;
|
||
mobileStack.replaceChildren(empty);
|
||
}
|
||
this.updateBulkDeleteButton();
|
||
this.updateSelectAllCheckbox();
|
||
return;
|
||
}
|
||
|
||
if (!this.mobileLayout.matches) {
|
||
tbody.innerHTML = this.filteredData.map((contact, contactIndex) => `
|
||
<tr>
|
||
<td class="text-center align-middle">
|
||
<input type="checkbox" class="form-check-input contact-row-checkbox" data-contact-index="${contactIndex}" ${this.selectedContactIds.has(contact.user_id) ? 'checked' : ''}>
|
||
</td>
|
||
<td>
|
||
<strong>${this.escapeHtml(contact.username || 'Unknown')}</strong>
|
||
<br>
|
||
<small class="text-muted">${this.escapeHtml(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.formatPathBytes(contact)}</td>
|
||
<td>${this.formatTimestamp(contact.first_heard)}</td>
|
||
<td>${this.formatTimeAgo(contact.last_seen)}</td>
|
||
<td><span class="badge bg-success">${Number(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'}" data-contact-action="star" data-contact-index="${contactIndex}" title="${contact.is_starred ? 'Unstar contact' : 'Star contact'}">
|
||
<i class="fas fa-star"></i>
|
||
</button>
|
||
<button class="btn btn-sm btn-outline-info" data-contact-action="advert" data-contact-index="${contactIndex}" 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" data-contact-action="geocode" data-contact-index="${contactIndex}" title="Geocode Location">
|
||
<i class="fas fa-map-marker-alt"></i>
|
||
</button>` :
|
||
''
|
||
}
|
||
<button class="btn btn-sm btn-outline-danger" data-contact-action="delete" data-contact-index="${contactIndex}" title="Delete Contact">
|
||
<i class="fas fa-trash"></i>
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
if (mobileStack) mobileStack.replaceChildren();
|
||
} else if (mobileStack) {
|
||
tbody.replaceChildren();
|
||
mobileStack.innerHTML = this.filteredData.map((contact, contactIndex) => this.renderContactCardHtml(contact, contactIndex)).join('');
|
||
}
|
||
|
||
// Setup path tooltips after rendering
|
||
this.setupPathTooltips();
|
||
this.updateBulkDeleteButton();
|
||
this.updateSelectAllCheckbox();
|
||
if (mobileStack && this.mobileLayout.matches) {
|
||
this.initContactsDropdowns(mobileStack);
|
||
}
|
||
}
|
||
|
||
updatePagination() {
|
||
const summary = document.getElementById('contacts-page-summary');
|
||
const previous = document.getElementById('contacts-page-previous');
|
||
const next = document.getElementById('contacts-page-next');
|
||
const total = Number(this.pagination.total_items) || 0;
|
||
const pageSize = Number(this.pagination.page_size) || this.pageSize;
|
||
const page = Number(this.pagination.page) || 1;
|
||
const start = total === 0 ? 0 : ((page - 1) * pageSize) + 1;
|
||
const end = Math.min(total, start + this.filteredData.length - 1);
|
||
if (summary) {
|
||
summary.textContent = total === 0
|
||
? 'No contacts'
|
||
: `${start.toLocaleString()}–${end.toLocaleString()} of ${total.toLocaleString()} contacts`;
|
||
}
|
||
if (previous) previous.disabled = !this.pagination.has_previous;
|
||
if (next) next.disabled = !this.pagination.has_next;
|
||
}
|
||
|
||
updateBulkDeleteButton() {
|
||
const btn = document.getElementById('bulk-delete-contacts');
|
||
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.filteredStats.contacts_24h ?? this.filteredData.filter(contact => {
|
||
const lastSeen = new Date(contact.last_seen);
|
||
return lastSeen >= oneDayAgo;
|
||
}).length;
|
||
|
||
const contacts7d = this.filteredStats.contacts_7d ?? this.filteredData.filter(contact => {
|
||
const lastSeen = new Date(contact.last_seen);
|
||
return lastSeen >= oneWeekAgo;
|
||
}).length;
|
||
|
||
const contactsAll = this.filteredStats.contacts_total ?? this.filteredData.length;
|
||
|
||
// Calculate advertisement metrics using daily tracking data
|
||
// Note: These should now be provided by the server from the daily_advertisements table
|
||
// Fallback to client-side calculation if server data not available
|
||
|
||
// Try to get server-provided daily stats first
|
||
const serverStats = this.contactsData.server_stats || {};
|
||
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);
|
||
|
||
// Calculate new device metrics (devices first seen in the last 7 days)
|
||
// Try different possible device type values
|
||
const newCompanions = this.filteredStats.new_companions ?? this.filteredData.filter(contact => {
|
||
const firstHeard = new Date(contact.first_heard);
|
||
const deviceType = (contact.device_type || '').toLowerCase();
|
||
return firstHeard >= oneWeekAgo && (
|
||
deviceType === 'companion' ||
|
||
deviceType === 'companions' ||
|
||
deviceType.includes('companion')
|
||
);
|
||
}).length;
|
||
|
||
const newRepeaters = this.filteredStats.new_repeaters ?? this.filteredData.filter(contact => {
|
||
const firstHeard = new Date(contact.first_heard);
|
||
const deviceType = (contact.device_type || '').toLowerCase();
|
||
return firstHeard >= oneWeekAgo && (
|
||
deviceType === 'repeater' ||
|
||
deviceType === 'repeaters' ||
|
||
deviceType.includes('repeater')
|
||
);
|
||
}).length;
|
||
|
||
const newRoomServers = this.filteredStats.new_room_servers ?? this.filteredData.filter(contact => {
|
||
const firstHeard = new Date(contact.first_heard);
|
||
const deviceType = (contact.device_type || '').toLowerCase();
|
||
return firstHeard >= oneWeekAgo && (
|
||
deviceType === 'room_server' ||
|
||
deviceType === 'roomserver' ||
|
||
deviceType === 'room_servers' ||
|
||
deviceType.includes('room') ||
|
||
deviceType.includes('server')
|
||
);
|
||
}).length;
|
||
|
||
// 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 `${this.escapeHtml(contact.city)}, ${this.escapeHtml(contact.state)}`;
|
||
} else if (contact.city) {
|
||
return this.escapeHtml(contact.city);
|
||
} else if (contact.latitude && contact.longitude) {
|
||
return `${contact.latitude.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}">${this.escapeHtml(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 = Number(contact.hop_count) || 0;
|
||
const outPath = contact.out_path || '';
|
||
const outPathLen = Number(contact.out_path_len ?? -1);
|
||
// Full paths are loaded on demand in the modal; the list only needs the count.
|
||
const pathsCount = Number(contact.paths_count) || 0;
|
||
const hasMultiplePaths = pathsCount > 1;
|
||
|
||
// Escape the path for use in HTML attributes
|
||
const escapedPath = this.escapeAttribute(outPath);
|
||
const escapedUserId = this.escapeAttribute(contact.user_id);
|
||
|
||
// 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>(${pathsCount} 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}"
|
||
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 '';
|
||
}
|
||
|
||
updateFiltersButton() {
|
||
const trigger = document.getElementById('contacts-filters-trigger');
|
||
if (!trigger) return;
|
||
const active = [...document.querySelectorAll('.contacts-list-filter')]
|
||
.filter(filter => filter.value !== '').length;
|
||
trigger.innerHTML = `<i class="fas fa-filter" aria-hidden="true"></i> Filters${active ? ` (${active})` : ''}`;
|
||
trigger.classList.toggle('btn-primary', active > 0);
|
||
trigger.classList.toggle('btn-outline-secondary', active === 0);
|
||
}
|
||
|
||
formatPathBytes(contact) {
|
||
const bytes = Number(contact.path_bytes_per_hop);
|
||
return [1, 2, 3].includes(bytes)
|
||
? `<span class="badge bg-primary">${bytes}</span>`
|
||
: '<span class="text-muted">—</span>';
|
||
}
|
||
|
||
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';
|
||
badge.addEventListener('click', () => this.showPathsModal(badge.dataset.userId));
|
||
|
||
// Load path data on first hover (for tooltip)
|
||
let pathLoaded = false;
|
||
let pathContent = 'Loading path...';
|
||
|
||
const showTooltip = (event) => {
|
||
const rect = badge.getBoundingClientRect();
|
||
customTooltip.textContent = pathContent;
|
||
customTooltip.style.whiteSpace = 'pre-line';
|
||
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) {
|
||
const name = String(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('\n') : '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) {
|
||
this.renderedModalPaths = paths;
|
||
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}${Number(path.path_length) || 0} bytes, ${Number(path.observation_count) || 0} 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" data-decode-path-index="${i}">
|
||
<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;
|
||
const primaryPath = contact.out_path || '';
|
||
|
||
// Full path objects are loaded on demand (kept out of the /api/contacts list payload
|
||
// for performance) — fetch them for this contact now.
|
||
let detailPaths = [];
|
||
try {
|
||
const resp = await fetch('/api/contact-detail?user_id=' + encodeURIComponent(userId));
|
||
if (resp.ok) {
|
||
const detail = await resp.json();
|
||
detailPaths = detail.all_paths || [];
|
||
} else {
|
||
console.error('Failed to load contact paths:', resp.status);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading contact paths:', err);
|
||
}
|
||
this.currentModalPaths = detailPaths;
|
||
|
||
// 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);
|
||
|
||
const pathsListContainer = document.getElementById('paths-list-container');
|
||
pathsListContainer.addEventListener('click', (event) => {
|
||
const button = event.target.closest('[data-decode-path-index]');
|
||
if (!button) return;
|
||
const index = Number(button.dataset.decodePathIndex);
|
||
const path = this.renderedModalPaths[index];
|
||
if (!path) return;
|
||
const bph = path.bytes_per_hop != null && [1, 2, 3].includes(Number(path.bytes_per_hop))
|
||
? Number(path.bytes_per_hop)
|
||
: null;
|
||
this.decodePathInModal(String(path.path_hex ?? ''), index, bph);
|
||
});
|
||
|
||
// 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');
|
||
}
|
||
|
||
const alert = document.createElement('div');
|
||
alert.className = 'alert alert-info decoded-path-alert mb-0';
|
||
const heading = document.createElement('strong');
|
||
heading.textContent = 'Decoded Path:';
|
||
const content = document.createElement('div');
|
||
content.className = 'mt-2 decoded-path-content';
|
||
data.path.forEach(node => {
|
||
const line = document.createElement('div');
|
||
line.className = node.found ? 'mb-1' : 'mb-1 text-muted';
|
||
const id = document.createElement('strong');
|
||
id.textContent = `${String(node.node_id ?? '')}:`;
|
||
line.append(id, document.createTextNode(` ${node.found ? String(node.name ?? '') : 'Unknown'}`));
|
||
if (node.found && node.geographic_guess) {
|
||
const guess = document.createElement('span');
|
||
guess.className = 'badge bg-warning ms-1';
|
||
guess.textContent = 'Guess';
|
||
line.appendChild(guess);
|
||
}
|
||
content.appendChild(line);
|
||
});
|
||
alert.append(heading, content);
|
||
decodedDiv.replaceChildren(alert);
|
||
|
||
} catch (error) {
|
||
console.error('Error decoding path:', error);
|
||
const alert = document.createElement('div');
|
||
alert.className = 'alert alert-danger mb-0';
|
||
alert.textContent = `Error: ${error.message}`;
|
||
decodedDiv.replaceChildren(alert);
|
||
} 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>';
|
||
}
|
||
}
|
||
|
||
async viewAdvertData(userId) {
|
||
// Find the contact data (for the title); the advert payload is loaded on demand below.
|
||
const contact = this.filteredData.find(c => c.user_id === userId);
|
||
if (!contact) {
|
||
alert('Contact data not found');
|
||
return;
|
||
}
|
||
|
||
// Advertisement data is kept out of the /api/contacts list payload for performance —
|
||
// fetch it for this contact now.
|
||
let advertData = 'No advertisement data available';
|
||
try {
|
||
const resp = await fetch('/api/contact-detail?user_id=' + encodeURIComponent(userId));
|
||
if (resp.ok) {
|
||
const detail = await resp.json();
|
||
advertData = detail.raw_advert_data_parsed || detail.raw_advert_data || advertData;
|
||
} else {
|
||
console.error('Failed to load advertisement data:', resp.status);
|
||
}
|
||
} catch (err) {
|
||
console.error('Error loading advertisement data:', err);
|
||
}
|
||
|
||
// 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 - ${this.escapeHtml(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);">${this.escapeHtml(JSON.stringify(advertData, 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) {
|
||
// Find the contact data
|
||
const contact = this.filteredData.find(c => c.user_id === userId);
|
||
if (!contact) {
|
||
this.showError('Contact data not found');
|
||
return;
|
||
}
|
||
// Derive the display name from the looked-up contact rather than
|
||
// trusting a value interpolated through an HTML attribute by the caller.
|
||
const username = contact.username || 'Unknown';
|
||
|
||
// 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>${this.escapeHtml(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();
|
||
}
|
||
|
||
this.selectedContactIds.delete(userId);
|
||
await this.loadContactsData();
|
||
|
||
// Show success message
|
||
this.showSuccess(`Contact "${this.escapeHtml(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.selectedContactIds.delete(userId));
|
||
await this.loadContactsData();
|
||
|
||
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;
|
||
}
|
||
|
||
escapeAttribute(text) {
|
||
return String(text ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>');
|
||
}
|
||
|
||
showError(message) {
|
||
const errorDiv = document.createElement('div');
|
||
errorDiv.className = 'alert alert-danger alert-dismissible fade show';
|
||
errorDiv.setAttribute('role', 'alert');
|
||
const text = document.createElement('span');
|
||
text.textContent = `Error: ${String(message ?? '')}`;
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'btn-close';
|
||
close.dataset.bsDismiss = 'alert';
|
||
close.setAttribute('aria-label', 'Close');
|
||
errorDiv.append(text, close);
|
||
|
||
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');
|
||
const text = document.createElement('span');
|
||
text.textContent = `Success: ${String(message ?? '')}`;
|
||
const close = document.createElement('button');
|
||
close.type = 'button';
|
||
close.className = 'btn-close';
|
||
close.dataset.bsDismiss = 'alert';
|
||
close.setAttribute('aria-label', 'Close');
|
||
successDiv.append(text, close);
|
||
|
||
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.textContent = `No contacts found that are older than ${days} days.`;
|
||
confirmBtn.disabled = true;
|
||
} else {
|
||
const sampleNames = (data.samples || []).map(s => String(s.name ?? '')).join(', ');
|
||
const more = data.count > 5 ? ` and ${data.count - 5} more` : '';
|
||
previewText.textContent = `${data.count} contact(s) will be deleted: ${sampleNames}${more}.`;
|
||
confirmLabel.textContent = `Purge ${data.count} contact(s)`;
|
||
confirmBtn.disabled = false;
|
||
}
|
||
} catch (err) {
|
||
previewText.textContent = `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 .sort-icon {
|
||
margin-left: 5px;
|
||
opacity: 0.5;
|
||
transition: opacity 0.2s ease;
|
||
font-size: 0.8em;
|
||
}
|
||
|
||
.sortable:hover .sort-icon {
|
||
opacity: 0.8;
|
||
}
|
||
|
||
.sort-active .sort-icon {
|
||
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 %}
|