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:
agessaman
2026-04-03 20:27:00 -07:00
parent 48fd46287e
commit c6a7355b3c
3 changed files with 717 additions and 94 deletions
+263 -2
View File
@@ -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
+89 -4
View File
@@ -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 (23 bytes per hop), ' +
'or (repeaters / room servers) their public key prefix matches a hop on a multibyte path. ' +
'Loaded paths can include more history than the 7-day dashboard chart.';
const el = document.getElementById('contacts-list-multibyte-badge-info');
if (el) {
const existing = bootstrap.Tooltip.getInstance(el);
if (existing) existing.dispose();
new bootstrap.Tooltip(el, {
title: listTip,
html: false,
customClass: 'multibyte-capable-hint',
});
}
}
// Initialize contacts manager when page loads
document.addEventListener('DOMContentLoaded', () => {
window.contactsManager = new ModernContactsManager();
window.contactsManager.setupPurgeModal();
});
</script>
initContactsMultibyteBadgeTooltip();
});
</script>
<style>
/* Sortable column styling - subtle and clean */
+365 -88
View File
@@ -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&amp;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 &amp; 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 &amp; 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 (23 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