diff --git a/README.md b/README.md
index bc79e27..b4ae896 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
- **IMPORTANT:** the predicted coverage feature requires the extra `pyitm` dependency. If it is not installed, the coverage API will return 503.
- Ubuntu install (inside the venv): `./env/bin/pip install pyitm`
- Coverage: predicted coverage overlay (Longley‑Rice area mode) with perimeter rendering and documentation.
+- UI: added QR code display for quick node/app access.
- Gateways: persistent gateway tracking (`is_mqtt_gateway`) and UI indicators in nodes, map popups, and stats.
- Map UX: deterministic jitter for overlapping nodes; edges follow jittered positions.
- Tooling: Meshtastic protobuf updater script with `--check` and `UPSTREAM_REV.txt` tracking.
diff --git a/meshview/lang/ru.json b/meshview/lang/ru.json
new file mode 100644
index 0000000..f914d59
--- /dev/null
+++ b/meshview/lang/ru.json
@@ -0,0 +1,252 @@
+{
+ "base": {
+ "chat": "Чат",
+ "nodes": "Ноды",
+ "everything": "Показать все",
+ "graphs": "Mesh графики",
+ "net": "Недельная сеть",
+ "map": "Живая карта",
+ "stats": "Статистика",
+ "top": "Крупнейшие ноды трафика",
+ "footer": "Посетите Meshview на 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"
+ }
+}
diff --git a/meshview/templates/map.html b/meshview/templates/map.html
index 6a21415..bdc1df8 100644
--- a/meshview/templates/map.html
+++ b/meshview/templates/map.html
@@ -228,6 +228,44 @@ function hashToColor(str){
return c;
}
+function labelFor(key, fallback){
+ return mapTranslations[key] || fallback;
+}
+
+function buildNodePopup(node){
+ const labels = {
+ channel: labelFor("channel_label", "Channel"),
+ model: labelFor("model_label", "Model"),
+ role: labelFor("role_label", "Role"),
+ mqtt: labelFor("mqtt_gateway", "MQTT Gateway"),
+ lastSeen: labelFor("last_seen", "Last Seen"),
+ firmware: labelFor("firmware", "Firmware"),
+ yes: mapTranslations.yes || "Yes",
+ no: mapTranslations.no || "No"
+ };
+
+ return `
+ ${node.long_name} (${node.short_name})
+
+ ${labels.channel} ${node.channel}
+ ${labels.model} ${node.hw_model}
+ ${labels.role} ${node.role}
+ ${labels.mqtt} ${node.is_mqtt_gateway ? labels.yes : labels.no}
+
+ ${
+ node.last_seen_us
+ ? `${labels.lastSeen} ${timeAgoFromUs(node.last_seen_us)}
`
+ : ""
+ }
+
+ ${
+ node.firmware
+ ? `${labels.firmware} ${node.firmware}
`
+ : ""
+ }
+ `;
+}
+
function hashToUnit(str){
let h = 2166136261;
for(let i=0;i {
onNodeClick(node);
- marker.bindPopup(popup).openPopup();
+ marker.setPopupContent(buildNodePopup(node));
+ marker.openPopup();
});
});
diff --git a/meshview/templates/node.html b/meshview/templates/node.html
index d3c8a7c..50d2ba5 100644
--- a/meshview/templates/node.html
+++ b/meshview/templates/node.html
@@ -898,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);
@@ -1627,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();
diff --git a/meshview/templates/nodelist.html b/meshview/templates/nodelist.html
index 58aa0d6..d0c92ed 100644
--- a/meshview/templates/nodelist.html
+++ b/meshview/templates/nodelist.html
@@ -266,7 +266,7 @@ select, .export-btn, .search-box, .clear-btn {
Last Latitude |
Last Longitude |
Channel |
- MQTT |
+ MQTT |
Last Seen |
|
@@ -331,7 +331,7 @@ const minStatusMs = 300;
const headers = document.querySelectorAll("thead th");
const keyMap = [
"short_name","long_name","hw_model","firmware","role",
- "last_lat","last_long","channel","last_seen_us"
+ "last_lat","last_long","channel","is_mqtt_gateway","last_seen_us"
];
function debounce(fn, delay = 250) {
@@ -717,6 +717,11 @@ document.addEventListener("DOMContentLoaded", async function() {
B = B || 0;
}
+ if (key === "is_mqtt_gateway") {
+ A = A ? 1 : 0;
+ B = B ? 1 : 0;
+ }
+
// Normalize strings for stable sorting
if (typeof A === "string") A = A.toLowerCase();
if (typeof B === "string") B = B.toLowerCase();
diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html
index 42e3122..0be014a 100644
--- a/meshview/templates/packet.html
+++ b/meshview/templates/packet.html
@@ -408,6 +408,11 @@ seenSorted.forEach(s => {
hopGroups[hopValue].push(s);
});
+function formatHopDisplay(hopKey, hopStart){
+ const startVal = hopStart ?? "—";
+ return `${hopKey}/${startVal}`;
+}
+
/* ---------------------------------------------
Render grouped gateway table + map markers
----------------------------------------------*/
@@ -424,6 +429,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
+ const hopDisplay = formatHopDisplay(hopKey, s.hop_start);
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
@@ -482,7 +488,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
RSSI: ${s.rx_rssi ?? "—"}
SNR: ${s.rx_snr ?? "—"}
- Hops: ${hopKey}
+ Hops: ${hopDisplay}
`);
@@ -493,7 +499,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
${label} |
${s.rx_rssi ?? "—"} |
${s.rx_snr ?? "—"} |
- ${hopKey} |
+ ${hopDisplay} |
${s.channel ?? "—"} |
${timeStr} |