Files
agessaman bcebeadcab style(web_viewer): polish the dashboard and Real-time after the critique
- Real-time rows put SNR before the hop count. On a phone the end of the line is cut off, and SNR is the reading a quick check wants; it used to show as "SNR …".
- A repeat heard again only lights its "heard ×N" count instead of re-running the whole row's 1.6 s flash, so a busy tape no longer shimmers. In 20 s of live traffic: 26 count bumps, 8 flashes for genuinely new rows. Reduced motion turns both off.
- The health strip says "1 client", not "1 clients".
- American spelling throughout the user-visible text: "One-hop neighbors" and its tooltip and empty message.
- "Multibyte share of packets" in sentence case like the other card titles.
- The hops chart's y-axis reads "% of nodes or packets" instead of chart-library "% of series".
- The route bar's label rounds to one decimal like its legend, so it no longer shows 98% beside 98.4%.
- The note that bot statistics need collect_stats is configuration guidance, not a degraded state, so it drops the warning color.

The full suite passes (5878), and axe-core still finds nothing on any page in either theme.
2026-10-24 14:59:35 -07:00

455 lines
22 KiB
HTML
Raw Permalink 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 %}
{# The health strip leads visually; the page still needs a level-one heading for screen readers. #}
<h1 class="visually-hidden">Dashboard</h1>
<!-- 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> <span id="health-clients-noun">clients</span>
</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>
<!-- First run: replaces the grid until the station has heard anything at all -->
<section class="first-run" id="first-run" aria-labelledby="first-run-title"
data-bot-name="{{ bot_name|default('The bot') }}" hidden>
<h2 class="first-run__title" id="first-run-title">Nothing heard yet</h2>
<p class="first-run__state" id="first-run-state">
{{ bot_name|default('The bot') }} hasn't heard anything on the mesh yet.
</p>
<p class="first-run__lead">This page fills in from what the radio hears, in roughly this order:</p>
<ol class="first-run__steps">
<li><strong>Nodes.</strong> Repeaters and companions announce themselves with adverts from time to time. Each one heard becomes a node here, with how many hops away it was.</li>
<li><strong>Paths.</strong> Relayed packets carry the repeaters they passed through. Those build the routing mix, the hop counts and the Mesh graph.</li>
<li><strong>Neighbors.</strong> Radios heard directly, with no repeater in between, show up as one-hop neighbors with their signal strength.</li>
</ol>
{# Real-time and Radio are admin pages; anonymous visitors only get the wait. #}
{% if not auth_enabled or is_admin %}
<p class="first-run__note">How soon depends on how busy your mesh is. Real-time shows each transmission the moment it arrives; this page catches up within a few minutes.</p>
<div class="first-run__actions">
<a class="btn btn-primary btn-sm" href="/realtime"><i class="fas fa-broadcast-tower me-1"></i>Watch Real-time</a>
<a class="btn btn-outline-secondary btn-sm" href="/radio"><i class="fas fa-sliders-h me-1"></i>Check radio settings</a>
</div>
{% else %}
<p class="first-run__note mb-0">How soon depends on how busy your mesh is; this page catches up within a few minutes.</p>
{% endif %}
</section>
<div id="dashboard-body">
<!-- ══ 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__sub stat-tile__newest" id="new-nodes-newest" hidden></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>
<span id="delta-gap" hidden></span>
</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 card-body--fill">
<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--fill">
<canvas id="hopsHistogramChart"
aria-label="Nodes and flood packets by hop distance"></canvas>
</div>
<p class="dashboard-note mt-2 mb-0" id="hops-hidden-note"></p>
</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 card-body--fill">
<div id="role-mix" class="mix-fill"><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 packets</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">
<p class="dashboard-note mb-2">
Share of each day's packets that took a multibyte path, by payload type
</p>
<div class="chart-box chart-box--tall">
<canvas id="multibyteTrendChart"
aria-label="Daily multibyte packets by payload type, last 30 days"></canvas>
<div class="dashboard-empty" id="multibyte-trend-empty" style="display:none">
No classified packet days yet — the chart 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 neighbors
<button type="button" class="dashboard-info-btn" id="neighbors-info"
aria-label="What counts as a one-hop neighbor"
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">heard directly by this radio</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 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>
</div>{# /dashboard-body #}
<!-- 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 page_libs %}
<script src="{{ url_for('static', filename='vendor/chart.js/chart.umd.js') }}"></script>
{% 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 %}