From 1ba64f890851451b807f9a7276e023d3ed918734 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Fri, 8 May 2026 16:20:56 -0700 Subject: [PATCH] Merge branch 3.0.6 into master (#145) * add world meshview tag * small map change * Add relay_node field to PacketSeen model and update template for display * Add migration script to introduce relay_node column in packet_seen table * Adapt PR 102 relay node display to packet page * Added DB configuration and snapshot for basic detaisl * update schemma * fix ingestion error * fixed the db and added more reporting * 1 * Fix MQTT store bug. * changes to reports * more changes to report * update latest list of instances * Update protobuf * remove file * add channel to firehouse * Add filter to firehose * filter at chat.html * remove duplicates from nodes in net.html * add note bottom of page * fixed issue reported on issue #143 --------- Co-authored-by: Paul Picazo --- README.md | 64 ++--- .../4f1d2a9c8b71_add_daily_snapshot_table.py | 35 +++ .../c6b1d8f2a9e3_compat_placeholder.py | 22 ++ meshtastic/protobuf/UPSTREAM_REV.txt | 2 +- meshview/__version__.py | 4 +- meshview/lang/en.json | 8 +- meshview/lang/es.json | 8 +- meshview/models.py | 14 +- meshview/mqtt_store.py | 125 +++++++-- meshview/templates/base.html | 1 + meshview/templates/chat.html | 66 ++++- meshview/templates/firehose.html | 72 ++++- meshview/templates/map.html | 78 +++++- meshview/templates/net.html | 22 +- meshview/templates/node.html | 80 +++--- meshview/templates/stats.html | 248 ++++++++++++++++-- meshview/web.py | 4 + meshview/web_api/api.py | 59 ++++- sample.config.ini | 9 + startdb.py | 27 ++ 20 files changed, 808 insertions(+), 140 deletions(-) create mode 100644 alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py create mode 100644 alembic/versions/c6b1d8f2a9e3_compat_placeholder.py diff --git a/README.md b/README.md index 42e9dee..b4ae896 100644 --- a/README.md +++ b/README.md @@ -100,27 +100,38 @@ See [README-Docker.md](README-Docker.md) for container deployment and [docs/](do Samples of currently running instances: -- https://meshview.bayme.sh (SF Bay Area - USA) -- https://www.svme.sh (Sacramento Valley - USA) -- https://meshview.nyme.sh (New York - USA) -- https://meshview.socalmesh.org (Los Angenles - USA) -- https://map.wpamesh.net (Western Pennsylvania - USA) -- https://meshview.chicagolandmesh.org (Chicago - USA) -- https://meshview.freq51.net/ (Salt Lake City - USA) -- https://meshview.mt.gt (Canada) -- https://canadaverse.org (Canada) -- https://meshview.meshtastic.es (Spain) -- https://view.mtnme.sh (North Georgia / East Tennessee - USA) -- https://meshview.lsinfra.de (Hessen - Germany) -- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts - USA) -- https://meshview.louisianamesh.org (Louisiana - USA) -- https://www.swlamesh.com (Southwest Louisiana- USA) -- https://meshview.meshcolombia.co (Colombia) -- https://meshview-salzburg.jmt.gr (Salzburg / Austria) -- https://map.cromesh.eu (Coatia) -- https://view.meshdresden.eu (Dresden / Germany) -- https://meshview.meshoregon.com (Oregon - USA) -- https://meshview.gamesh.net (Georgia - USA) +| Area | URL | Version | Nodes | Last Updated | +| --- | --- | --- | ---: | --- | +| Polski Mesh | https://meshview.format.ovh | 3.0.6 (65386be) | 4,827 | 4/1/2026, 12:12:25 PM | +| Poland | https://meshview.szastan.pl | 3.0.5 (unknown) | 4,194 | 4/1/2026, 12:12:39 PM | +| Spain | https://meshview.meshtastic.es | 3.0.5 (unknown) | 2,466 | 4/1/2026, 12:11:17 PM | +| San Francisco Bay Area | https://meshview.bayme.sh | 3.0.5 (bdf70da) | 1,983 | 4/1/2026, 12:11:08 PM | +| Southern California | https://meshview.socalmesh.org | 3.0.5 (unknown) | 1,205 | 4/1/2026, 12:11:50 PM | +| Oregon (USA) | https://meshview.meshoregon.com | 3.0.5 (bdf70da) | 1,167 | 4/1/2026, 12:11:41 PM | +| Australia/ New Zeland | https://my.meshview.world/anz | plus 1.0.0 | 1,165 | 4/1/2026, 12:11:59 PM | +| Hessen - Germany | https://meshview.lsinfra.de | 3.0.5 (unknown) | 1,082 | 4/1/2026, 12:11:30 PM | +| New York City | https://meshview.nyme.sh | 3.0.5 (bdf70da) | 984 | 4/1/2026, 12:11:26 PM | +| Salt Lake City (USA) | https://meshview.freq51.net | — | 801 | 4/1/2026, 12:11:36 PM | +| Salzburg / Austria | https://meshview-salzburg.jmt.gr | — | 748 | 4/1/2026, 12:11:21 PM | +| North Georgia / East Tennessee - USA | https://view.mtnme.sh | 3.0.3 (ff30623) | 730 | 4/1/2026, 12:11:33 PM | +| Canada | https://mv.canadaverse.org | 3.0.1 (unknown) | 674 | 4/1/2026, 12:12:06 PM | +| Pioneer Valley, Massachusetts (USA) | https://meshview.pvmesh.org | 3.0.4 (018e16e) | 560 | 4/1/2026, 12:11:44 PM | +| Western Pennsylvania (USA) | https://map.wpamesh.net | 3.0.5 (unknown) | 447 | 4/1/2026, 12:11:38 PM | +| Great Britan | https://my.meshview.world/gb | plus 1.0.0 | 421 | 4/1/2026, 12:12:03 PM | +| Chicago (USA) | https://meshview.chicagolandmesh.org | 3.0.5 (unknown) | 420 | 4/1/2026, 12:12:41 PM | +| Georgia (USA) | https://meshview.gamesh.net | 3.0.1 (unknown) | 384 | 4/1/2026, 12:11:45 PM | +| Brazil | https://my.meshview.world/brazil | plus 1.0.0 | 364 | 4/1/2026, 12:12:01 PM | +| Mexico | https://my.meshview.world/mexico | plus 1.0.0 | 268 | 4/1/2026, 12:11:59 PM | +| Northeast Ohio | https://meshview.neomesh.org | 3.0.5 (unknown) | 264 | 4/1/2026, 12:11:52 PM | +| South Africa | https://my.meshview.world/za | plus 1.0.0 | 263 | 4/1/2026, 12:12:07 PM | +| Argentina | https://my.meshview.world/argentina | plus 1.0.0 | 258 | 4/1/2026, 12:12:00 PM | +| Northwest Indiana (USA) | https://meshview.nwimesh.net | 3.0.5 (2cc53dc) | 219 | 4/1/2026, 12:12:08 PM | +| Louisiana (USA) | https://meshview.louisianamesh.org | — | 78 | 4/1/2026, 12:11:51 PM | +| Chile | https://my.meshview.world/chile | plus 1.0.0 | 58 | 4/1/2026, 12:12:06 PM | +| Colombia | https://meshview.meshcolombia.co | 3.0.5 (2cc53dc) | 55 | 4/1/2026, 12:11:46 PM | +| Croatia | https://map.cromesh.eu | — | 0 | 4/1/2026, 12:11:45 PM | +| Saint Louis (USA) | https://meshview.meshstl.org | 3.0.5 (2cc53dc) | 0 | 4/1/2026, 12:11:51 PM | +| Southwest Louisiana (USA) | https://www.swlamesh.com | — | 0 | 4/1/2026, 12:11:46 PM | --- @@ -652,17 +663,6 @@ Check the log file to see it the script run at the specific time. --- -## Testing - -MeshView includes a test suite using pytest. For detailed testing documentation, see [README-testing.md](README-testing.md). - -Quick start: -```bash -./env/bin/pytest tests/test_api_simple.py -v -``` - ---- - ## Technical Documentation For more detailed technical documentation including database migrations, architecture details, and advanced topics, see the [docs/](docs/) directory. diff --git a/alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py b/alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py new file mode 100644 index 0000000..7ff18c4 --- /dev/null +++ b/alembic/versions/4f1d2a9c8b71_add_daily_snapshot_table.py @@ -0,0 +1,35 @@ +"""Add daily_snapshot table + +Revision ID: 4f1d2a9c8b71 +Revises: 23dad03d2e42 +Create Date: 2026-03-05 00:00:00.000000 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "4f1d2a9c8b71" +down_revision: str | None = "23dad03d2e42" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + "daily_snapshot", + sa.Column("snapshot_date", sa.Date(), nullable=False), + sa.Column("node_count", sa.BigInteger(), nullable=False), + sa.Column("packet_count", sa.BigInteger(), nullable=False), + sa.Column("gateway_count", sa.BigInteger(), nullable=False), + sa.Column("captured_at_us", sa.BigInteger(), nullable=False), + sa.PrimaryKeyConstraint("snapshot_date"), + ) + + +def downgrade() -> None: + op.drop_table("daily_snapshot") diff --git a/alembic/versions/c6b1d8f2a9e3_compat_placeholder.py b/alembic/versions/c6b1d8f2a9e3_compat_placeholder.py new file mode 100644 index 0000000..772303b --- /dev/null +++ b/alembic/versions/c6b1d8f2a9e3_compat_placeholder.py @@ -0,0 +1,22 @@ +"""Compatibility placeholder for removed revision c6b1d8f2a9e3. + +Revision ID: c6b1d8f2a9e3 +Revises: 4f1d2a9c8b71 +Create Date: 2026-03-05 00:00:00.000000 +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "c6b1d8f2a9e3" +down_revision: str | None = "4f1d2a9c8b71" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/meshtastic/protobuf/UPSTREAM_REV.txt b/meshtastic/protobuf/UPSTREAM_REV.txt index 63b6ee9..3a38980 100644 --- a/meshtastic/protobuf/UPSTREAM_REV.txt +++ b/meshtastic/protobuf/UPSTREAM_REV.txt @@ -1 +1 @@ -e1a6b3a868d735da72cd6c94c574d655129d390a +cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0 diff --git a/meshview/__version__.py b/meshview/__version__.py index de9f5a7..6f7f517 100644 --- a/meshview/__version__.py +++ b/meshview/__version__.py @@ -3,8 +3,8 @@ import subprocess from pathlib import Path -__version__ = "3.0.5" -__release_date__ = "2026-2-6" +__version__ = "3.0.6" +__release_date__ = "2026-3-6" def get_git_revision(): diff --git a/meshview/lang/en.json b/meshview/lang/en.json index 302fe7d..1b2691a 100644 --- a/meshview/lang/en.json +++ b/meshview/lang/en.json @@ -51,7 +51,9 @@ "chat": { "chat_title": "Chats:", "replying_to": "Replying to:", - "view_packet_details": "View packet details" + "view_packet_details": "View packet details", + "channel": "Channel", + "all_channels": "All Channels" }, "nodelist": { "search_placeholder": "Search by name or ID...", @@ -127,6 +129,8 @@ "total_gateways": "Total Gateways", "total_packets": "Total Packets", "total_packets_seen": "Total Packets Seen", + "daily_snapshot_packets_history": "Daily Packet Snapshot History", + "daily_snapshot_nodes_gateways_history": "Daily Node/Gateway Snapshot History", "packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)", "packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)", "packets_per_hour_all": "Packets per Hour - All Ports", @@ -178,6 +182,8 @@ "packet_id": "Packet ID", "from": "From", "to": "To", + "channel": "Channel", + "all_channels": "All Channels", "port": "Port", "links": "Links", "unknown_app": "UNKNOWN APP", diff --git a/meshview/lang/es.json b/meshview/lang/es.json index 573d2a4..e3d6eb3 100644 --- a/meshview/lang/es.json +++ b/meshview/lang/es.json @@ -52,7 +52,9 @@ "chat": { "chat_title": "Conversaciones:", "replying_to": "Respondiendo a:", - "view_packet_details": "Ver detalles del paquete" + "view_packet_details": "Ver detalles del paquete", + "channel": "Canal", + "all_channels": "Todos los canales" }, "nodelist": { @@ -123,6 +125,8 @@ "total_gateways": "Gateways Totales", "total_packets": "Paquetes Totales", "total_packets_seen": "Paquetes Totales Vistos", + "daily_snapshot_packets_history": "Historial Diario de Instantáneas de Paquetes (Todos los Días Disponibles)", + "daily_snapshot_nodes_gateways_history": "Historial Diario de Instantáneas de Nodos/Gateways (Todos los Días Disponibles)", "packets_per_day_all": "Paquetes por Día - Todos los Puertos (Últimos 14 Días)", "packets_per_day_text": "Paquetes por Día - Mensajes de Texto (Puerto 1, Últimos 14 Días)", "packets_per_hour_all": "Paquetes por Hora - Todos los Puertos", @@ -177,6 +181,8 @@ "packet_id": "ID de paquete", "from": "De", "to": "A", + "channel": "Canal", + "all_channels": "Todos los canales", "port": "Puerto", "direct_to_mqtt": "Directo a MQTT", "all_broadcast": "Todos" diff --git a/meshview/models.py b/meshview/models.py index 7302eca..b450336 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -1,4 +1,6 @@ -from sqlalchemy import BigInteger, ForeignKey, Index, desc +from datetime import date + +from sqlalchemy import BigInteger, Date, ForeignKey, Index, desc from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship @@ -119,3 +121,13 @@ class NodePublicKey(Base): Index("idx_node_public_key_node_id", "node_id"), Index("idx_node_public_key_public_key", "public_key"), ) + + +class DailySnapshot(Base): + __tablename__ = "daily_snapshot" + + snapshot_date: Mapped[date] = mapped_column(Date, primary_key=True) + node_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + packet_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + gateway_count: Mapped[int] = mapped_column(BigInteger, nullable=False) + captured_at_us: Mapped[int] = mapped_column(BigInteger, nullable=False) diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index 7ec7ad2..82ddd0a 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -1,8 +1,9 @@ import logging import re import time +from datetime import UTC, datetime -from sqlalchemy import select, update +from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.exc import IntegrityError @@ -11,13 +12,69 @@ from meshtastic.protobuf.config_pb2 import Config from meshtastic.protobuf.mesh_pb2 import HardwareModel from meshtastic.protobuf.portnums_pb2 import PortNum from meshview import decode_payload, mqtt_database -from meshview.models import Node, NodePublicKey, Packet, PacketSeen, Traceroute +from meshview.models import DailySnapshot, Node, NodePublicKey, Packet, PacketSeen, Traceroute logger = logging.getLogger(__name__) MQTT_GATEWAY_CACHE: set[int] = set() +async def capture_daily_snapshot() -> None: + today = datetime.now(UTC).date() + + async with mqtt_database.async_session() as session: + node_count = (await session.execute(select(func.count()).select_from(Node))).scalar_one() + packet_count = ( + await session.execute(select(func.count()).select_from(Packet)) + ).scalar_one() + gateway_count = ( + await session.execute( + select(func.count()).select_from(Node).where(Node.is_mqtt_gateway.is_(True)) + ) + ).scalar_one() + captured_at_us = int(time.time() * 1_000_000) + values = { + "snapshot_date": today, + "node_count": node_count, + "packet_count": packet_count, + "gateway_count": gateway_count, + "captured_at_us": captured_at_us, + } + + dialect = session.get_bind().dialect.name + stmt = None + if dialect == "sqlite": + stmt = ( + sqlite_insert(DailySnapshot) + .values(**values) + .on_conflict_do_update(index_elements=["snapshot_date"], set_=values) + ) + elif dialect == "postgresql": + stmt = ( + pg_insert(DailySnapshot) + .values(**values) + .on_conflict_do_update(index_elements=["snapshot_date"], set_=values) + ) + + if stmt is not None: + await session.execute(stmt) + else: + snapshot = ( + await session.execute( + select(DailySnapshot).where(DailySnapshot.snapshot_date == today) + ) + ).scalar_one_or_none() + if snapshot is None: + session.add(DailySnapshot(**values)) + else: + snapshot.node_count = node_count + snapshot.packet_count = packet_count + snapshot.gateway_count = gateway_count + snapshot.captured_at_us = captured_at_us + + await session.commit() + + async def process_envelope(topic, env): # MAP_REPORT_APP if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP: @@ -124,7 +181,7 @@ async def process_envelope(topic, env): except IntegrityError: pass - # --- PacketSeen (no conflict handling here, normal insert) + # --- PacketSeen insert with conflict-safe handling if not env.gateway_id: print("WARNING: Missing gateway_id, skipping PacketSeen entry") @@ -139,28 +196,43 @@ async def process_envelope(topic, env): update(Node).where(Node.node_id == node_id).values(is_mqtt_gateway=True) ) - result = await session.execute( - select(PacketSeen).where( - PacketSeen.packet_id == env.packet.id, - PacketSeen.node_id == node_id, - PacketSeen.rx_time == env.packet.rx_time, + now_us = int(time.time() * 1_000_000) + seen_values = { + "packet_id": env.packet.id, + "node_id": int(env.gateway_id[1:], 16), + "channel": env.channel_id, + "rx_time": env.packet.rx_time, + "rx_snr": env.packet.rx_snr, + "rx_rssi": env.packet.rx_rssi, + "hop_limit": env.packet.hop_limit, + "hop_start": env.packet.hop_start, + "topic": topic, + "import_time_us": now_us, + } + dialect = session.get_bind().dialect.name + seen_stmt = None + if dialect == "sqlite": + seen_stmt = ( + sqlite_insert(PacketSeen) + .values(**seen_values) + .on_conflict_do_nothing(index_elements=["packet_id", "node_id", "rx_time"]) ) - ) - if not result.scalar_one_or_none(): - now_us = int(time.time() * 1_000_000) - seen = PacketSeen( - packet_id=env.packet.id, - node_id=int(env.gateway_id[1:], 16), - channel=env.channel_id, - rx_time=env.packet.rx_time, - rx_snr=env.packet.rx_snr, - rx_rssi=env.packet.rx_rssi, - hop_limit=env.packet.hop_limit, - hop_start=env.packet.hop_start, - topic=topic, - import_time_us=now_us, + elif dialect == "postgresql": + seen_stmt = ( + pg_insert(PacketSeen) + .values(**seen_values) + .on_conflict_do_nothing(index_elements=["packet_id", "node_id", "rx_time"]) ) - session.add(seen) + + if seen_stmt is not None: + await session.execute(seen_stmt) + else: + try: + async with session.begin_nested(): + session.add(PacketSeen(**seen_values)) + await session.flush() + except IntegrityError: + pass # --- NODEINFO_APP handling if env.packet.decoded.portnum == PortNum.NODEINFO_APP: @@ -172,7 +244,12 @@ async def process_envelope(topic, env): if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]): node_id = int(user.id[1:], 16) else: - node_id = None + logger.warning( + "Skipping NODEINFO_APP packet %s: unable to determine node_id", + env.packet.id, + ) + await session.commit() + return hw_model = ( HardwareModel.Name(user.hw_model) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index 44f3d0d..80447ab 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -176,6 +176,7 @@ async function initializePage() { items.push(`${dict[key] || key}`); } } + items.push('MeshviewWorld'); menu.innerHTML = items.join(" - "); } diff --git a/meshview/templates/chat.html b/meshview/templates/chat.html index bd719fb..d724afe 100644 --- a/meshview/templates/chat.html +++ b/meshview/templates/chat.html @@ -46,6 +46,13 @@ padding-left: 10px; } .replying-to .reply-preview { color: #aaa; } +.chat-controls { + gap: 12px; + margin-bottom: 10px; +} +.chat-filter { + min-width: 220px; +} {% endblock %} {% block body %} @@ -53,10 +60,19 @@
-

- 💬 - -

+
+

+ 💬 + +

+ +
+ + +
+
@@ -68,6 +84,7 @@ document.addEventListener("DOMContentLoaded", async () => { if (!chatContainer) return console.error("#chat-log not found"); let lastTime = null; + let selectedChannel = ""; const renderedPacketIds = new Set(); const packetMap = new Map(); let chatLang = {}; @@ -99,6 +116,26 @@ document.addEventListener("DOMContentLoaded", async () => { } } + async function loadChannels() { + try { + const res = await fetch("/api/channels"); + if (!res.ok) return; + + const data = await res.json(); + const select = document.getElementById("channel-filter"); + const channels = data.channels || []; + + for (const channel of channels) { + const option = document.createElement("option"); + option.value = channel; + option.textContent = channel; + select.appendChild(option); + } + } catch (err) { + console.error("Failed loading channels:", err); + } + } + /* ========================================================== SAFE HTML ========================================================== */ @@ -191,6 +228,18 @@ document.addEventListener("DOMContentLoaded", async () => { if (highlight) setTimeout(() => div.classList.remove("flash"), 2500); } + function matchesSelectedChannel(packet) { + if (!selectedChannel) return true; + return (packet.channel || "").toLowerCase() === selectedChannel.toLowerCase(); + } + + function resetChat() { + lastTime = null; + renderedPacketIds.clear(); + packetMap.clear(); + chatContainer.innerHTML = ""; + } + function renderPacketsEnsureDescending(packets, highlight=false) { if (!Array.isArray(packets) || packets.length===0) return; const sortedDesc = packets.slice().sort((a,b)=>{ @@ -199,6 +248,7 @@ document.addEventListener("DOMContentLoaded", async () => { return bTime - aTime; }); for (let i=sortedDesc.length-1; i>=0; i--) { + if (!matchesSelectedChannel(sortedDesc[i])) continue; renderPacket(sortedDesc[i], highlight); } } @@ -235,6 +285,14 @@ document.addEventListener("DOMContentLoaded", async () => { INIT ========================================================== */ await loadChatLang(); // load translations FIRST + await loadChannels(); + + document.getElementById("channel-filter").addEventListener("change", async (e) => { + selectedChannel = e.target.value; + resetChat(); + await fetchInitial(); + }); + await fetchInitial(); // then fetch initial packets setInterval(fetchUpdates, 5000); diff --git a/meshview/templates/firehose.html b/meshview/templates/firehose.html index 790b4d6..bc5266a 100644 --- a/meshview/templates/firehose.html +++ b/meshview/templates/firehose.html @@ -11,6 +11,12 @@ font-size: 0.9rem; border-radius: 6px; } +.firehose-controls { + gap: 12px; +} +.firehose-filter { + min-width: 220px; +} .port-tag { display: inline-block; @@ -89,15 +95,22 @@ {% block body %}
-
+

📡 Live Feed

- +
+ + + + +
@@ -107,6 +120,7 @@ + @@ -240,6 +254,7 @@ function logPacketTimes(packet) { let lastImportTimeUs = null; let updatesPaused = false; let updateInterval = 3000; +let selectedChannel = ""; async function configureFirehose() { try { @@ -249,6 +264,36 @@ async function configureFirehose() { } catch {} } +async function loadChannels() { + try { + const res = await fetch("/api/channels"); + if (!res.ok) return; + + const data = await res.json(); + const select = document.getElementById("channel-filter"); + const channels = data.channels || []; + + for (const channel of channels) { + const option = document.createElement("option"); + option.value = channel; + option.textContent = channel; + select.appendChild(option); + } + } catch (err) { + console.error("Failed loading channels:", err); + } +} + +function resetFirehose() { + lastImportTimeUs = null; + document.getElementById("packet_list").innerHTML = ""; +} + +function matchesSelectedChannel(packet) { + if (!selectedChannel) return true; + return (packet.channel || "").toLowerCase() === selectedChannel.toLowerCase(); +} + async function fetchUpdates() { if (updatesPaused) return; @@ -269,6 +314,8 @@ async function fetchUpdates() { const list = document.getElementById("packet_list"); for (const pkt of packets.reverse()) { + if (!matchesSelectedChannel(pkt)) continue; + logPacketTimes(pkt); /* FROM — includes translation */ @@ -343,12 +390,13 @@ async function fetchUpdates() { + - + `; @@ -371,6 +419,7 @@ async function fetchUpdates() { document.addEventListener("DOMContentLoaded", async () => { const pauseBtn = document.getElementById("pause-button"); + const channelFilter = document.getElementById("channel-filter"); pauseBtn.addEventListener("click", () => { updatesPaused = !updatesPaused; @@ -381,6 +430,12 @@ document.addEventListener("DOMContentLoaded", async () => { : (firehoseTranslations.pause || "Pause"); }); + channelFilter.addEventListener("change", async (e) => { + selectedChannel = e.target.value; + resetFirehose(); + await fetchUpdates(); + }); + document.addEventListener("click", e => { const btn = e.target.closest(".toggle-btn"); if (!btn) return; @@ -395,6 +450,7 @@ document.addEventListener("DOMContentLoaded", async () => { await loadTranslationsFirehose(); await configureFirehose(); await loadNodes(); + await loadChannels(); fetchUpdates(); setInterval(fetchUpdates, updateInterval); diff --git a/meshview/templates/map.html b/meshview/templates/map.html index 47cd56d..bdc1df8 100644 --- a/meshview/templates/map.html +++ b/meshview/templates/map.html @@ -23,7 +23,25 @@ #reset-filters-button:hover { background-color:#da190b; } #reset-filters-button:active { background-color:#c41e0d; } - .blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; } + .blinking-tooltip { + background: white; + color: black; + border: 1px solid #111; + border-radius: 6px; + padding: 6px 10px; + font-size: 14px; + font-weight: 400; + box-shadow: 0 2px 8px rgba(0,0,0,0.25); + } + .blinking-tooltip.text-packet { + animation: textPulse 1.1s ease-in-out 6; + border-color: #ff8c00; + } + @keyframes textPulse { + 0% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); } + 50% { box-shadow: 0 4px 14px rgba(255,140,0,0.45); } + 100% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); } + } #map-wrapper { position: relative; @@ -312,7 +330,7 @@ function fetchNewPackets(){ const marker = markerById[pkt.from_node_id]; const nodeData = nodeMap.get(pkt.from_node_id); if(marker && nodeData) { - blinkNode(marker,nodeData.long_name,pkt.portnum); + blinkNode(marker, nodeData.long_name, pkt.portnum, pkt.payload); } else { addUnmappedPacket(pkt, nodeData); } @@ -451,7 +469,29 @@ function renderNodesOnMap(){ marker.originalColor = color; markerById[node.key] = marker; - marker.bindPopup(buildNodePopup(node)); + const popup = ` + ${node.long_name} (${node.short_name})
+ + ${node.channel}
+ ${node.hw_model}
+ ${node.role}
+ ${ + node.is_mqtt_gateway ? (mapTranslations.yes || "Yes") : (mapTranslations.no || "No") + }
+ + ${ + node.last_seen_us + ? ` ${timeAgoFromUs(node.last_seen_us)}
` + : "" + } + + ${ + node.firmware + ? ` ${node.firmware}
` + : "" + } + `; + marker.on('click', () => { onNodeClick(node); marker.setPopupContent(buildNodePopup(node)); @@ -585,7 +625,20 @@ map.on('click', e=>{ BLINKING ====================================================== */ -function blinkNode(marker,longName,portnum){ +function escapeHtml(value){ + return String(value) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function isTextPort(portnum){ + return portnum === 1 || portnum === 7; +} + +function blinkNode(marker,longName,portnum,payload){ if(!map.hasLayer(marker)) return; if(activeBlinks.has(marker)){ @@ -595,13 +648,24 @@ function blinkNode(marker,longName,portnum){ } let blinkCount = 0; + const blinkStart = Date.now(); + const blinkDurationMs = 7000; + const safeName = escapeHtml(longName); + const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`; + const payloadText = (payload || "").trim(); + const showPayload = isTextPort(portnum) && payloadText && payloadText !== "Did not decode"; + const shortPayload = showPayload && payloadText.length > 80 + ? `${payloadText.slice(0, 77)}...` + : payloadText; + const payloadLine = showPayload ? `
${escapeHtml(shortPayload)}` : ""; + const tooltip = L.tooltip({ permanent:true, direction:'top', offset:[0,-marker.options.radius-5], - className:'blinking-tooltip' + className: isTextPort(portnum) ? 'blinking-tooltip text-packet' : 'blinking-tooltip' }) - .setContent(`${longName} (${portMap[portnum] || "Port "+portnum})`) + .setContent(`${safeName} (${escapeHtml(portLabel)})${payloadLine}`) .setLatLng(marker.getLatLng()) .addTo(map); @@ -616,7 +680,7 @@ function blinkNode(marker,longName,portnum){ } blinkCount++; - if(blinkCount>7){ + if(Date.now() - blinkStart > blinkDurationMs){ clearInterval(interval); marker.setStyle({ fillColor: marker.originalColor }); map.removeLayer(tooltip); diff --git a/meshview/templates/net.html b/meshview/templates/net.html index d3c21be..669bb97 100644 --- a/meshview/templates/net.html +++ b/meshview/templates/net.html @@ -25,6 +25,7 @@ #weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; } #total-count { margin-bottom: 10px; font-style: italic; color: #ccc; } +#net-note { margin: 16px 0 0; font-size: 0.9rem; color: #aaa; } {% endblock %} {% block body %} @@ -50,6 +51,10 @@
+ +
+ Only the earliest report from each device is listed. +
Packet ID From ToChannel Port
${from} ${to}${pkt.channel || "—"} ${portLabel(pkt.portnum, pkt.payload, inlineLinks)}
${safePayload}${safePayload}