6 Commits

Author SHA1 Message Date
pablorevilla-meshtastic 0d52dfeac1 fixed pop up 2026-05-12 15:58:13 -07:00
pablorevilla-meshtastic 31744aace9 Merge branch 'master' of github.com:pablorevilla-meshtastic/meshview 2026-05-12 15:51:13 -07:00
pablorevilla-meshtastic a3ce0640ab update 2026-05-12 15:51:10 -07:00
Pablo Revilla 1ba64f8908 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 <paul@picazo.com>
2026-05-08 16:20:56 -07:00
Dmitriy Q e4384f020c add russian translation (#141) 2026-04-01 10:16:24 -07:00
pablorevilla-meshtastic bdf70da803 fix location of node in node.html 2026-02-24 12:00:08 -08:00
22 changed files with 1075 additions and 156 deletions
View File
+32 -32
View File
@@ -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.
@@ -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")
@@ -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
+1 -1
View File
@@ -1 +1 @@
e1a6b3a868d735da72cd6c94c574d655129d390a
cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0
+2 -2
View File
@@ -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():
+7 -1
View File
@@ -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",
+7 -1
View File
@@ -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"
+252
View File
@@ -0,0 +1,252 @@
{
"base": {
"chat": "Чат",
"nodes": "Ноды",
"everything": "Показать все",
"graphs": "Mesh графики",
"net": "Недельная сеть",
"map": "Живая карта",
"stats": "Статистика",
"top": "Крупнейшие ноды трафика",
"footer": "Посетите <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> на GitHub",
"node id": "ID ноды",
"go to node": "Перейти к ноде",
"all": "Все",
"portnum_options": {
"0": "Неизвестно",
"1": "Текстовое сообщение",
"2": "Удалённое оборудование",
"3": "Позиция",
"4": "Информация о ноде",
"5": "Маршрутизация",
"6": "Администрирование",
"7": "Текст (сжатый)",
"8": "Контрольная точка",
"9": "Аудио",
"10": "Датчик обнаружения",
"11": "Тревога",
"12": "Проверка ключа",
"32": "Ответить",
"33": "IP туннель",
"34": "Счетчик прохожих",
"35": "Store Forward++",
"36": "Состояние ноды",
"64": "Последовательный",
"65": "Store & Forward",
"66": "Проверка дальности",
"67": "Телеметрия",
"68": "ZPS",
"69": "Симулятор",
"70": "Трассировка",
"71": "Neighbor Info",
"72": "ATAK",
"73": "Отчёт по карте",
"74": "Power Stress",
"76": "Reticulum туннель",
"77": "Cayenne",
"256": "Частное приложение",
"257": "Пересылка ATAK"
}
},
"chat": {
"chat_title": "Чаты:",
"replying_to": "В ответ на:",
"view_packet_details": "Просмотреть детали пакета"
},
"nodelist": {
"search_placeholder": "Поиск по имени или ID...",
"all_roles": "Все роли",
"all_channels": "Все каналы",
"all_hw": "Все модели устройств",
"all_firmware": "Все прошивки",
"show_favorites": "⭐ Показать избранные",
"show_all": "⭐ Показать все",
"export_csv": "Экспорт CSV",
"clear_filters": "Очистить фильтры",
"showing_nodes": "Показать ноды",
"nodes_suffix": "ноды",
"loading_nodes": "Загрузка нод...",
"error_loading_nodes": "Ошибка загрузки нод",
"no_nodes_found": "Ноды не найдены",
"short_name": "Короткое имя",
"long_name": "Длинное имя",
"hw_model": "Модель оборудования",
"firmware": "Прошивка",
"role": "Роль",
"last_lat": "Последняя широта",
"last_long": "Последняя долгота",
"channel": "Канал",
"mqtt_gateway": "MQTT",
"last_seen": "Был в сети",
"favorite": "Избранные",
"yes": "Да",
"no": "Нет",
"time_just_now": "только что",
"time_min_ago": "минут назад",
"time_hr_ago": "часов назад",
"time_day_ago": "день назад",
"time_days_ago": "дней назад"
},
"net": {
"net_title": "Еженедельная сеть:",
"total_messages": "Количество сообщений:",
"view_packet_details": "Больше деталей"
},
"map": {
"show_routers_only": "Показать только маршрутизаторы",
"show_mqtt_only": "Показать только шлюзы MQTT",
"share_view": "Поделиться этим видом",
"reset_filters": "Сбросить фильтры",
"unmapped_packets_title": "Несопоставленные пакеты",
"unmapped_packets_empty": "Нет последних несопоставленных пакетов.",
"channel_label": "Канал:",
"model_label": "Модель:",
"role_label": "Роль:",
"mqtt_gateway": "Шлюз MQTT:",
"last_seen": "Последний раз слышен:",
"firmware": "Прошивка:",
"yes": "Да",
"no": "Нет",
"link_copied": "Ссылка скопирована!",
"legend_traceroute": "Трассировка (со стрелками)",
"legend_neighbor": "Сосед"
},
"stats": {
"mesh_stats_summary": "Сводка статистики сети (все доступные в базе данных)",
"total_nodes": "Всего нод",
"total_gateways": "Всего шлюзов",
"total_packets": "Всего пакетов",
"total_packets_seen": "Всего пакетов обнаружено",
"packets_per_day_all": "Пакетов в день - все порты (за последние 14 дней)",
"packets_per_day_text": "Пакетов в день - текстовые сообщения (порт 1 за последние 14 дней)",
"packets_per_hour_all": "Пакетов в час - все порты",
"packets_per_hour_text": "Пакетов в час - текстовые сообщения (порт 1)",
"packet_types_last_24h": "Типы пакетов - последние 24 часа",
"hardware_breakdown": "Распределение устройств",
"role_breakdown": "Распределение ролей",
"channel_breakdown": "Распределение каналов",
"gateway_channel_breakdown": "Распределение шлюзов канала",
"gateway_role_breakdown": "Распределение ролей шлюза",
"gateway_firmware_breakdown": "Распределение прошивки шлюза",
"no_gateways": "Шлюзов не найдено",
"expand_chart": "Развернуть диаграмму",
"export_csv": "Экспорт CSV",
"all_channels": "Все каналы",
"node_id": "ID ноды"
},
"top": {
"top_traffic_nodes": " Трафик нод",
"channel": "Канал",
"search": "Поиск",
"search_placeholder": "Поиск нод...",
"long_name": "Длинное имя",
"short_name": "Короткое имя",
"packets_sent": "Отправлено (24ч)",
"times_seen": "Принято (24ч)",
"avg_gateways": "Среднее количество шлюзов",
"showing_nodes": "Показать",
"nodes_suffix": "ноды"
},
"nodegraph": {
"channel_label": "Канал:",
"search_node_placeholder": "Поиск ноды...",
"search_button": "Поиск",
"long_name_label": "Полное имя:",
"short_name_label": "Короткое имя:",
"role_label": "Роль:",
"hw_model_label": "Модель оборудования:",
"node_not_found": "Узел не найден в текущем канале!"
},
"firehose": {
"live_feed": "📡 Прямой эфир",
"pause": "Пауза",
"resume": "Продолжить",
"time": "Время",
"packet_id": "ID пакета",
"from": "От",
"to": "К",
"port": "Порт",
"links": "Ссылки",
"unknown_app": "НЕИЗВ-НОЕ ПРИЛОЖ",
"text_message": "Текстовое сообщение",
"position": "Позиция",
"node_info": "Информация об узле",
"routing": "Маршрутизация",
"administration": "Администрирование",
"waypoint": "Контрольная точка",
"store_forward": "Store Forward",
"telemetry": "Телеметрия",
"trace_route": "Трассировка маршрута",
"neighbor_info": "Neighbor Info",
"direct_to_mqtt": "напрямую к MQTT",
"all": "Все",
"map": "Карта",
"graph": "Диаграмма"
},
"node": {
"specifications": "Технические характеристики",
"node_id": "ID ноды",
"long_name": "Длинное имя",
"short_name": "Короткое имя",
"hw_model": "Модель оборудования",
"firmware": "Прошивка",
"role": "Роль",
"mqtt_gateway": "Шлюз MQTT",
"channel": "Канал",
"latitude": "Широта",
"longitude": "Долгота",
"first_update": "Первое обновление",
"last_update": "Последнее обновление",
"battery_voltage": "Батарея и напряжение",
"air_channel": "Использование сети и каналов",
"environment": "Метрики среды",
"neighbors_chart": "Соседи (Соотношение сигнал/шум)",
"expand": "Расширить",
"export_csv": "Экспорт в CSV",
"time": "Время",
"packet_id": "ID пакета",
"from": "От",
"to": "К",
"port": "Порт",
"direct_to_mqtt": "Напрямую к MQTT",
"all_broadcast": "Все",
"statistics": "Статистика",
"last_24h": "24ч",
"packets_sent": "Отправленные пакеты",
"times_seen": "Просмотрено раз",
"yes": "Да",
"no": "Нет",
"copy_import_url": "Копировать URL для импорта",
"show_qr_code": "Показать QR-код",
"toggle_coverage": "Прогнозируемое покрытие",
"location_required": "Требуется местоположение для покрытия",
"coverage_help": "Помощь по покрытию",
"share_contact_qr": "Поделиться QR контакта",
"copy_url": "Копировать URL",
"copied": "Скопировано!",
"potential_impersonation": "Обнаружено возможное самозванство",
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
},
"packet": {
"loading": "Загрузка информации о пакете...",
"packet_id_label": "ID пакета",
"from_node": "От ноды",
"to_node": "К ноде",
"channel": "Канал",
"port": "Порт",
"raw_payload": "Незашифрованная нагрузка",
"decoded_telemetry": "Раскодированная телеметрия",
"location": "Местоположение",
"seen_by": "Просмотрено",
"gateway": "Шлюз",
"rssi": "RSSI",
"snr": "SNR",
"hops": "Хоп",
"time": "Время",
"packet_source": "Источник пакета",
"distance": "Расстояние",
"node_id_short": "ID ноды",
"all_broadcast": "Все",
"direct_to_mqtt": "Напрямую к MQTT"
}
}
+13 -1
View File
@@ -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)
+101 -24
View File
@@ -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)
+1
View File
@@ -176,6 +176,7 @@ async function initializePage() {
items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`);
}
}
items.push('<a href="https://meshview.world" target="_blank" rel="noopener noreferrer">MeshviewWorld</a>');
menu.innerHTML = items.join("&nbsp;-&nbsp;");
}
+62 -4
View File
@@ -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 @@
<!-- ⭐ CHAT TITLE WITH ICON, aligned to container ⭐ -->
<div class="container px-2">
<h2 style="color:white; margin:0 0 10px 0;">
<span class="icon">💬</span>
<span data-translate="chat_title"></span>
</h2>
<div class="d-flex align-items-center justify-content-between chat-controls">
<h2 style="color:white; margin:0;">
<span class="icon">💬</span>
<span data-translate="chat_title"></span>
</h2>
<div class="d-flex align-items-center gap-2">
<label for="channel-filter" class="mb-0" data-translate="channel">Channel</label>
<select id="channel-filter" class="form-select form-select-sm chat-filter">
<option value="" data-translate="all_channels">All Channels</option>
</select>
</div>
</div>
</div>
<div class="container" id="chat-log"></div>
@@ -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);
+64 -8
View File
@@ -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 %}
<div class="container">
<form class="d-flex align-items-center justify-content-between mb-3">
<form class="d-flex align-items-center justify-content-between mb-3 firehose-controls">
<h2 class="mb-0" data-translate-lang="live_feed">📡 Live Feed</h2>
<button type="button"
id="pause-button"
class="btn btn-sm btn-outline-secondary"
data-translate-lang="pause">
Pause
</button>
<div class="d-flex align-items-center gap-2">
<label for="channel-filter" class="mb-0" data-translate-lang="channel">Channel</label>
<select id="channel-filter" class="form-select form-select-sm firehose-filter">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
<button type="button"
id="pause-button"
class="btn btn-sm btn-outline-secondary"
data-translate-lang="pause">
Pause
</button>
</div>
</form>
<table class="packet-table">
@@ -107,6 +120,7 @@
<th data-translate-lang="packet_id">Packet ID</th>
<th data-translate-lang="from">From</th>
<th data-translate-lang="to">To</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="port">Port</th>
</tr>
</thead>
@@ -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() {
<td>${from}</td>
<td>${to}</td>
<td>${pkt.channel || "—"}</td>
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
</tr>
<tr class="payload-row">
<td colspan="5" class="payload-cell">${safePayload}</td>
<td colspan="6" class="payload-cell">${safePayload}</td>
</tr>
`;
@@ -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);
+68 -14
View File
@@ -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;
@@ -217,6 +235,7 @@ function labelFor(key, fallback){
function buildNodePopup(node){
const labels = {
channel: labelFor("channel_label", "Channel"),
nodeId: labelFor("node_id", "Node ID"),
model: labelFor("model_label", "Model"),
role: labelFor("role_label", "Role"),
mqtt: labelFor("mqtt_gateway", "MQTT Gateway"),
@@ -225,13 +244,16 @@ function buildNodePopup(node){
yes: mapTranslations.yes || "Yes",
no: mapTranslations.no || "No"
};
const displayName = node.long_name || node.short_name || `Node ${node.node_id}`;
const shortName = node.short_name ? ` (${escapeHtml(node.short_name)})` : "";
return `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b><a href="/node/${node.node_id}">${escapeHtml(displayName)}</a>${shortName}</b><br>
<b>${labels.channel}</b> ${node.channel}<br>
<b>${labels.model}</b> ${node.hw_model}<br>
<b>${labels.role}</b> ${node.role}<br>
<b>${labels.nodeId}</b> ${node.node_id}<br>
<b>${labels.channel}</b> ${escapeHtml(node.channel || "N/A")}<br>
<b>${labels.model}</b> ${escapeHtml(node.hw_model || "N/A")}<br>
<b>${labels.role}</b> ${escapeHtml(node.role || "N/A")}<br>
<b>${labels.mqtt}</b> ${node.is_mqtt_gateway ? labels.yes : labels.no}<br>
${
@@ -242,7 +264,7 @@ function buildNodePopup(node){
${
node.firmware
? `<b>${labels.firmware}</b> ${node.firmware}<br>`
? `<b>${labels.firmware}</b> ${escapeHtml(node.firmware)}<br>`
: ""
}
`;
@@ -312,7 +334,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);
}
@@ -450,12 +472,18 @@ function renderNodesOnMap(){
marker.nodeId = node.key;
marker.originalColor = color;
markerById[node.key] = marker;
marker.bindPopup(buildNodePopup(node), {
autoPan: true,
closeButton: true
});
marker.bindPopup(buildNodePopup(node));
marker.on('click', () => {
onNodeClick(node);
marker.on('click', e => {
if(e.originalEvent){
L.DomEvent.stopPropagation(e.originalEvent);
}
marker.setPopupContent(buildNodePopup(node));
marker.openPopup();
onNodeClick(node);
});
});
@@ -575,6 +603,8 @@ async function onNodeClick(node){
}
map.on('click', e=>{
if(e.originalEvent.defaultPrevented) return;
if(!e.originalEvent.target.classList.contains('leaflet-interactive')){
edgeLayer.clearLayers();
selectedNodeId=null;
@@ -585,7 +615,20 @@ map.on('click', e=>{
BLINKING
====================================================== */
function blinkNode(marker,longName,portnum){
function escapeHtml(value){
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
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 +638,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 ? `<br><span>${escapeHtml(shortPayload)}</span>` : "";
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 +670,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);
+20 -2
View File
@@ -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 @@
<div id="chat-container">
<div class="container" id="chat-log"></div>
</div>
<div id="net-note">
Only the earliest report from each device is listed.
</div>
</div>
<script>
@@ -65,6 +70,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}
const renderedPacketIds = new Set();
const renderedNodeIds = new Set();
let netTranslations = {};
let netTag = "";
@@ -95,7 +101,7 @@ document.addEventListener("DOMContentLoaded", async () => {
Update count
----------------------------------- */
function updateTotalCount() {
totalCountValueEl.textContent = renderedPacketIds.size;
totalCountValueEl.textContent = renderedNodeIds.size;
}
/* -----------------------------------
@@ -104,6 +110,7 @@ document.addEventListener("DOMContentLoaded", async () => {
function renderPacket(packet) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
renderedNodeIds.add(packet.from_node_id);
const date = new Date(packet.import_time_us / 1000);
@@ -161,7 +168,18 @@ document.addEventListener("DOMContentLoaded", async () => {
----------------------------------- */
function renderPacketsEnsureDescending(packets) {
if (!packets || !packets.length) return;
const sorted = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
const earliestByNode = new Map();
for (const packet of packets) {
const existing = earliestByNode.get(packet.from_node_id);
if (!existing || packet.import_time_us < existing.import_time_us) {
earliestByNode.set(packet.from_node_id, packet);
}
}
const sorted = Array.from(earliestByNode.values()).sort(
(a, b) => b.import_time_us - a.import_time_us
);
for (let i = sorted.length - 1; i >= 0; i--) {
renderPacket(sorted[i]);
}
+62 -45
View File
@@ -18,21 +18,23 @@
/* --- Node Info --- */
.node-info {
background-color: #1f2226;
border: 1px solid #3a3f44;
color: #ddd;
font-size: 0.88rem;
padding: 12px 14px;
margin-bottom: 14px;
border-radius: 8px;
display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr));
grid-column-gap: 14px;
grid-row-gap: 6px;
grid-column-gap: 12px;
grid-row-gap: 12px;
}
.node-info div { padding: 2px 0; }
.node-info-col {
background-color: #1f2226;
border: 1px solid #3a3f44;
border-radius: 8px;
padding: 12px 14px;
}
.node-info-col div { padding: 2px 0; }
.node-info strong {
color: #9fd4ff;
font-weight: 600;
@@ -357,31 +359,32 @@
<!-- Node Info -->
<div id="node-info" class="node-info">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div>
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway"></span></div>
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div>
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update"></span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div>
<div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
<span id="info-stats"
data-label-24h="24h"
data-label-sent="Packets sent"
data-label-seen="Times seen">—</span>
<div class="node-info-col">
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id"></span></div>
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id"></span></div>
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel"></span></div>
</div>
<div class="node-info-col">
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway"></span></div>
</div>
<div class="node-info-col">
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update"></span></div>
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update"></span></div>
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware"></span></div>
<div>
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
<span id="info-stats"
data-label-24h="24h"
data-label-sent="Packets sent"
data-label-seen="Times seen">—</span>
</div>
</div>
</div>
<!-- Map. -->
@@ -895,13 +898,21 @@ function addMarker(id, lat, lon, color = "red", node = null) {
async function drawNeighbors(src, nids) {
if (!map) return;
// Ensure source node position exists
const srcNode = await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
// Prefer the currently displayed source position (e.g. latest track point),
// then fall back to the node API location.
let srcLat;
let srcLon;
let srcNode = currentNode || nodeCache[src] || null;
const srcLat = srcNode.last_lat / 1e7;
const srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
if (nodePositions[src]) {
[srcLat, srcLon] = nodePositions[src];
} else {
srcNode = srcNode || await fetchNodeFromApi(src);
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
srcLat = srcNode.last_lat / 1e7;
srcLon = srcNode.last_long / 1e7;
nodePositions[src] = [srcLat, srcLon];
}
for (const nid of nids) {
const neighbor = await fetchNodeFromApi(nid);
@@ -1439,13 +1450,20 @@ async function loadPacketHistogram() {
const DAYS = 7;
const now = new Date();
const dayKeyFromDate = (d) => {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
};
const dayKeys = [];
const dayLabels = [];
for (let i = DAYS - 1; i >= 0; i--) {
const d = new Date(now);
d.setDate(d.getDate() - i);
dayKeys.push(d.toISOString().slice(0, 10));
dayKeys.push(dayKeyFromDate(d));
dayLabels.push(
d.toLocaleDateString([], { month: "short", day: "numeric" })
);
@@ -1473,9 +1491,7 @@ async function loadPacketHistogram() {
for (const pkt of packets) {
if (!pkt.import_time_us) continue;
const day = new Date(pkt.import_time_us / 1000)
.toISOString()
.slice(0, 10);
const day = dayKeyFromDate(new Date(pkt.import_time_us / 1000));
if (!dayKeys.includes(day)) continue;
@@ -1619,15 +1635,16 @@ document.addEventListener("DOMContentLoaded", async () => {
// ✅ MAP MUST EXIST FIRST
if (!map) initMap();
// Load the track first so neighbor links anchor to the same
// visible current-node position shown on this page.
await loadTrack();
// ✅ DRAW LATEST NEIGHBORS ONCE
const neighborIds = await loadLatestNeighborIds();
if (neighborIds.length) {
await drawNeighbors(fromNodeId, neighborIds);
}
// ⚠️ Track may add to map, but must not hide it
await loadTrack();
await loadPackets();
initPacketPortFilter();
await loadTelemetryCharts();
+228 -20
View File
@@ -85,6 +85,15 @@
border:none;
border-radius:4px;
}
.chart-filter {
margin-bottom: 8px;
padding: 4px 6px;
background: #444;
color: #fff;
border: none;
border-radius: 4px;
}
{% endblock %}
{% block head %}
@@ -118,15 +127,38 @@
</div>
</div>
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_all">
Packets per Day - All Ports (Last 14 Days)
<p class="section-header" data-translate-lang="daily_snapshot_packets_history">
Daily Packet Snapshot History
</p>
<div id="total_daily_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_all" class="chart"></div>
<select id="snapshotPacketsRange" class="chart-filter" aria-label="Snapshot packet timeframe">
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">1 month</option>
<option value="183">6 months</option>
<option value="365">1 year</option>
<option value="all" selected>All</option>
</select>
<button class="expand-btn" data-chart="chart_daily_snapshot_packets" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot_packets" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot_packets" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="daily_snapshot_nodes_gateways_history">
Daily Node/Gateway Snapshot History
</p>
<select id="snapshotNodesGatewaysRange" class="chart-filter" aria-label="Snapshot node and gateway timeframe">
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">1 month</option>
<option value="183">6 months</option>
<option value="365">1 year</option>
<option value="all" selected>All</option>
</select>
<button class="expand-btn" data-chart="chart_daily_snapshot_nodes_gateways" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot_nodes_gateways" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot_nodes_gateways" class="chart"></div>
</div>
<!-- Packet Types Pie Chart with Channel Selector -->
@@ -247,6 +279,15 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
async function fetchDailySnapshots(){
try{
const res=await fetch("/api/snapshots/daily");
if(!res.ok) return [];
const json=await res.json();
return json.data||[];
}catch{return [];}
}
async function fetchNodes(){
try{
const res=await fetch("/api/nodes");
@@ -267,6 +308,18 @@ async function fetchChannels(){
}
}
function buildPacketTypeBreakdownFromSeries(allData, perPortSeries){
const totalAll = (allData || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
const results = perPortSeries.map(({portnum, data}) => ({
portnum,
count: (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0),
}));
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
const other = Math.max(totalAll - trackedTotal,0);
if(other>0) results.push({portnum:"other", count:other});
return results;
}
function processCountField(nodes,field){
const counts={};
nodes.forEach(n=>{
@@ -293,6 +346,22 @@ function prepareTopN(data,n=20){
return top;
}
function filterSnapshotsByRange(data, rangeValue){
if(rangeValue === "all") return data;
const days = Number.parseInt(rangeValue, 10);
if(Number.isNaN(days) || days <= 0 || !data.length) return data;
const latest = data.reduce((maxDate, item) => {
const itemDate = new Date(`${item.date}T00:00:00Z`);
return itemDate > maxDate ? itemDate : maxDate;
}, new Date(`${data[0].date}T00:00:00Z`));
const cutoff = new Date(latest);
cutoff.setUTCDate(cutoff.getUTCDate() - (days - 1));
return data.filter(item => new Date(`${item.date}T00:00:00Z`) >= cutoff);
}
// --- Chart Rendering ---
function renderChart(domId,data,type,color){
const el=document.getElementById(domId);
@@ -365,6 +434,106 @@ function renderPieChart(elId,data,name){
return chart;
}
function makeTelemetryLine(name, color, seriesData) {
return {
name,
type: "line",
smooth: true,
connectNulls: true,
showSymbol: true,
symbol: "circle",
symbolSize: 7,
lineStyle: {
width: 2,
color,
shadowColor: color.replace("1)", "0.35)"),
shadowBlur: 8,
shadowOffsetY: 3
},
itemStyle: {
color,
borderColor: "#000",
borderWidth: 1
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: color.replace("1)", "0.5)") },
{ offset: 0.6, color: color.replace("1)", "0.2)") },
{ offset: 1, color: "rgba(0,0,0,0)" }
])
},
data: seriesData
};
}
function renderDailySnapshotPacketsChart(domId,data){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.getInstanceByDom(el) || echarts.init(el);
const labels=data.map(d=>d.date||"");
const packetCounts=data.map(d=>d.packet_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
legend:{
data:['Packets'],
textStyle:{color:'#ccc'}
},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{
type:'category',
data:labels,
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{rotate:45,color:'#ccc'}
},
yAxis:{
type:'value',
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{color:'#ccc'}
},
series:[
makeTelemetryLine("Packets", "rgba(255,214,82,1)", packetCounts)
]
});
return chart;
}
function renderDailySnapshotNodesGatewaysChart(domId,data){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.getInstanceByDom(el) || echarts.init(el);
const labels=data.map(d=>d.date||"");
const nodeCounts=data.map(d=>d.node_count??0);
const gatewayCounts=data.map(d=>d.gateway_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
legend:{
data:['Nodes','Gateways'],
textStyle:{color:'#ccc'}
},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{
type:'category',
data:labels,
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{rotate:45,color:'#ccc'}
},
yAxis:{
type:'value',
axisLine:{lineStyle:{color:'#aaa'}},
axisLabel:{color:'#ccc'}
},
series:[
makeTelemetryLine("Nodes", "rgba(79,155,255,1)", nodeCounts),
makeTelemetryLine("Gateways", "rgba(138,255,108,1)", gatewayCounts)
]
});
return chart;
}
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
@@ -388,9 +557,25 @@ async function fetchPacketTypeBreakdown(channel=null) {
// --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1;
let chartDailySnapshotPackets, chartDailySnapshotNodesGateways;
let chartHwModel, chartRole, chartChannel;
let chartGatewayChannel, chartGatewayRole, chartGatewayFirmware;
let chartPacketTypes;
let defaultPacketTypesData = [];
let dailySnapshots = [];
function renderSnapshotCharts(){
const packetsRange = document.getElementById("snapshotPacketsRange")?.value || "all";
const nodesGatewaysRange = document.getElementById("snapshotNodesGatewaysRange")?.value || "all";
chartDailySnapshotPackets = renderDailySnapshotPacketsChart(
"chart_daily_snapshot_packets",
filterSnapshotsByRange(dailySnapshots, packetsRange)
);
chartDailySnapshotNodesGateways = renderDailySnapshotNodesGatewaysChart(
"chart_daily_snapshot_nodes_gateways",
filterSnapshotsByRange(dailySnapshots, nodesGatewaysRange)
);
}
async function init(){
// Channel selector
@@ -403,10 +588,9 @@ async function init(){
select.appendChild(opt);
});
// Daily all ports
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
// Daily snapshot histogram
dailySnapshots = await fetchDailySnapshots();
renderSnapshotCharts();
// Daily port 1
const dailyPort1Data=await fetchStats('day',14,1);
@@ -463,7 +647,9 @@ async function init(){
}
// Packet types pie
const packetTypesData = await fetchPacketTypeBreakdown();
const packetSeries = portnums.map((pn, i) => ({ portnum: pn, data: allData[i] }));
const packetTypesData = buildPacketTypeBreakdownFromSeries(hourlyAllData, packetSeries);
defaultPacketTypesData = packetTypesData;
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
@@ -502,6 +688,8 @@ window.addEventListener('resize',()=>{
chartPortnum67,
chartPortnum70,
chartPortnum71,
chartDailySnapshotPackets,
chartDailySnapshotNodesGateways,
chartDailyAll,
chartDailyPortnum1,
chartHwModel,
@@ -554,10 +742,23 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
const option=chart.getOption();
let rows=[];
if(option.series[0].type==="bar"||option.series[0].type==="line"){
rows.push(["Period","Count"]);
const xData=option.xAxis[0].data;
const yData=option.series[0].data;
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
const series = option.series || [];
if(series.length > 1){
const headers = ["Period", ...series.map(s => s.name || "Series")];
rows.push(headers);
for(let i=0;i<xData.length;i++){
const row=[xData[i]];
series.forEach(s=>{
row.push((s.data && s.data[i]!==undefined) ? s.data[i] : "");
});
rows.push(row);
}
}else{
rows.push(["Period","Count"]);
const yData=series[0].data;
for(let i=0;i<xData.length;i++) rows.push([xData[i],yData[i]]);
}
}
if(option.series[0].type==="pie"){
rows.push(["Name","Value","Percentage"]);
@@ -573,7 +774,7 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
const packetTypesData = await fetchPacketTypeBreakdown(channel);
const packetTypesData = channel ? await fetchPacketTypeBreakdown(channel) : defaultPacketTypesData;
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
@@ -586,6 +787,9 @@ document.getElementById("channelSelect").addEventListener("change", async (e)=>{
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
});
document.getElementById("snapshotPacketsRange")?.addEventListener("change", renderSnapshotCharts);
document.getElementById("snapshotNodesGatewaysRange")?.addEventListener("change", renderSnapshotCharts);
// Kick everything off
init();
@@ -593,10 +797,14 @@ init();
async function loadConfigAndTranslations() {
let langCode = "en";
try {
const resConfig = await fetch("/api/config");
const cfg = await resConfig.json();
window.site_config = cfg;
langCode = cfg?.site?.language || "en";
if (window.site_config?.site) {
langCode = window.site_config.site.language || "en";
} else {
const resConfig = await fetch("/api/config");
const cfg = await resConfig.json();
window.site_config = cfg;
langCode = cfg?.site?.language || "en";
}
} catch(err) {
console.error("Failed to load /api/config:", err);
window.site_config = { site: { language: "en" } };
+4
View File
@@ -354,6 +354,8 @@ async def graph_traceroute(request):
node_ids = set()
for tr in traceroutes:
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
node_ids.add(tr.gateway_node_id)
for node_id in route.route:
node_ids.add(node_id)
@@ -379,6 +381,8 @@ async def graph_traceroute(request):
if tr.done and dest:
continue
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
path = [packet.from_node_id]
path.extend(route.route)
if tr.done:
+58 -1
View File
@@ -13,7 +13,7 @@ from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store
from meshview.__version__ import __version__, _git_revision_short, get_version_info
from meshview.config import CONFIG
from meshview.models import Node, NodePublicKey
from meshview.models import DailySnapshot, Node, NodePublicKey
from meshview.models import Packet as PacketModel
from meshview.models import PacketSeen as PacketSeenModel
from meshview.radio.coverage import (
@@ -452,6 +452,59 @@ async def api_stats_count(request):
return web.json_response({"total_packets": total_packets, "total_seen": total_seen})
@routes.get("/api/snapshots/daily")
async def api_daily_snapshots(request):
length_raw = request.query.get("length")
length = None
if length_raw is not None:
try:
length = int(length_raw)
except ValueError:
return web.json_response({"error": "length must be an integer"}, status=400)
length = max(1, min(length, 3650))
try:
async with database.async_session() as session:
if length is not None:
rows = (
(
await session.execute(
select(DailySnapshot)
.order_by(DailySnapshot.snapshot_date.desc())
.limit(length)
)
)
.scalars()
.all()
)
rows = list(reversed(rows))
else:
rows = (
(
await session.execute(
select(DailySnapshot).order_by(DailySnapshot.snapshot_date.asc())
)
)
.scalars()
.all()
)
data = [
{
"date": row.snapshot_date.isoformat(),
"node_count": row.node_count,
"packet_count": row.packet_count,
"gateway_count": row.gateway_count,
"captured_at_us": row.captured_at_us,
}
for row in rows
]
return web.json_response({"data": data})
except Exception as e:
logger.error(f"Error in /api/snapshots/daily: {e}")
return web.json_response({"error": "Failed to fetch daily snapshots"}, status=500)
@routes.get("/api/edges")
async def api_edges(request):
since = datetime.datetime.now() - datetime.timedelta(hours=12)
@@ -480,6 +533,8 @@ async def api_edges(request):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
except Exception:
continue
if route is None or tr.packet is None:
continue
path = [tr.packet.from_node_id] + list(route.route)
path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
@@ -781,6 +836,8 @@ async def api_traceroute(request):
# --------------------------------------------
for idx, tr in enumerate(traceroutes):
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
if route is None:
continue
forward_list = list(route.route)
reverse_list = list(route.route_back)
+9
View File
@@ -118,6 +118,15 @@ backup_dir = ./backups
backup_hour = 2
backup_minute = 00
# -------------------------
# Daily Snapshot Configuration
# -------------------------
[snapshot]
# Time to run daily network snapshot capture (24-hour format)
# Defaults are used if this section is omitted.
hour = 1
minute = 00
# -------------------------
# Logging Configuration
+27
View File
@@ -129,6 +129,27 @@ async def daily_backup_at(hour: int = 2, minute: int = 0, backup_dir: str = ".")
await backup_database(database_url, backup_dir)
# -------------------------
# Daily snapshot scheduler
# -------------------------
async def daily_snapshot_at(hour: int = 1, minute: int = 0):
while True:
now = datetime.datetime.now()
next_run = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if next_run <= now:
next_run += datetime.timedelta(days=1)
delay = (next_run - now).total_seconds()
cleanup_logger.info(f"Next daily snapshot scheduled at {next_run}")
await asyncio.sleep(delay)
try:
async with db_lock:
await mqtt_store.capture_daily_snapshot()
cleanup_logger.info("Daily snapshot captured successfully.")
except Exception as e:
cleanup_logger.error(f"Error capturing daily snapshot: {e}")
# -------------------------
# Database cleanup using ORM
# -------------------------
@@ -290,6 +311,8 @@ async def main():
backup_dir = CONFIG.get("cleanup", {}).get("backup_dir", "./backups")
backup_hour = get_int(CONFIG, "cleanup", "backup_hour", cleanup_hour)
backup_minute = get_int(CONFIG, "cleanup", "backup_minute", cleanup_minute)
snapshot_hour = get_int(CONFIG, "snapshot", "hour", 1)
snapshot_minute = get_int(CONFIG, "snapshot", "minute", 0)
logger.info(f"Starting MQTT ingestion from {CONFIG['mqtt']['server']}:{CONFIG['mqtt']['port']}")
if cleanup_enabled:
@@ -300,6 +323,7 @@ async def main():
logger.info(
f"Daily backups enabled: storing in {backup_dir} at {backup_hour:02d}:{backup_minute:02d}"
)
logger.info(f"Daily snapshots enabled: capturing at {snapshot_hour:02d}:{snapshot_minute:02d}")
async with asyncio.TaskGroup() as tg:
tg.create_task(
@@ -329,8 +353,11 @@ async def main():
)
)
tg.create_task(daily_snapshot_at(snapshot_hour, snapshot_minute))
if not cleanup_enabled and not backup_enabled:
cleanup_logger.info("Daily cleanup and backups are both disabled by configuration.")
cleanup_logger.info("Daily snapshots remain enabled by schedule configuration.")
# -------------------------