Files
meshcore-bot/modules/web_viewer/templates/index.html
T
agessaman 3a4d7aab33 feat(web-viewer): add flood-packet distance to the hops chart
Keeps the advert series — nodes by their closest observed path — and
adds arriving flood packets by how far they had already travelled.

The two answer different questions and, on the live mesh, disagree
usefully: nodes peak at 2-3 hops and fall away quickly, while flood
traffic peaks at 5 and holds a long tail past 16. A close-in
neighbourhood absorbing flood from well beyond it.

One series counts nodes (2.8k) and the other packets (156k), so raw
counts on a shared axis would flatten the node series into the baseline.
Both are drawn as a share of their own total, with absolute counts in
the tooltip, and padded onto one contiguous hop range so the bars line
up. They also cover different spans — 7 days of adverts against
whatever packet_stream retains — so each is labelled with its own
window instead of being presented as one period.

Watch the units. observed_paths.path_length is a BYTE count, so hops are
path_length / bytes_per_hop. packet_stream.path_len is already a HOP
count, with the byte length carried separately as path_byte_length. A
17-hop 3-byte path is path_length 51 in one table and path_len 17 in the
other. Applying either rule to the other silently rescales the axis and
the only symptom is a chart that looks a bit off, so both conventions
are now pinned by tests against the shapes real rows take.

Flood packets carry no sender identity and observed_paths holds only
adverts, so the flood series cannot be reduced to a shortest path per
node the way the advert series is. It is a per-packet distribution, and
the tooltip says so.
2026-07-29 22:45:21 -07:00

473 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}Dashboard - MeshCore Bot Data Viewer{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/dashboard.css') }}">
{% endblock %}
{% block content %}
<!-- Health strip — replaces the old four-column health card -->
<div class="health-strip">
<span class="health-strip__item">
<span class="status-indicator status-warning" id="health-status-dot"></span>
<strong id="health-status-text">Connecting…</strong>
</span>
<span class="health-strip__item" id="health-db">DB —</span>
<span class="health-strip__item">
<span class="health-strip__label">Uptime</span>
<span id="health-uptime">—</span>
</span>
<span class="health-strip__item">
<a href="#" id="health-clients-link" class="text-decoration-none"
data-bs-toggle="modal" data-bs-target="#connectedClientsModal">
<span id="health-clients">0</span> clients
</a>
</span>
<span class="health-strip__item" id="health-radio">radio —</span>
<span class="health-strip__spacer"></span>
<span class="health-strip__item">
<span id="health-snapshot">snapshot —</span>
<button type="button" class="btn-refresh" id="snapshot-refresh"
title="Recompute the dashboard snapshot now" aria-label="Refresh snapshot">
<i class="fas fa-sync-alt" aria-hidden="true"></i>
</button>
</span>
</div>
<!-- ══ Mesh ══════════════════════════════════════════════════════════════ -->
<h2 class="dashboard-section-title"><i class="fas fa-project-diagram"></i> Mesh</h2>
<!-- Row 1 — stat tiles (five, so col-xl auto-splits them evenly) -->
<div class="row g-3 mb-1">
<div class="col-6 col-md-4 col-xl">
<div class="stat-tile">
<div class="stat-tile__label">Nodes heard 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="nodes-24h-value">—</span>
<span class="delta-chip" id="nodes-24h-delta">–</span>
</div>
<div class="stat-tile__spark"><canvas id="nodes-24h-spark"
aria-label="Nodes heard per day, last 30 days"></canvas></div>
</div>
</div>
<div class="col-6 col-md-4 col-xl">
<div class="stat-tile">
<div class="stat-tile__label">Adverts 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="adverts-24h-value">—</span>
<span class="delta-chip" id="adverts-24h-delta">–</span>
</div>
<div class="stat-tile__spark"><canvas id="adverts-24h-spark"
aria-label="Adverts per day, last 30 days"></canvas></div>
</div>
</div>
<div class="col-6 col-md-4 col-xl">
<div class="stat-tile">
<div class="stat-tile__label">New nodes 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="new-nodes-value">—</span>
<span class="delta-chip" id="new-nodes-delta">–</span>
</div>
<div class="stat-tile__sub" id="new-nodes-7d">—</div>
<div class="stat-tile__spark"><canvas id="new-nodes-spark"
aria-label="New nodes per day, last 30 days"></canvas></div>
</div>
</div>
<div class="col-6 col-md-4 col-xl">
<div class="stat-tile">
<div class="stat-tile__label">Gone quiet 7d</div>
<div class="stat-tile__row">
<span class="stat-tile__value text-warning" id="gone-quiet">—</span>
</div>
<div class="stat-tile__sub">Heard last week, silent this week</div>
</div>
</div>
<div class="col-6 col-md-4 col-xl">
<div class="stat-tile">
<div class="stat-tile__label">Coverage</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="coverage-countries">—</span>
<span class="stat-tile__sub">countries</span>
</div>
<div class="stat-tile__sub">
<span id="coverage-states">—</span> states ·
<span id="coverage-cities">—</span> cities
</div>
<div class="stat-tile__sub"><span id="coverage-known">—</span> contacts known</div>
</div>
</div>
</div>
<p class="dashboard-note mb-3">
Tile values are rolling 24-hour counts; the
<span class="delta-chip">–</span> chip compares the last two complete calendar days.
<button type="button" class="dashboard-info-btn" id="delta-info"
aria-label="How the change figure is calculated"
data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</p>
<!-- Row 2 — routing health -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header"><i class="fas fa-route"></i> Routing mix</div>
<div class="card-body">
<p class="dashboard-note mb-2">
Incoming packets, <span id="route-mix-window">no data</span>
</p>
<div class="stacked-bar" id="route-mix-bar"></div>
<div class="stacked-bar__legend" id="route-mix-legend"></div>
<hr class="my-3">
<p class="dashboard-note mb-2">Payload types over the same packets</p>
<div id="payload-mix"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-ruler-horizontal"></i> Hops away</span>
<button type="button" class="dashboard-info-btn" id="hops-info"
aria-label="How hop distance is derived"
data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</div>
<div class="card-body">
<p class="dashboard-note mb-2">
Nodes by their closest advert path, and how far arriving
flood packets had travelled
</p>
<div class="chart-box chart-box--tall">
<canvas id="hopsHistogramChart"
aria-label="Nodes and flood packets by hop distance"></canvas>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-3">
<div class="card h-100">
<div class="card-header"><i class="fas fa-microchip"></i> Roles</div>
<div class="card-body">
<div id="role-mix"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
</div>
<!-- Row 3 — path encoding -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header"><i class="fas fa-chart-pie"></i> Path encoding</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-md-6 text-center">
<p class="dashboard-note mb-2">
Contacts (last 7 days)
<button type="button" class="dashboard-info-btn" id="contacts-encoding-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="doughnut-box">
<canvas id="contactsEncodingChart"
aria-label="Multibyte path share among contacts, last 7 days"></canvas>
</div>
<p class="dashboard-note mt-2 mb-0" id="contacts-encoding-summary"></p>
</div>
<div class="col-12 col-md-6 text-center">
<p class="dashboard-note mb-2">
Incoming packets (<span id="packets-window-label">no data</span>)
</p>
<div class="doughnut-box">
<canvas id="packetsEncodingChart"
aria-label="Multibyte path share among incoming packets"></canvas>
</div>
<p class="dashboard-note mt-2 mb-0" id="packets-encoding-summary"></p>
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-chart-line"></i> Multibyte share of adverts</span>
<button type="button" class="dashboard-info-btn" id="multibyte-trend-info"
aria-label="How the multibyte trend is computed"
data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</div>
<div class="card-body">
<div class="chart-box chart-box--tall">
<canvas id="multibyteTrendChart"
aria-label="Multibyte share of daily adverts, last 30 days"></canvas>
<div class="dashboard-empty" id="multibyte-trend-empty" style="display:none">
No classified advert days yet — the trend starts accumulating from today.
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Row 4 — busiest repeaters, role &amp; device mix -->
<div class="row g-3 mb-3">
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-tower-broadcast"></i> Busiest repeaters</span>
<select id="top-repeaters-window" class="form-select form-select-sm"
style="width:auto;font-size:0.75rem" aria-label="Repeater time window">
<option value="7d">Last 7 days</option>
</select>
</div>
<div class="card-body">
<div id="top-repeaters-list"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span>
<i class="fas fa-signal"></i> One-hop neighbours
<button type="button" class="dashboard-info-btn" id="neighbors-info"
aria-label="What counts as a one-hop neighbour"
data-bs-toggle="tooltip" data-bs-placement="top">
<i class="fas fa-info-circle" aria-hidden="true"></i>
</button>
</span>
<select id="neighbors-window" class="form-select form-select-sm"
style="width:auto;font-size:0.75rem" aria-label="Neighbour time window">
<option value="24h">Last 24 hours</option>
</select>
</div>
<div class="card-body">
<div class="d-flex align-items-baseline gap-2 flex-wrap mb-1">
<span class="stat-tile__value" id="neighbors-count">—</span>
<span class="dashboard-note">nodes reached this radio in a single hop</span>
</div>
<p class="dashboard-note mb-3" id="neighbors-shown"></p>
<div id="neighbors-list"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
</div>
<!-- ══ Bot ═══════════════════════════════════════════════════════════════ -->
<h2 class="dashboard-section-title"><i class="fas fa-robot"></i> Bot</h2>
<p class="dashboard-note text-warning mb-2" id="bot-source-note" style="display:none"></p>
<!-- Row 5 — bot tiles -->
<div class="row g-3 mb-3">
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="stat-tile__label">Messages 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="messages-24h-value">—</span>
<span class="delta-chip" id="messages-24h-delta">–</span>
</div>
<div class="stat-tile__spark"><canvas id="messages-24h-spark"
aria-label="Messages per day, last 30 days"></canvas></div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="stat-tile__label">Commands 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="commands-24h-value">—</span>
<span class="delta-chip" id="commands-24h-delta">–</span>
</div>
<div class="stat-tile__spark"><canvas id="commands-24h-spark"
aria-label="Commands per day, last 30 days"></canvas></div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="stat-tile__label">Reply rate 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="reply-rate-24h">—</span>
</div>
<div class="stat-tile__sub">Commands that received a response</div>
</div>
</div>
<div class="col-6 col-lg-3">
<div class="stat-tile">
<div class="stat-tile__label">Unique users 24h</div>
<div class="stat-tile__row">
<span class="stat-tile__value" id="unique-users-24h">—</span>
</div>
<div class="stat-tile__sub" id="unique-channels-24h">—</div>
</div>
</div>
</div>
<!-- Row 6 — leaderboards -->
<div class="row g-3 mb-3">
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-trophy"></i> Top commands</span>
<select id="top-commands-window" class="form-select form-select-sm"
style="width:auto;font-size:0.75rem" aria-label="Command time window">
<option value="all">All retained</option>
</select>
</div>
<div class="card-body">
<div id="top-commands-list"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-users"></i> Top users</span>
<select id="top-users-window" class="form-select form-select-sm"
style="width:auto;font-size:0.75rem" aria-label="User time window">
<option value="all">All retained</option>
</select>
</div>
<div class="card-body">
<div id="top-users-list"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-hashtag"></i> Top channels</span>
<select id="top-channels-window" class="form-select form-select-sm"
style="width:auto;font-size:0.75rem" aria-label="Channel time window">
<option value="all">All retained</option>
</select>
</div>
<div class="card-body">
<div id="top-channels-list"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-xl-3">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fas fa-route"></i> Longest paths</span>
<select id="top-paths-window" class="form-select form-select-sm"
style="width:auto;font-size:0.75rem" aria-label="Path time window">
<option value="all">All retained</option>
</select>
</div>
<div class="card-body">
<div id="top-paths-list"><div class="dashboard-empty">Loading…</div></div>
</div>
</div>
</div>
</div>
<!-- Row 7 — live activity -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="fas fa-satellite-dish me-2"></i>Live Activity
<span id="live-dot" class="status-indicator status-disconnected ms-2"
title="SocketIO connection"></span>
</span>
<div class="d-flex gap-2 align-items-center flex-wrap">
<div class="d-flex gap-2 align-items-center" id="live-filters">
<small class="text-muted">Filter:</small>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox"
id="filter-packet" data-type="packet" checked>
<label class="form-check-label" for="filter-packet"
style="font-size:0.8rem;color:#fd7e14">Packets</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox"
id="filter-command" data-type="command" checked>
<label class="form-check-label" for="filter-command"
style="font-size:0.8rem;color:#198754">Commands</label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input live-filter-cb" type="checkbox"
id="filter-message" data-type="message" checked>
<label class="form-check-label" for="filter-message"
style="font-size:0.8rem;color:#0dcaf0">Messages</label>
</div>
</div>
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-top"
title="Scroll to newest"><i class="fas fa-arrow-up"></i></button>
<button class="btn btn-sm btn-outline-secondary" id="live-scroll-bottom"
title="Scroll to oldest"><i class="fas fa-arrow-down"></i></button>
<span id="live-count" class="badge bg-secondary">0</span>
<button class="btn btn-sm btn-outline-secondary" id="live-pause-btn">
<i class="fas fa-pause" id="live-pause-icon"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" id="live-clear-btn">
<i class="fas fa-trash-alt"></i>
</button>
<a href="/realtime" class="btn btn-sm btn-outline-primary">
Full Monitor <i class="fas fa-external-link-alt ms-1"></i>
</a>
</div>
</div>
<div class="card-body p-0">
<div id="live-feed" class="live-feed">
<div class="text-muted text-center py-3" id="live-placeholder">
<i class="fas fa-hourglass-half"></i> Connecting…
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Connected Clients Modal -->
<div class="modal fade" id="connectedClientsModal" tabindex="-1"
aria-labelledby="connectedClientsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="connectedClientsModalLabel">
<i class="fas fa-users me-2"></i>Connected Clients
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="connected-clients-loading" class="text-center py-3">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span class="ms-2">Loading…</span>
</div>
<div id="connected-clients-empty" class="text-center text-muted py-3" style="display:none">
No clients currently connected.
</div>
<table class="table table-sm mb-0" id="connected-clients-table" style="display:none">
<thead>
<tr>
<th>Client ID</th>
<th>Connected At</th>
<th>Last Activity</th>
</tr>
</thead>
<tbody id="connected-clients-tbody"></tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
{# Data, not code: a JSON block needs no CSP nonce and cannot execute. #}
<script type="application/json" id="dashboard-boot">{{ dashboard_boot|tojson }}</script>
<script src="{{ url_for('static', filename='js/dashboard.js') }}"></script>
{% endblock %}