mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-28 13:24:09 +00:00
Enhance multibyte path statistics and UI in web viewer
- Added calculations for contacts and incoming packets with multibyte path evidence over the last 7 days in `app.py`, improving data accuracy. - Introduced new methods for handling multibyte path chunks and counting packets from JSON data, enhancing backend functionality. - Updated `contacts.html` and `index.html` templates to display multibyte path encoding badges and tooltips, improving user interface clarity. - Enhanced CSS for path encoding badges to differentiate between multibyte and one-byte paths, ensuring better visual representation. These changes improve the overall user experience and data representation in the Bot Data Viewer.
This commit is contained in:
+263
-2
@@ -4106,6 +4106,53 @@ class BotDataViewer:
|
||||
""")
|
||||
stats['contacts_7d'] = cursor.fetchone()[0]
|
||||
|
||||
# Contacts heard in 7d with multibyte path evidence. Scope observed_paths to 7d so
|
||||
# the pie chart matches "last 7 days" (lifetime paths + stale out_bytes_per_hop
|
||||
# otherwise inflated the percentage).
|
||||
stats['contacts_7d_multibyte_path'] = 0
|
||||
multibyte_chunks: set[str] = set()
|
||||
mb_advert_pks: set[str] = set()
|
||||
if 'observed_paths' in tables:
|
||||
try:
|
||||
multibyte_chunks = self._collect_multibyte_hop_chunks(
|
||||
cursor, recent_days=7
|
||||
)
|
||||
# Use date() — julianday(iso8601) often returns NULL for Python isoformat() strings
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT DISTINCT public_key FROM observed_paths
|
||||
WHERE packet_type = 'advert' AND public_key IS NOT NULL
|
||||
AND bytes_per_hop IN (2, 3)
|
||||
AND date(last_seen) >= date('now', '-7 days')
|
||||
"""
|
||||
)
|
||||
mb_advert_pks = {
|
||||
row["public_key"] for row in cursor.fetchall() if row["public_key"]
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not load multibyte path sets for 7d stats: {e}")
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT public_key, role, out_bytes_per_hop
|
||||
FROM complete_contact_tracking
|
||||
WHERE last_heard > datetime('now', '-7 days')
|
||||
"""
|
||||
)
|
||||
mb_7d = 0
|
||||
for row in cursor.fetchall():
|
||||
if self._contact_has_multibyte_path_evidence(
|
||||
row["public_key"],
|
||||
row["role"],
|
||||
row["out_bytes_per_hop"],
|
||||
mb_advert_pks,
|
||||
multibyte_chunks,
|
||||
):
|
||||
mb_7d += 1
|
||||
stats['contacts_7d_multibyte_path'] = mb_7d
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compute contacts_7d_multibyte_path: {e}")
|
||||
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM complete_contact_tracking
|
||||
WHERE is_currently_tracked = 1
|
||||
@@ -4137,6 +4184,37 @@ class BotDataViewer:
|
||||
""")
|
||||
stats['unique_device_types'] = cursor.fetchone()[0]
|
||||
|
||||
# Incoming packets (packet_stream): multibyte path share, last 7 days (decoded bytes_per_hop)
|
||||
stats['incoming_packets_7d'] = 0
|
||||
stats['incoming_packets_7d_multibyte_path'] = 0
|
||||
if 'packet_stream' in tables:
|
||||
try:
|
||||
cutoff_ts = time.time() - 7 * 86400
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM packet_stream
|
||||
WHERE type = ? AND timestamp > ?
|
||||
""",
|
||||
("packet", cutoff_ts),
|
||||
)
|
||||
stats['incoming_packets_7d'] = cursor.fetchone()[0] or 0
|
||||
mb_pk = 0
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) FROM packet_stream
|
||||
WHERE type = ? AND timestamp > ?
|
||||
AND CAST(json_extract(data, '$.bytes_per_hop') AS INTEGER) IN (2, 3)
|
||||
""",
|
||||
("packet", cutoff_ts),
|
||||
)
|
||||
mb_pk = cursor.fetchone()[0] or 0
|
||||
except sqlite3.OperationalError:
|
||||
mb_pk = self._count_multibyte_packets_from_stream_json(cursor, cutoff_ts)
|
||||
stats['incoming_packets_7d_multibyte_path'] = mb_pk
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not compute incoming_packets_7d multibyte stats: {e}")
|
||||
|
||||
# Advertisement statistics using daily tracking table
|
||||
if 'daily_stats' in tables:
|
||||
# Total advertisements (all time)
|
||||
@@ -4660,6 +4738,181 @@ class BotDataViewer:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _chunks_from_multibyte_path_hex(path_hex: str, bytes_per_hop: int) -> list[str]:
|
||||
"""Split path hex into per-hop segments for 2- or 3-byte hop encoding."""
|
||||
if not path_hex or bytes_per_hop not in (2, 3):
|
||||
return []
|
||||
step = bytes_per_hop * 2
|
||||
out: list[str] = []
|
||||
for i in range(0, len(path_hex), step):
|
||||
seg = path_hex[i : i + step]
|
||||
if len(seg) == step:
|
||||
out.append(seg.lower())
|
||||
return out
|
||||
|
||||
def _count_multibyte_packets_from_stream_json(self, cursor, cutoff_ts: float) -> int:
|
||||
"""Count packet_stream rows (type=packet) since cutoff with bytes_per_hop in (2, 3). JSON parse fallback."""
|
||||
import json
|
||||
|
||||
n = 0
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT data FROM packet_stream
|
||||
WHERE type = ? AND timestamp > ?
|
||||
""",
|
||||
("packet", cutoff_ts),
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
raw = row["data"]
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
bph = d.get("bytes_per_hop")
|
||||
try:
|
||||
bph_i = int(bph) if bph is not None else None
|
||||
except (TypeError, ValueError):
|
||||
bph_i = None
|
||||
if bph_i in (2, 3):
|
||||
n += 1
|
||||
except Exception as e:
|
||||
self.logger.debug(f"packet_stream JSON scan for multibyte: {e}")
|
||||
return n
|
||||
|
||||
def _collect_multibyte_hop_chunks(
|
||||
self, cursor, recent_days: Optional[int] = None
|
||||
) -> set[str]:
|
||||
"""Hop prefixes from multibyte paths in observed_paths (for repeater/room pubkey matching).
|
||||
|
||||
If ``recent_days`` is set (e.g. 7), only paths whose ``last_seen`` falls within that
|
||||
window are used. Default (None) keeps full history — used by the contacts API badge.
|
||||
Dashboard 7d stats pass ``recent_days=7`` so percentages match the chart title.
|
||||
"""
|
||||
chunks: set[str] = set()
|
||||
try:
|
||||
extra = ""
|
||||
if recent_days is not None:
|
||||
d = max(1, min(int(recent_days), 366))
|
||||
extra = f" AND date(last_seen) >= date('now', '-{d} days')"
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT path_hex, bytes_per_hop FROM observed_paths
|
||||
WHERE bytes_per_hop IN (2, 3) AND path_hex IS NOT NULL AND length(path_hex) > 0
|
||||
{extra}
|
||||
"""
|
||||
)
|
||||
for row in cursor.fetchall():
|
||||
ph = row["path_hex"]
|
||||
bph = row["bytes_per_hop"]
|
||||
try:
|
||||
bph_i = int(bph) if bph is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
bph_i = 0
|
||||
for c in self._chunks_from_multibyte_path_hex(ph, bph_i):
|
||||
if len(c) in (4, 6):
|
||||
chunks.add(c)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not load multibyte hop chunks: {e}")
|
||||
return chunks
|
||||
|
||||
def _compute_path_encoding_badge(
|
||||
self,
|
||||
row: Any,
|
||||
all_paths: list[dict[str, Any]],
|
||||
multibyte_hop_chunks: set[str],
|
||||
) -> Optional[str]:
|
||||
"""Return 'multibyte', 'one_byte', or None for contacts path-encoding badge."""
|
||||
pk = row["public_key"] or ""
|
||||
role = (row["role"] or "").lower()
|
||||
obph_raw = row["out_bytes_per_hop"]
|
||||
obph: Optional[int]
|
||||
try:
|
||||
obph = int(obph_raw) if obph_raw is not None else None
|
||||
except (TypeError, ValueError):
|
||||
obph = None
|
||||
if obph is not None and obph not in (1, 2, 3):
|
||||
obph = None
|
||||
|
||||
out_path_len = row["out_path_len"]
|
||||
if out_path_len is None:
|
||||
out_path_len = -1
|
||||
try:
|
||||
out_path_len = int(out_path_len)
|
||||
except (TypeError, ValueError):
|
||||
out_path_len = -1
|
||||
|
||||
advert_count = row["advert_count"] or 0
|
||||
|
||||
def norm_bph(b: Any) -> int:
|
||||
if b is None:
|
||||
return 1
|
||||
try:
|
||||
i = int(b)
|
||||
return i if i in (1, 2, 3) else 1
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
# Multibyte evidence
|
||||
if obph in (2, 3):
|
||||
return "multibyte"
|
||||
for p in all_paths:
|
||||
if norm_bph(p.get("bytes_per_hop")) in (2, 3):
|
||||
return "multibyte"
|
||||
if role in ("repeater", "roomserver") and pk:
|
||||
pk_low = pk.lower()
|
||||
for chunk in multibyte_hop_chunks:
|
||||
if pk_low.startswith(chunk):
|
||||
return "multibyte"
|
||||
|
||||
# One-byte: positive signal and no multibyte observation
|
||||
has_signal = bool(
|
||||
advert_count > 0 or len(all_paths) > 0 or out_path_len >= 0
|
||||
)
|
||||
if not has_signal:
|
||||
return None
|
||||
|
||||
if obph is not None and obph != 1:
|
||||
return None
|
||||
for p in all_paths:
|
||||
if norm_bph(p.get("bytes_per_hop")) != 1:
|
||||
return None
|
||||
|
||||
return "one_byte"
|
||||
|
||||
def _contact_has_multibyte_path_evidence(
|
||||
self,
|
||||
public_key: str,
|
||||
role: Optional[str],
|
||||
out_bytes_per_hop: Any,
|
||||
multibyte_advert_public_keys: set[str],
|
||||
multibyte_hop_chunks: set[str],
|
||||
) -> bool:
|
||||
"""Multibyte detection for dashboard 7d stats (observed_paths scoped by date in SQL)."""
|
||||
pk = public_key or ""
|
||||
role_l = (role or "").lower()
|
||||
obph: Optional[int]
|
||||
try:
|
||||
obph = int(out_bytes_per_hop) if out_bytes_per_hop is not None else None
|
||||
except (TypeError, ValueError):
|
||||
obph = None
|
||||
if obph is not None and obph not in (1, 2, 3):
|
||||
obph = None
|
||||
|
||||
if obph in (2, 3):
|
||||
return True
|
||||
if pk and pk in multibyte_advert_public_keys:
|
||||
return True
|
||||
if role_l in ("repeater", "roomserver") and pk:
|
||||
pk_low = pk.lower()
|
||||
for chunk in multibyte_hop_chunks:
|
||||
if pk_low.startswith(chunk):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _get_tracking_data(self, since='30d'):
|
||||
"""Get contact tracking data. since: 24h, 7d, 30d, 90d, or all (heard in that window)."""
|
||||
conn = None
|
||||
@@ -4724,8 +4977,11 @@ class BotDataViewer:
|
||||
ORDER BY c.last_heard DESC
|
||||
""", params)
|
||||
|
||||
main_rows = cursor.fetchall()
|
||||
multibyte_hop_chunks = self._collect_multibyte_hop_chunks(cursor)
|
||||
|
||||
tracking = []
|
||||
for row in cursor.fetchall():
|
||||
for row in main_rows:
|
||||
# Parse raw advertisement data if available
|
||||
raw_advert_data_parsed = None
|
||||
if row['raw_advert_data']:
|
||||
@@ -4768,6 +5024,10 @@ class BotDataViewer:
|
||||
'last_seen': paths_last_seen[i] if i < len(paths_last_seen) and paths_last_seen[i] else None
|
||||
})
|
||||
|
||||
path_encoding_badge = self._compute_path_encoding_badge(
|
||||
row, all_paths, multibyte_hop_chunks
|
||||
)
|
||||
|
||||
tracking.append({
|
||||
'user_id': row['public_key'],
|
||||
'username': row['name'],
|
||||
@@ -4794,7 +5054,8 @@ class BotDataViewer:
|
||||
'out_path': row['out_path'] if row['out_path'] is not None else '',
|
||||
'out_path_len': row['out_path_len'] if row['out_path_len'] is not None else -1,
|
||||
'out_bytes_per_hop': row['out_bytes_per_hop'] if row['out_bytes_per_hop'] is not None else None,
|
||||
'all_paths': all_paths
|
||||
'all_paths': all_paths,
|
||||
'path_encoding_badge': path_encoding_badge,
|
||||
})
|
||||
|
||||
# Get server statistics for daily tracking using direct database queries
|
||||
|
||||
@@ -177,6 +177,47 @@
|
||||
.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 %}
|
||||
|
||||
@@ -421,7 +462,16 @@
|
||||
SNR <i class="fas fa-sort"></i>
|
||||
</th>
|
||||
<th class="sortable" data-sort="hop_count">
|
||||
Hops <i class="fas fa-sort"></i>
|
||||
<span class="d-inline-flex align-items-center gap-1">
|
||||
Hops <i class="fas fa-sort"></i>
|
||||
<button type="button" class="btn btn-link p-0 border-0 align-baseline text-muted path-encoding-info-btn"
|
||||
id="contacts-list-multibyte-badge-info"
|
||||
aria-label="How Multibyte and 1-byte only badges are determined"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top"
|
||||
onclick="event.stopPropagation();">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>
|
||||
</th>
|
||||
<th class="sortable" data-sort="first_heard">
|
||||
First Heard <i class="fas fa-sort"></i>
|
||||
@@ -840,7 +890,10 @@ class ModernContactsManager {
|
||||
<div class="small text-muted">${this.formatTimestamp(contact.first_heard)} · ${this.formatTimeAgo(contact.last_seen)}</div>
|
||||
<div class="contact-mobile-hops-adverts d-flex align-items-center justify-content-between flex-wrap gap-2 mt-2">
|
||||
<div class="d-flex align-items-center flex-wrap gap-2 min-w-0">
|
||||
<div class="d-flex flex-column align-items-start gap-1 min-w-0">
|
||||
<div class="d-flex align-items-center min-w-0">${this.formatHops(contact)}</div>
|
||||
${this.formatPathEncodingBadge(contact)}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<span class="badge bg-success">${contact.advert_count || 0}</span>
|
||||
<span class="text-muted small">adverts</span>
|
||||
@@ -904,7 +957,7 @@ class ModernContactsManager {
|
||||
<td>${this.formatLocation(contact)}</td>
|
||||
<td>${this.formatDistance(contact)}</td>
|
||||
<td>${this.formatSignal(contact)}</td>
|
||||
<td>${this.formatHops(contact)}</td>
|
||||
<td><div class="d-flex flex-column align-items-start gap-1">${this.formatHops(contact)}${this.formatPathEncodingBadge(contact)}</div></td>
|
||||
<td>${this.formatTimestamp(contact.first_heard)}</td>
|
||||
<td>${this.formatTimeAgo(contact.last_seen)}</td>
|
||||
<td><span class="badge bg-success">${contact.advert_count || 0}</span></td>
|
||||
@@ -1446,6 +1499,18 @@ class ModernContactsManager {
|
||||
return `<span class="badge bg-primary">${hopCount}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
formatPathEncodingBadge(contact) {
|
||||
const v = contact.path_encoding_badge;
|
||||
if (!v) return '';
|
||||
if (v === 'multibyte') {
|
||||
return '<span class="badge path-encoding-badge path-encoding-badge-multibyte">Multibyte</span>';
|
||||
}
|
||||
if (v === 'one_byte') {
|
||||
return '<span class="badge path-encoding-badge path-encoding-badge-onebyte">1-byte only</span>';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
setupPathTooltips() {
|
||||
// Create a global custom tooltip element if it doesn't exist
|
||||
@@ -2463,12 +2528,32 @@ function exportData(dataset, fmt) {
|
||||
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();
|
||||
});
|
||||
</script>
|
||||
initContactsMultibyteBadgeTooltip();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Sortable column styling - subtle and clean */
|
||||
|
||||
@@ -2,6 +2,37 @@
|
||||
|
||||
{% block title %}Bot Statistics - MeshCore Bot Data Viewer{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Equal-height columns: stacks fill the same vertical space as the path-encoding card (lg+) */
|
||||
@media (min-width: 992px) {
|
||||
.dashboard-overview-row > .col-lg-4 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.dashboard-overview-stack {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
/* Multibyte explainer tooltip — allow wrapping for long copy */
|
||||
.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">
|
||||
@@ -60,50 +91,130 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Activity Overview -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-users"></i> Active Contacts
|
||||
<!-- Network overview: 3 columns — (Active + Devices&cache) | (Network + Geo) | Path encoding -->
|
||||
<div class="row mb-4 g-3 align-items-stretch dashboard-overview-row">
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="dashboard-overview-stack">
|
||||
<div class="card flex-shrink-0">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-users"></i> Active Contacts
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<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="total-contacts" class="text-success">0</h4>
|
||||
<small class="text-muted">All</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<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="total-contacts" class="text-success">0</h4>
|
||||
<small class="text-muted">All</small>
|
||||
<div class="card flex-grow-1 d-flex flex-column">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-microchip"></i> Devices & cache
|
||||
</div>
|
||||
<div class="card-body flex-grow-1 d-flex align-items-center justify-content-center">
|
||||
<div class="row text-center w-100">
|
||||
<div class="col-4">
|
||||
<h4 id="unique-device-types" class="text-primary">0</h4>
|
||||
<small class="text-muted">Device types</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="unique-roles" class="text-info">0</h4>
|
||||
<small class="text-muted">Roles</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="active-cache-entries" class="text-success">0</h4>
|
||||
<small class="text-muted">Active cache</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-network-wired"></i> Network Health
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="dashboard-overview-stack">
|
||||
<div class="card flex-shrink-0">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-network-wired"></i> Network Health
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<h4 id="avg-hop-count" class="text-warning">0</h4>
|
||||
<small class="text-muted">Avg Hops</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="max-hop-count" class="text-danger">0</h4>
|
||||
<small class="text-muted">Max Hops</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="tracked-contacts" class="text-info">0</h4>
|
||||
<small class="text-muted">Tracked</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<h4 id="avg-hop-count" class="text-warning">0</h4>
|
||||
<small class="text-muted">Avg Hops</small>
|
||||
<div class="card flex-grow-1 d-flex flex-column">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-globe"></i> Geographic Coverage
|
||||
</div>
|
||||
<div class="card-body flex-grow-1 d-flex align-items-center justify-content-center">
|
||||
<div class="row text-center w-100">
|
||||
<div class="col-4">
|
||||
<h4 id="countries" class="text-primary">0</h4>
|
||||
<small class="text-muted">Countries</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="states" class="text-info">0</h4>
|
||||
<small class="text-muted">States</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="cities" class="text-success">0</h4>
|
||||
<small class="text-muted">Cities</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="max-hop-count" class="text-danger">0</h4>
|
||||
<small class="text-muted">Max Hops</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="tracked-contacts" class="text-info">0</h4>
|
||||
<small class="text-muted">Tracked</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="dashboard-overview-stack">
|
||||
<div class="card h-100 d-flex flex-column flex-grow-1">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-chart-pie"></i> Path encoding (7d)
|
||||
</div>
|
||||
<div class="card-body py-3 flex-grow-1">
|
||||
<div class="row g-3 justify-content-center align-items-start">
|
||||
<div class="col-12 col-md-6 text-center">
|
||||
<p class="small text-muted mb-2 d-flex align-items-center justify-content-center gap-1 flex-wrap">
|
||||
<span>Contacts (last 7 days)</span>
|
||||
<button type="button" class="btn btn-link p-0 border-0 align-baseline text-muted path-encoding-info-btn"
|
||||
id="contacts-multibyte-chart-info"
|
||||
aria-label="How multibyte capable contacts are counted"
|
||||
data-bs-toggle="tooltip" data-bs-placement="top">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
</p>
|
||||
<div class="path-encoding-pie-wrap mx-auto" style="max-width: 200px; width: 100%;">
|
||||
<canvas id="pathEncodingPieChart" aria-label="Multibyte path share among contacts last 7 days"></canvas>
|
||||
</div>
|
||||
<p class="small text-muted mt-2 mb-0" id="path-encoding-pie-summary"></p>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 text-center">
|
||||
<p class="small text-muted mb-2">Incoming packets (last 7 days)</p>
|
||||
<div class="path-encoding-pie-wrap mx-auto" style="max-width: 200px; width: 100%;">
|
||||
<canvas id="incomingPacketsPieChart" aria-label="Multibyte path share among incoming packets last 7 days"></canvas>
|
||||
</div>
|
||||
<p class="small text-muted mt-2 mb-0" id="incoming-packets-pie-summary"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,60 +222,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Device types, roles, and repeater cache (IDs used by dashboard metrics JS) -->
|
||||
<!-- Network Performance -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-microchip"></i> Devices & cache
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-4">
|
||||
<h4 id="unique-device-types" class="text-primary">0</h4>
|
||||
<small class="text-muted">Device types</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h4 id="unique-roles" class="text-info">0</h4>
|
||||
<small class="text-muted">Roles</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h4 id="active-cache-entries" class="text-success">0</h4>
|
||||
<small class="text-muted">Active cache entries</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Performance - Two Columns -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-globe"></i> Geographic Coverage
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<h4 id="countries" class="text-primary">0</h4>
|
||||
<small class="text-muted">Countries</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="states" class="text-info">0</h4>
|
||||
<small class="text-muted">States</small>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<h4 id="cities" class="text-success">0</h4>
|
||||
<small class="text-muted">Cities</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-header">
|
||||
<i class="fas fa-hashtag"></i> Channel Statistics
|
||||
@@ -440,6 +500,8 @@ class ModernDashboard {
|
||||
this.botStartTime = null;
|
||||
this.updateInterval = null;
|
||||
this.uptimeInterval = null;
|
||||
this.pathEncodingPieChart = null;
|
||||
this.incomingPacketsPieChart = null;
|
||||
this.initializeDashboard();
|
||||
}
|
||||
|
||||
@@ -583,8 +645,195 @@ class ModernDashboard {
|
||||
this.updateElement('bot-reply-rate-7d', data.bot_reply_rate_7d !== undefined ? `${data.bot_reply_rate_7d}%` : '0%');
|
||||
this.updateElement('bot-reply-rate-30d', data.bot_reply_rate_30d !== undefined ? `${data.bot_reply_rate_30d}%` : '0%');
|
||||
|
||||
this.updatePathEncodingPieChart(data);
|
||||
this.updateIncomingPacketsPieChart(data);
|
||||
|
||||
// Analytics are loaded separately with time window selectors
|
||||
}
|
||||
|
||||
updatePathEncodingPieChart(data) {
|
||||
const canvas = document.getElementById('pathEncodingPieChart');
|
||||
const summaryEl = document.getElementById('path-encoding-pie-summary');
|
||||
if (!canvas || typeof Chart === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const total = Number(data.contacts_7d) || 0;
|
||||
let mb = data.contacts_7d_multibyte_path;
|
||||
mb = Number(mb);
|
||||
if (!Number.isFinite(mb)) {
|
||||
mb = 0;
|
||||
}
|
||||
mb = Math.max(0, Math.min(mb, total));
|
||||
const other = Math.max(0, total - mb);
|
||||
|
||||
if (this.pathEncodingPieChart) {
|
||||
this.pathEncodingPieChart.destroy();
|
||||
this.pathEncodingPieChart = null;
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const legendColor = (cs.getPropertyValue('--text-muted') || '#6c757d').trim();
|
||||
const mbColor = '#0891b2';
|
||||
const otherColor = '#a8a29e';
|
||||
const emptyColor = (cs.getPropertyValue('--bg-tertiary') || '#dee2e6').trim();
|
||||
|
||||
if (total === 0) {
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = 'No contacts in the last 7 days.';
|
||||
}
|
||||
this.pathEncodingPieChart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['—'],
|
||||
datasets: [{
|
||||
data: [1],
|
||||
backgroundColor: [emptyColor],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||
cutout: '55%',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = Math.round((mb / total) * 1000) / 10;
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = `${pct}% multibyte (${mb} of ${total})`;
|
||||
}
|
||||
|
||||
this.pathEncodingPieChart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Multibyte paths', 'Other'],
|
||||
datasets: [{
|
||||
data: [mb, other],
|
||||
backgroundColor: [mbColor, otherColor],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: legendColor,
|
||||
boxWidth: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const n = ctx.raw;
|
||||
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
|
||||
return ` ${n} (${p}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cutout: '55%',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateIncomingPacketsPieChart(data) {
|
||||
const canvas = document.getElementById('incomingPacketsPieChart');
|
||||
const summaryEl = document.getElementById('incoming-packets-pie-summary');
|
||||
if (!canvas || typeof Chart === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const total = Number(data.incoming_packets_7d) || 0;
|
||||
let mb = data.incoming_packets_7d_multibyte_path;
|
||||
mb = Number(mb);
|
||||
if (!Number.isFinite(mb)) {
|
||||
mb = 0;
|
||||
}
|
||||
mb = Math.max(0, Math.min(mb, total));
|
||||
const other = Math.max(0, total - mb);
|
||||
|
||||
if (this.incomingPacketsPieChart) {
|
||||
this.incomingPacketsPieChart.destroy();
|
||||
this.incomingPacketsPieChart = null;
|
||||
}
|
||||
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const legendColor = (cs.getPropertyValue('--text-muted') || '#6c757d').trim();
|
||||
const mbColor = '#0891b2';
|
||||
const otherColor = '#a8a29e';
|
||||
const emptyColor = (cs.getPropertyValue('--bg-tertiary') || '#dee2e6').trim();
|
||||
|
||||
if (total === 0) {
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = 'No incoming packets in the last 7 days.';
|
||||
}
|
||||
this.incomingPacketsPieChart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['—'],
|
||||
datasets: [{
|
||||
data: [1],
|
||||
backgroundColor: [emptyColor],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
||||
cutout: '55%',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const pct = Math.round((mb / total) * 1000) / 10;
|
||||
if (summaryEl) {
|
||||
summaryEl.textContent = `${pct}% multibyte (${mb} of ${total})`;
|
||||
}
|
||||
|
||||
this.incomingPacketsPieChart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Multibyte paths', 'Other'],
|
||||
datasets: [{
|
||||
data: [mb, other],
|
||||
backgroundColor: [mbColor, otherColor],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: legendColor,
|
||||
boxWidth: 12,
|
||||
font: { size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => {
|
||||
const n = ctx.raw;
|
||||
const p = total ? Math.round((n / total) * 1000) / 10 : 0;
|
||||
return ` ${n} (${p}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cutout: '55%',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
updateElement(elementId, value) {
|
||||
const element = document.getElementById(elementId);
|
||||
@@ -822,6 +1071,14 @@ class ModernDashboard {
|
||||
if (this.uptimeInterval) {
|
||||
clearInterval(this.uptimeInterval);
|
||||
}
|
||||
if (this.pathEncodingPieChart) {
|
||||
this.pathEncodingPieChart.destroy();
|
||||
this.pathEncodingPieChart = null;
|
||||
}
|
||||
if (this.incomingPacketsPieChart) {
|
||||
this.incomingPacketsPieChart.destroy();
|
||||
this.incomingPacketsPieChart = null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadConnectedClients() {
|
||||
@@ -884,10 +1141,30 @@ class ModernDashboard {
|
||||
}
|
||||
}
|
||||
|
||||
function initMultibyteCapableTooltips() {
|
||||
if (typeof bootstrap === 'undefined' || !bootstrap.Tooltip) return;
|
||||
const dashboardTip =
|
||||
'Contacts heard in the last 7 days are counted as multibyte capable if any apply: ' +
|
||||
'stored path encoding (out bytes per hop) is 2 or 3; ' +
|
||||
'we saw an advert from them in that window on observed paths with multibyte hops (2–3 bytes per hop); ' +
|
||||
'or, for repeaters and room servers, their public key prefix matches a hop prefix from a multibyte advert path in that window.';
|
||||
const el = document.getElementById('contacts-multibyte-chart-info');
|
||||
if (el) {
|
||||
const existing = bootstrap.Tooltip.getInstance(el);
|
||||
if (existing) existing.dispose();
|
||||
new bootstrap.Tooltip(el, {
|
||||
title: dashboardTip,
|
||||
html: false,
|
||||
customClass: 'multibyte-capable-hint',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dashboard when page loads
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('DOM loaded, initializing dashboard...');
|
||||
window.dashboard = new ModernDashboard();
|
||||
initMultibyteCapableTooltips();
|
||||
});
|
||||
|
||||
// Clean up when page is unloaded
|
||||
|
||||
Reference in New Issue
Block a user