mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-07-06 18:01:05 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d52dfeac1 | |||
| 31744aace9 | |||
| a3ce0640ab | |||
| 1ba64f8908 | |||
| e4384f020c | |||
| bdf70da803 |
@@ -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 @@
|
||||
e1a6b3a868d735da72cd6c94c574d655129d390a
|
||||
cb1f89372a70b0d4b4f8caf05aec28de8d4a13e0
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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(" - ");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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, "&")
|
||||
.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 +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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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" } };
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
|
||||
# -------------------------
|
||||
|
||||
Reference in New Issue
Block a user