Merge branch 'master' into 3.0.6

This commit is contained in:
Pablo Revilla
2026-04-30 11:32:37 -07:00
committed by GitHub
6 changed files with 326 additions and 14 deletions
+1
View File
@@ -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 (LongleyRice 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.
+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"
}
}
+40 -1
View File
@@ -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 `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</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.mqtt}</b> ${node.is_mqtt_gateway ? labels.yes : labels.no}<br>
${
node.last_seen_us
? `<b>${labels.lastSeen}</b> ${timeAgoFromUs(node.last_seen_us)}<br>`
: ""
}
${
node.firmware
? `<b>${labels.firmware}</b> ${node.firmware}<br>`
: ""
}
`;
}
function hashToUnit(str){
let h = 2166136261;
for(let i=0;i<str.length;i++){
@@ -456,7 +494,8 @@ function renderNodesOnMap(){
marker.on('click', () => {
onNodeClick(node);
marker.bindPopup(popup).openPopup();
marker.setPopupContent(buildNodePopup(node));
marker.openPopup();
});
});
+18 -9
View File
@@ -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();
+7 -2
View File
@@ -266,7 +266,7 @@ select, .export-btn, .search-box, .clear-btn {
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
<th data-translate-lang="mqtt_gateway">MQTT</th>
<th data-translate-lang="mqtt_gateway">MQTT <span class="sort-icon"></span></th>
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
<th data-translate-lang="favorite"></th>
</tr>
@@ -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();
+8 -2
View File
@@ -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 ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b data-translate-lang="hops">Hops</b>: ${hopKey}
<b data-translate-lang="hops">Hops</b>: ${hopDisplay}
</div>
`);
@@ -493,7 +499,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
<td><a href="/node/${s.node_id}">${label}</a></td>
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${hopKey}</td>
<td>${hopDisplay}</td>
<td>${s.channel ?? "—"}</td>
<td>${timeStr}</td>
</tr>