Update multi-language support. So far Spanish and english.

This commit is contained in:
Pablo Revilla
2025-12-02 13:51:18 -08:00
parent 191a01a03c
commit 198afcc7d8
12 changed files with 1572 additions and 929 deletions

View File

@@ -22,47 +22,69 @@
}
},
"chat": {
"chat_title": "Chats:",
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw_models": "All HW Models",
"all_firmware": "All Firmware",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing": "Showing",
"nodes": "nodes",
"short": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_update": "Last Update",
"loading_nodes": "Loading nodes...",
"no_nodes": "No nodes found",
"error_nodes": "Error loading nodes"
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw": "All HW Models",
"all_firmware": "All Firmware",
"show_favorites": "⭐ Show Favorites",
"show_all": "Show All",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing_nodes": "Showing",
"nodes_suffix": "nodes",
"loading_nodes": "Loading nodes...",
"error_loading_nodes": "Error loading nodes",
"no_nodes_found": "No nodes found",
"short_name": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_seen": "Last Seen",
"favorite": "Favorite",
"time_just_now": "just now",
"time_min_ago": "min ago",
"time_hr_ago": "hr ago",
"time_day_ago": "day ago",
"time_days_ago": "days ago"
},
"net": {
"number_of_checkins": "Number of Check-ins:",
"view_packet_details": "View packet details",
"view_all_packets_from_node": "View all packets from this node",
"no_packets_found": "No packets found."
},
"map": {
"channel": "Channel:",
"model": "Model:",
"role": "Role:",
"show_routers_only": "Show Routers Only",
"share_view": "Share This View",
"reset_filters": "Reset Filters To Defaults",
"channel_label": "Channel:",
"model_label": "Model:",
"role_label": "Role:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"show_routers_only": "Show Routers Only",
"share_view": "Share This View"
"link_copied": "Link Copied!",
"legend_traceroute": "Ruta de traceroute (con flechas)",
"legend_neighbor": "Enlace de vecino"
},
"stats":
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
@@ -82,21 +104,20 @@
"all_channels": "All Channels",
"node_id": "Node ID"
},
"top":
{
"top_traffic_nodes": "Top Traffic Nodes (last 24 hours)",
"chart_description_1": "This chart shows a bell curve (normal distribution) based on the total \"Times Seen\" values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.",
"chart_description_2": "This \"Times Seen\" value is the closest that we can get to Mesh utilization by node.",
"mean_label": "Mean:",
"stddev_label": "Standard Deviation:",
"top": {
"top_traffic_nodes": "Top Nodes Traffic",
"channel": "Channel",
"search": "Search",
"search_placeholder": "Search nodes...",
"long_name": "Long Name",
"short_name": "Short Name",
"channel": "Channel",
"packets_sent": "Packets Sent",
"times_seen": "Times Seen",
"seen_percent": "Seen % of Mean",
"no_nodes": "No top traffic nodes available."
"packets_sent": "Sent (24h)",
"times_seen": "Seen (24h)",
"avg_gateways": "Avg Gateways",
"showing_nodes": "Showing",
"nodes_suffix": "nodes"
},
"nodegraph":
{
"channel_label": "Channel:",
@@ -131,11 +152,59 @@
"telemetry": "Telemetry",
"trace_route": "Trace Route",
"neighbor_info": "Neighbor Info",
"direct_to_mqtt": "direct to MQTT",
"all": "All",
"map": "Map",
"graph": "Graph"
}
},
"node": {
"specifications": "Specifications:",
"node_id": "Node ID:",
"long_name": "Long Name:",
"short_name": "Short Name:",
"hw_model": "Hardware Model:",
"firmware": "Firmware:",
"role": "Role:",
"channel": "Channel:",
"latitude": "Latitude:",
"longitude": "Longitude:",
"last_update": "Last Update:",
"battery_voltage": "Battery & Voltage",
"air_channel": "Air & Channel Utilization",
"environment": "Environment Metrics",
"neighbors_chart": "Neighbors (Signal-to-Noise)",
"expand": "Expand",
"export_csv": "Export CSV",
"time": "Time",
"packet_id": "Packet ID",
"from": "From",
"to": "To",
"port": "Port",
"direct_to_mqtt": "Direct to MQTT",
"all_broadcast": "All"
},
"packet": {
"loading": "Loading packet information...",
"packet_id_label": "Packet ID",
"from_node": "From Node",
"to_node": "To Node",
"channel": "Channel",
"port": "Port",
"raw_payload": "Raw Payload",
"decoded_telemetry": "Decoded Telemetry",
"location": "Location",
"seen_by": "Seen By",
"gateway": "Gateway",
"rssi": "RSSI",
"snr": "SNR",
"hops": "Hop",
"time": "Time",
"packet_source": "Packet Source",
"distance": "Distance",
"node_id_short": "Node ID",
"all_broadcast": "All",
"direct_to_mqtt": "Direct to MQTT"
}
}

View File

@@ -1,16 +1,16 @@
{
"base": {
"conversations": "Conversaciones",
"chat": "Conversaciones",
"nodes": "Nodos",
"everything": "Mostrar Todo",
"graph": "Gráficos de la Malla",
"everything": "Mostrar todo",
"graphs": "Gráficos de la Malla",
"net": "Red Semanal",
"map": "Mapa en Vivo",
"stats": "Estadísticas",
"top": "Nodos con Mayor Tráfico",
"footer": "Visita <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> en Github.",
"node id": "ID de Nodo",
"go to node": "Ir al nodo",
"node_id": "ID de Nodo",
"go_to_node": "Ir al nodo",
"all": "Todos",
"portnum_options": {
"1": "Mensaje de Texto",
@@ -21,48 +21,65 @@
"71": "Información de Vecinos"
}
},
"chat": {
"replying_to": "Respondiendo a:",
"view_packet_details": "Ver detalles del paquete"
"chat_title": "Conversaciones:",
"replying_to": "Respondiendo a:",
"view_packet_details": "Ver detalles del paquete"
},
"nodelist": {
"search_placeholder": "Buscar por nombre o ID...",
"all_roles": "Todos los Roles",
"all_channels": "Todos los Canales",
"all_hw_models": "Todos los Modelos",
"all_firmware": "Todo el Firmware",
"all_roles": "Todos los roles",
"all_channels": "Todos los canales",
"all_hw": "Todos los modelos",
"all_firmware": "Todo el firmware",
"show_favorites": "⭐ Mostrar favoritos",
"show_all": "⭐ Mostrar todos",
"export_csv": "Exportar CSV",
"clear_filters": "Limpiar Filtros",
"showing": "Mostrando",
"nodes": "nodos",
"short": "Corto",
"long_name": "Largo",
"hw_model": "Modelo",
"clear_filters": "Limpiar filtros",
"showing_nodes": "Mostrando",
"nodes_suffix": "nodos",
"loading_nodes": "Cargando nodos...",
"error_loading_nodes": "Error al cargar nodos",
"no_nodes_found": "No se encontraron nodos",
"short_name": "Corto",
"long_name": "Nombre largo",
"hw_model": "Modelo HW",
"firmware": "Firmware",
"role": "Rol",
"last_lat": "Última Latitud",
"last_long": "Última Longitud",
"last_lat": "Última latitud",
"last_long": "Última longitud",
"channel": "Canal",
"last_update": "Última Actualización",
"loading_nodes": "Cargando nodos...",
"no_nodes": "No se encontraron nodos",
"error_nodes": "Error al cargar nodos"
"last_seen": "Última vez visto",
"favorite": "Favorito",
"time_just_now": "justo ahora",
"time_min_ago": "min atrás",
"time_hr_ago": "h atrás",
"time_day_ago": "día atrás",
"time_days_ago": "días atrás"
},
"net": {
"number_of_checkins": "Número de registros:",
"view_packet_details": "Ver detalles del paquete",
"view_all_packets_from_node": "Ver todos los paquetes de este nodo",
"no_packets_found": "No se encontraron paquetes."
"net_title": "Red Semanal:",
"total_messages": "Número de mensajes:",
"view_packet_details": "Más Detalles"
},
"map": {
"channel": "Canal:",
"model": "Modelo:",
"role": "Rol:",
"filter_routers_only": "Mostrar solo enrutadores",
"share_view": "Compartir esta vista",
"reset_filters": "Restablecer filtros",
"channel_label": "Canal:",
"model_label": "Modelo:",
"role_label": "Rol:",
"last_seen": "Visto por última vez:",
"firmware": "Firmware:",
"show_routers_only": "Mostrar solo enrutadores",
"share_view": "Compartir esta vista"
"link_copied": "¡Enlace copiado!",
"legend_traceroute": "Ruta de traceroute (flechas de dirección)",
"legend_neighbor": "Vínculo de vecinos"
},
"stats": {
"mesh_stats_summary": "Estadísticas de la Malla - Resumen (completas en la base de datos)",
"total_nodes": "Nodos Totales",
@@ -80,22 +97,22 @@
"export_csv": "Exportar CSV",
"all_channels": "Todos los Canales"
},
"top": {
"top_traffic_nodes": "Tráfico (últimas 24 horas)",
"chart_description_1": "Este gráfico muestra una curva normal (distribución normal) basada en el valor total de \"Veces Visto\" para todos los nodos. Ayuda a visualizar con qué frecuencia se detectan los nodos en relación con el promedio.",
"chart_description_2": "Este valor de \"Veces Visto\" es lo más aproximado que tenemos al nivel de uso de la malla por nodo.",
"mean_label": "Media:",
"stddev_label": "Desviación Estándar:",
"top_traffic_nodes": "Tráfico de Nodos (24h)",
"channel": "Canal",
"search": "Buscar",
"search_placeholder": "Buscar nodos...",
"long_name": "Nombre Largo",
"short_name": "Nombre Corto",
"channel": "Canal",
"packets_sent": "Paquetes Enviados",
"times_seen": "Veces Visto",
"seen_percent": "% Visto respecto a la Media",
"no_nodes": "No hay nodos con mayor tráfico disponibles."
"packets_sent": "Enviados (24h)",
"times_seen": "Visto (24h)",
"avg_gateways": "Promedio de Gateways",
"showing_nodes": "Mostrando",
"nodes_suffix": "nodos"
},
"nodegraph":
{
"nodegraph": {
"channel_label": "Canal:",
"search_placeholder": "Buscar nodo...",
"search_button": "Buscar",
@@ -109,34 +126,68 @@
"unknown": "Desconocido",
"node_not_found": "¡Nodo no encontrado en el canal actual!"
},
"firehose":
{
"live_feed": "📡 Flujo en Vivo",
"pause": "Pausar",
"resume": "Continuar",
"time": "Hora",
"packet_id": "ID del Paquete",
"from": "De",
"to": "Para",
"port": "Puerto",
"links": "Enlaces",
"unknown_app": "APLICACIÓN DESCONOCIDA",
"text_message": "Mensaje de Texto",
"position": "Posición",
"node_info": "Información del Nodo",
"routing": "Enrutamiento",
"administration": "Administración",
"waypoint": "Punto de Ruta",
"store_forward": "Almacenar y Reenviar",
"telemetry": "Telemetría",
"trace_route": "Rastreo de Ruta",
"neighbor_info": "Información de Vecinos",
"firehose": {
"live_feed": "📡 Flujo en vivo",
"pause": "Pausar",
"resume": "Reanudar",
"time": "Hora",
"packet_id": "ID de paquete",
"from": "De",
"to": "A",
"port": "Puerto",
"direct_to_mqtt": "Directo a MQTT",
"all_broadcast": "Todos"
},
"direct_to_mqtt": "Directo a MQTT",
"all": "Todos",
"map": "Mapa",
"graph": "Gráfico"
}
"node": {
"specifications": "Especificaciones:",
"node_id": "ID de Nodo:",
"long_name": "Nombre Largo:",
"short_name": "Nombre Corto:",
"hw_model": "Modelo de Hardware:",
"firmware": "Firmware:",
"role": "Rol:",
"channel": "Canal:",
"latitude": "Latitud:",
"longitude": "Longitud:",
"last_update": "Última Actualización:",
"battery_voltage": "Batería y voltaje",
"air_channel": "Utilización del aire y del canal",
"environment": "Métricas Ambientales",
"neighbors_chart": "Vecinos (Relación Señal/Ruido)",
"expand": "Ampliar",
"export_csv": "Exportar CSV",
"time": "Hora",
"packet_id": "ID del Paquete",
"from": "De",
"to": "A",
"port": "Puerto",
"direct_to_mqtt": "Directo a MQTT",
"all_broadcast": "Todos"
},
"packet": {
"loading": "Cargando información del paquete...",
"packet_id_label": "ID del Paquete",
"from_node": "De",
"to_node": "A",
"channel": "Canal",
"port": "Puerto",
"raw_payload": "Payload sin procesar",
"decoded_telemetry": "Telemetría Decodificada",
"location": "Ubicación",
"seen_by": "Visto por",
"gateway": "Gateway",
"rssi": "RSSI",
"snr": "SNR",
"hops": "Saltos",
"time": "Hora",
"packet_source": "Origen del Paquete",
"distance": "Distancia",
"node_id_short": "ID de Nodo",
"all_broadcast": "Todos",
"direct_to_mqtt": "Directo a MQTT",
"signal": "Señal"
}
}

View File

@@ -1,6 +1,5 @@
from datetime import datetime, timedelta
from sqlalchemy import and_, func, or_, select, text
from sqlalchemy import select, and_, or_, func, cast, Text
from sqlalchemy.orm import lazyload
from meshview import database, models
@@ -27,18 +26,12 @@ async def get_fuzzy_nodes(query):
async def get_packets(
from_node_id=None,
to_node_id=None,
node_id=None, # legacy: match either from OR to
node_id=None, # legacy
portnum=None,
after=None,
contains=None, # NEW: SQL-level substring match
contains=None, # substring search
limit=50,
):
"""
SQLAlchemy 2.0 async ORM version.
Supports strict from/to/node filtering, substring payload search,
portnum, since, and limit.
"""
async with database.async_session() as session:
stmt = select(models.Packet)
conditions = []
@@ -51,36 +44,40 @@ async def get_packets(
if to_node_id is not None:
conditions.append(models.Packet.to_node_id == to_node_id)
# Legacy node ID filter: match either direction
# Legacy node_id (either direction)
if node_id is not None:
conditions.append(
or_(models.Packet.from_node_id == node_id, models.Packet.to_node_id == node_id)
or_(
models.Packet.from_node_id == node_id,
models.Packet.to_node_id == node_id,
)
)
# Port filter
if portnum is not None:
conditions.append(models.Packet.portnum == portnum)
# Timestamp filter
# Timestamp filter using microseconds
if after is not None:
conditions.append(models.Packet.import_time_us > after)
# Case-insensitive substring search on UTF-8 payload (stored as BLOB)
# Case-insensitive substring search on payload (BLOB → TEXT)
if contains:
contains_lower = contains.lower()
conditions.append(func.lower(models.Packet.payload).like(f"%{contains_lower}%"))
contains_lower = f"%{contains.lower()}%"
payload_text = cast(models.Packet.payload, Text)
conditions.append(func.lower(payload_text).like(contains_lower))
# Apply all conditions
# Apply WHERE conditions
if conditions:
stmt = stmt.where(and_(*conditions))
# Order newest → oldest
# Order by newest first
stmt = stmt.order_by(models.Packet.import_time_us.desc())
# Apply limit
# Limit
stmt = stmt.limit(limit)
# Execute query
# Run query
result = await session.execute(stmt)
return result.scalars().all()

View File

@@ -53,8 +53,9 @@
<!-- ⭐ CHAT TITLE WITH ICON, aligned to container ⭐ -->
<div class="container px-2">
<h2 data-translate="chat_title" style="color:white; margin:0 0 10px 0;">
💬 Chat
<h2 style="color:white; margin:0 0 10px 0;">
<span class="icon">💬</span>
<span data-translate="chat_title"></span>
</h2>
</div>
@@ -71,24 +72,45 @@ document.addEventListener("DOMContentLoaded", async () => {
const packetMap = new Map();
let chatLang = {};
function applyTranslations(dict, root = document) {
/* ==========================================================
TRANSLATIONS FOR CHAT PAGE
========================================================== */
function applyTranslations(dict, root=document) {
root.querySelectorAll("[data-translate]").forEach(el => {
const key = el.dataset.translate;
const val = dict[key];
if (!val) return;
if (el.placeholder) el.placeholder = val;
else if (el.tagName === "INPUT" && el.value) el.value = val;
else if (key === "footer") el.innerHTML = val;
else el.textContent = val;
});
}
async function loadChatLang() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatLang = await res.json();
// Apply to existing DOM
applyTranslations(chatLang);
} catch (err) {
console.error("Chat translation load failed:", err);
}
}
/* ==========================================================
SAFE HTML
========================================================== */
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
/* ==========================================================
RENDERING PACKETS
========================================================== */
function renderPacket(packet, highlight = false) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
@@ -139,20 +161,31 @@ document.addEventListener("DOMContentLoaded", async () => {
const div = document.createElement("div");
div.className = "row chat-packet" + (highlight ? " flash" : "");
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time_us}">${formattedTimestamp}</span>
<span class="col-2 timestamp" title="${packet.import_time_us}">
${formattedTimestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">🔎</a>
${escapeHtml(packet.channel || "")}
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/node/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
</a>
</span>
<span class="col-5 message">${escapeHtml(packet.payload)}${replyHtml}</span>
<span class="col-5 message">
${escapeHtml(packet.payload)}${replyHtml}
</span>
`;
chatContainer.prepend(div);
// Translate newly added DOM
applyTranslations(chatLang, div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
@@ -161,26 +194,27 @@ document.addEventListener("DOMContentLoaded", async () => {
function renderPacketsEnsureDescending(packets, highlight=false) {
if (!Array.isArray(packets) || packets.length===0) return;
const sortedDesc = packets.slice().sort((a,b)=>{
const aTime =
(a.import_time_us && a.import_time_us > 0)
? a.import_time_us
: (a.import_time ? new Date(a.import_time).getTime() * 1000 : 0);
const bTime =
(b.import_time_us && b.import_time_us > 0)
? b.import_time_us
: (b.import_time ? new Date(b.import_time).getTime() * 1000 : 0);
const aTime = a.import_time_us || (new Date(a.import_time).getTime() * 1000);
const bTime = b.import_time_us || (new Date(b.import_time).getTime() * 1000);
return bTime - aTime;
});
for (let i=sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight);
for (let i=sortedDesc.length-1; i>=0; i--) {
renderPacket(sortedDesc[i], highlight);
}
}
/* ==========================================================
FETCHING PACKETS
========================================================== */
async function fetchInitial() {
try {
const resp = await fetch("/api/packets?portnum=1&limit=100");
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
lastTime = data?.latest_import_time || lastTime;
} catch(err){ console.error("Initial fetch error:", err); }
} catch(err){
console.error("Initial fetch error:", err);
}
}
async function fetchUpdates() {
@@ -192,21 +226,19 @@ document.addEventListener("DOMContentLoaded", async () => {
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets, true);
lastTime = data?.latest_import_time || lastTime;
} catch(err){ console.error("Fetch updates error:", err); }
} catch(err){
console.error("Fetch updates error:", err);
}
}
async function loadChatLang() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatLang = await res.json();
applyTranslations(chatLang);
} catch(err){ console.error("Chat translation load failed:", err); }
}
/* ==========================================================
INIT
========================================================== */
await loadChatLang(); // load translations FIRST
await fetchInitial(); // then fetch initial packets
await Promise.all([loadChatLang(), fetchInitial()]);
setInterval(fetchUpdates, 5000);
});
</script>
{% endblock %}

View File

@@ -12,6 +12,16 @@
border-radius: 6px;
}
.port-tag {
display: inline-block;
padding: 2px 6px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 500;
color: #fff;
}
/* Packet table */
.packet-table {
width: 100%;
border-collapse: collapse;
@@ -31,6 +41,7 @@
.packet-table tr:nth-of-type(odd) { background-color: #272b2f; }
.packet-table tr:nth-of-type(even) { background-color: #212529; }
/* Port tag */
.port-tag {
display: inline-block;
padding: 1px 6px;
@@ -39,29 +50,22 @@
font-weight: 500;
color: #fff;
}
.port-0 { background-color: #6c757d; }
.port-1 { background-color: #007bff; }
.port-3 { background-color: #28a745; }
.port-4 { background-color: #ffc107; }
.port-5 { background-color: #dc3545; }
.port-6 { background-color: #20c997; }
.port-65 { background-color: #ff66b3; }
.port-67 { background-color: #17a2b8; }
.port-70 { background-color: #6f42c1; }
.port-71 { background-color: #fd7e14; }
.to-mqtt { font-style: italic; color: #aaa; }
/* Payload rows */
.payload-row { display: none; background-color: #1b1e22; }
.payload-cell {
padding: 8px 12px;
font-family: monospace;
white-space: pre-wrap;
color: #b0bec5;
border-top: none;
}
.packet-table tr.expanded + .payload-row { display: table-row; }
.packet-table tr.expanded + .payload-row {
display: table-row;
}
/* Toggle arrow */
.toggle-btn {
cursor: pointer;
color: #aaa;
@@ -70,7 +74,7 @@
}
.toggle-btn:hover { color: #fff; }
/* Link next to port tag */
/* Inline link next to port tag */
.inline-link {
margin-left: 6px;
font-weight: bold;
@@ -84,9 +88,16 @@
{% block body %}
<div class="container">
<form class="d-flex align-items-center justify-content-between mb-3">
<h5 class="mb-0" data-translate-lang="live_feed">📡 Live Feed</h5>
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary" data-translate-lang="pause">Pause</button>
<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>
</form>
<table class="packet-table">
@@ -101,34 +112,71 @@
</thead>
<tbody id="packet_list"></tbody>
</table>
</div>
<script>
let lastImportTimeUs = null;
let updatesPaused = false;
let nodeMap = {};
let updateInterval = 3000;
/* ======================================================
FIREHOSE TRANSLATION SYSTEM (isolated from base)
====================================================== */
let firehoseTranslations = {};
function applyTranslations(translations, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
});
function applyTranslationsFirehose(translations, root=document) {
root
.querySelectorAll("[data-translate-lang]")
.forEach(el => {
const key = el.dataset.translateLang;
if (!translations[key]) return;
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = translations[key];
} else {
el.textContent = translations[key];
}
});
}
async function loadTranslations() {
async function loadTranslationsFirehose() {
try {
const cfg = await window._siteConfigPromise;
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=firehose`);
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=firehose`);
firehoseTranslations = await res.json();
applyTranslations(firehoseTranslations, document);
applyTranslationsFirehose(firehoseTranslations);
} catch (err) {
console.error("Firehose translation load failed:", err);
}
}
/* ======================================================
NODE LOOKUP
====================================================== */
let nodeMap = {};
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
const data = await res.json();
for (const n of data.nodes || []) {
nodeMap[n.node_id] = n.long_name || n.short_name || n.id || n.node_id;
}
nodeMap[4294967295] = firehoseTranslations.all_broadcast || "All";
} catch (err) {
console.error("Failed loading nodes:", err);
}
}
function nodeName(id) {
return nodeMap[id] || id;
}
/* ======================================================
PORT COLORS & NAMES
====================================================== */
const PORT_MAP = {
0: "UNKNOWN APP",
1: "Text Message",
@@ -140,51 +188,38 @@ const PORT_MAP = {
65: "Store Forward",
67: "Telemetry",
70: "Trace Route",
71: "Neighbor Info",
71: "Neighbor Info"
};
const PORT_COLORS = {
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
68: "#fd7e14",
69: "#6f42c1",
70: "#ff4444",
71: "#ff66cc",
72: "#00cc99",
73: "#9999ff",
74: "#cc00cc",
75: "#ffbb33",
76: "#00bcd4",
77: "#8bc34a",
78: "#795548"
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
68: "#fd7e14",
69: "#6f42c1",
70: "#ff4444",
71: "#ff66cc",
72: "#00cc99",
73: "#9999ff",
74: "#cc00cc",
75: "#ffbb33",
76: "#00bcd4",
77: "#8bc34a",
78: "#795548"
};
// Load node names
async function loadNodes() {
const res = await fetch("/api/nodes");
if (!res.ok) return;
const data = await res.json();
for (const n of data.nodes || []) {
nodeMap[n.node_id] = n.long_name || n.short_name || n.id || n.node_id;
}
nodeMap[4294967295] = "All";
}
function nodeName(id) {
if (id === 4294967295) return `<span class="to-mqtt">All</span>`;
return nodeMap[id] || id;
}
function portLabel(portnum, payload, linksHtml) {
const name = PORT_MAP[portnum] || "Unknown";
const color = PORT_COLORS[portnum] || "#6c757d";
const safePayload = payload ? payload.replace(/"/g, "&quot;") : "";
const safePayload = payload
? payload.replace(/"/g, "&quot;")
: "";
return `
<span class="port-tag" style="background-color:${color}" title="${safePayload}">
@@ -195,31 +230,46 @@ function portLabel(portnum, payload, linksHtml) {
`;
}
/* ======================================================
TIME FORMAT
====================================================== */
function formatLocalTime(importTimeUs) {
const ms = importTimeUs / 1000;
const date = new Date(ms);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
});
}
/* ======================================================
FIREHOSE FETCHING
====================================================== */
let lastImportTimeUs = null;
let updatesPaused = false;
let updateInterval = 3000;
async function configureFirehose() {
try {
const cfg = await window._siteConfigPromise;
const intervalSec = cfg?.site?.firehose_interval;
if (intervalSec && !isNaN(intervalSec)) updateInterval = parseInt(intervalSec) * 1000;
} catch (err) {
console.warn("Failed to read firehose interval:", err);
}
const sec = cfg?.site?.firehose_interval;
if (sec && !isNaN(sec)) updateInterval = sec * 1000;
} catch {}
}
async function fetchUpdates() {
if (updatesPaused) return;
const url = new URL("/api/packets", window.location.origin);
if (lastImportTimeUs) url.searchParams.set("since", lastImportTimeUs);
url.searchParams.set("limit", 50);
if (lastImportTimeUs)
url.searchParams.set("since", lastImportTimeUs);
try {
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const packets = data.packets || [];
if (!packets.length) return;
@@ -227,19 +277,34 @@ async function fetchUpdates() {
const list = document.getElementById("packet_list");
for (const pkt of packets.reverse()) {
const from = pkt.from_node_id === 4294967295
? `<span class="to-mqtt">All</span>`
: `<a href="/node/${pkt.from_node_id}" style="text-decoration:underline; color:inherit;">${nodeMap[pkt.from_node_id] || pkt.from_node_id}</a>`;
const to = pkt.to_node_id === 1
? `<span class="to-mqtt">direct to MQTT</span>`
: pkt.to_node_id === 4294967295
? `<span class="to-mqtt">All</span>`
: `<a href="/node/${pkt.to_node_id}" style="text-decoration:underline; color:inherit;">${nodeMap[pkt.to_node_id] || pkt.to_node_id}</a>`;
/* FROM — includes translation */
const from =
pkt.from_node_id === 4294967295
? `<span class="to-mqtt" data-translate-lang="all_broadcast">
${firehoseTranslations.all_broadcast || "All"}
</span>`
: `<a href="/node/${pkt.from_node_id}" style="text-decoration:underline; color:inherit;">
${nodeMap[pkt.from_node_id] || pkt.from_node_id}
</a>`;
/* TO — includes translation */
const to =
pkt.to_node_id === 1
? `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
${firehoseTranslations.direct_to_mqtt || "direct to MQTT"}
</span>`
: pkt.to_node_id === 4294967295
? `<span class="to-mqtt" data-translate-lang="all_broadcast">
${firehoseTranslations.all_broadcast || "All"}
</span>`
: `<a href="/node/${pkt.to_node_id}" style="text-decoration:underline; color:inherit;">
${nodeMap[pkt.to_node_id] || pkt.to_node_id}
</a>`;
// Inline link next to port tag
let inlineLinks = "";
// Position link
if (pkt.portnum === 3 && pkt.payload) {
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
@@ -247,36 +312,57 @@ async function fetchUpdates() {
if (latMatch && lonMatch) {
const lat = parseInt(latMatch[1]) / 1e7;
const lon = parseInt(lonMatch[1]) / 1e7;
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
inlineLinks += ` <a class="inline-link"
href="https://www.google.com/maps?q=${lat},${lon}"
target="_blank">📍</a>`;
}
}
// Traceroute link
if (pkt.portnum === 70) {
let traceId = pkt.id;
const match = pkt.payload.match(/ID:\s*(\d+)/i);
if (match) traceId = match[1];
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
inlineLinks += ` <a class="inline-link"
href="/graph/traceroute/${traceId}"
target="_blank">⮕</a>`;
}
const safePayload = (pkt.payload || "").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const localTime = formatLocalTime(pkt.import_time_us);
const safePayload = (pkt.payload || "")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const html = `
<tr class="packet-row" data-id="${pkt.id}">
<td>${localTime}</td>
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
<tr class="packet-row">
<td>${formatLocalTime(pkt.import_time_us)}</td>
<td>
<span class="toggle-btn">▶</span>
<a href="/packet/${pkt.id}"
style="text-decoration:underline; color:inherit;">
${pkt.id}
</a>
</td>
<td>${from}</td>
<td>${to}</td>
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
</tr>
<tr class="payload-row">
<td colspan="5" class="payload-cell">${safePayload}</td>
</tr>`;
</tr>
`;
list.insertAdjacentHTML("afterbegin", html);
}
// Limit table size
while (list.rows.length > 400) list.deleteRow(-1);
lastImportTimeUs = packets[packets.length - 1].import_time_us;
} catch (err) {
@@ -284,29 +370,40 @@ async function fetchUpdates() {
}
}
// --- Initialize ---
/* ======================================================
INITIALIZE PAGE
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
const pauseBtn = document.getElementById("pause-button");
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
pauseBtn.textContent = updatesPaused
? (firehoseTranslations.resume || "Resume")
: (firehoseTranslations.pause || "Pause");
pauseBtn.textContent =
updatesPaused
? (firehoseTranslations.resume || "Resume")
: (firehoseTranslations.pause || "Pause");
});
document.addEventListener("click", (e) => {
document.addEventListener("click", e => {
const btn = e.target.closest(".toggle-btn");
if (!btn) return;
const row = btn.closest(".packet-row");
row.classList.toggle("expanded");
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
btn.textContent =
row.classList.contains("expanded") ? "▼" : "▶";
});
await loadTranslations();
await loadTranslationsFirehose();
await configureFirehose();
await loadNodes();
fetchUpdates();
setInterval(fetchUpdates, updateInterval);
});
</script>
{% endblock %}

View File

@@ -1,9 +1,7 @@
{% extends "base.html" %}
{% block css %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<style>
.legend { background:white;padding:8px;line-height:1.5;border-radius:5px;box-shadow:0 0 10px rgba(0,0,0,0.3);font-size:14px;color:black; }
.legend i { width:12px;height:12px;display:inline-block;margin-right:6px;border-radius:50%; }
@@ -24,54 +22,131 @@
{% block body %}
<div id="map" style="width:100%;height:calc(100vh - 270px)"></div>
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
</div>
<div style="text-align:center;margin-top:5px;">
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
<div id="map-legend" class="legend" style="position:absolute; bottom:30px; right:15px; z-index:1000;">
<div>
<i style="background:orange; width:15px; height:3px; border-radius:0;"></i>
<span data-translate-lang="legend_traceroute">Traceroute Path (arrowed)</span>
</div>
<div style="margin-top:6px;">
<i style="background:gray; width:15px; height:3px; border-radius:0;"></i>
<span data-translate-lang="legend_neighbor">Neighbor Link</span>
</div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js"
integrity="sha384-FhPn/2P/fJGhQLeNWDn9B/2Gml2bPOrKJwFqJXgR3xOPYxWg5mYQ5XZdhUSugZT0"
crossorigin></script>
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only">
<span data-translate-lang="filter_routers_only">Show Routers Only</span>
</div>
<div style="text-align:center;margin-top:5px;">
<button id="share-button" onclick="shareCurrentView()" data-translate-lang="share_view">
🔗 Share This View
</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()" data-translate-lang="reset_filters">
↺ Reset Filters To Defaults
</button>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js" crossorigin=""></script>
<script>
// ---------------------- Map Initialization ----------------------
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'&copy; OpenStreetMap' }).addTo(map);
/* ===========================================================
TRANSLATION LOADER (map section)
=========================================================== */
let mapTranslations = {};
async function loadTranslationsMap() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=map`);
mapTranslations = await res.json();
applyTranslationsMap();
} catch (err) {
console.error("Map translation load failed:", err);
}
}
function applyTranslationsMap() {
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (mapTranslations[key]) {
if (el.tagName === "INPUT" && el.placeholder) {
el.placeholder = mapTranslations[key];
} else {
el.textContent = mapTranslations[key];
}
}
});
}
/* ===========================================================
MAP LOGIC (unchanged except translated popups)
=========================================================== */
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19 }).addTo(map);
// ---------------------- Globals ----------------------
var nodes=[], markers={}, markerById={}, nodeMap = new Map();
var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var mapInterval = 0;
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const colorMap = new Map(); let nextColorIndex = 0;
const portMap = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
};
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6",
"#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8",
"#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const colorMap = new Map();
let nextColorIndex = 0;
const channelSet = new Set();
// ---------------------- Helpers ----------------------
function timeAgo(date){ const diff=Date.now()-new Date(date), s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24); return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s"; }
function hashToColor(str){ if(colorMap.has(str)) return colorMap.get(str); const c=palette[nextColorIndex++%palette.length]; colorMap.set(str,c); return c; }
function isInvalidCoord(n){ return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long); }
function timeAgo(date){
const diff=Date.now()-new Date(date),
s=Math.floor(diff/1000),
m=Math.floor(s/60),
h=Math.floor(m/60),
d=Math.floor(h/24);
return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s";
}
function hashToColor(str){
if(colorMap.has(str)) return colorMap.get(str);
const c=palette[nextColorIndex++%palette.length];
colorMap.set(str,c);
return c;
}
function isInvalidCoord(n){
return !n||!n.lat||!n.long||n.lat===0||n.long===0||
Number.isNaN(n.lat)||Number.isNaN(n.long);
}
/* ------------ Packet Polling ------------ */
// ---------------------- Packet Fetching ----------------------
function fetchLatestPacket(){
fetch(`/api/packets?limit=1`)
.then(r=>r.json())
.then(data=>{
lastImportTime=data.packets?.[0]?.import_time_us||0;
})
.catch(console.error);
});
}
function fetchNewPackets(){
if(mapInterval <= 0) return;
if(lastImportTime===null) return;
const url = new URL(`/api/packets`, window.location.origin);
url.searchParams.set("since", lastImportTime);
url.searchParams.set("limit", 50);
@@ -79,20 +154,21 @@ function fetchNewPackets(){
fetch(url)
.then(r=>r.json())
.then(data=>{
if(!data.packets || data.packets.length===0) return;
if(!data.packets?.length) return;
let latest = lastImportTime;
data.packets.forEach(pkt=>{
if(pkt.import_time_us > latest) latest = pkt.import_time_us;
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);
if(marker && nodeData)
blinkNode(marker, nodeData.long_name, pkt.portnum);
});
lastImportTime = latest;
})
.catch(console.error);
});
}
// ---------------------- Polling ----------------------
let packetInterval=null;
function startPacketFetcher(){
if(mapInterval<=0) return;
@@ -111,226 +187,285 @@ document.addEventListener("visibilitychange",()=>{
document.hidden?stopPacketFetcher():startPacketFetcher();
});
// ---------------------- WAIT FOR CONFIG ----------------------
/* ------------ Load Config ------------ */
async function waitForConfig() {
while (typeof window._siteConfigPromise === "undefined") {
console.log("Waiting for _siteConfigPromise...");
while (!window._siteConfigPromise) {
await new Promise(r => setTimeout(r, 100));
}
try {
const cfg = await window._siteConfigPromise;
if (!cfg || !cfg.site) throw new Error("Config missing site object");
return cfg.site;
} catch (err) {
console.error("Error loading site config:", err);
return cfg.site || {};
} catch {
return {};
}
}
// ---------------------- Load Config & Start Polling ----------------------
async function initMapPolling() {
try {
const site = await waitForConfig();
mapInterval = parseInt(site.map_interval, 10) || 0;
const site = await waitForConfig();
mapInterval = parseInt(site.map_interval, 10) || 0;
// ---- Check URL params ----
const params = new URLSearchParams(window.location.search);
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
const zoom = parseInt(params.get('zoom'), 10);
if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
map.setView([lat, lng], zoom);
window.configBoundsApplied = true;
setTimeout(() => map.invalidateSize(), 100);
}
else {
const topLeft = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
const bottomRight = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
if (topLeft.every(isFinite) && bottomRight.every(isFinite)) {
map.fitBounds([topLeft, bottomRight]);
window.configBoundsApplied = true;
setTimeout(() => map.invalidateSize(), 100);
}
}
const params = new URLSearchParams(window.location.search);
const lat = parseFloat(params.get('lat'));
const lng = parseFloat(params.get('lng'));
const zoom = parseInt(params.get('zoom'), 10);
if (mapInterval > 0) {
console.log(`Starting map polling every ${mapInterval}s`);
startPacketFetcher();
} else {
console.log("Map polling disabled (map_interval=0)");
}
} catch (err) {
console.error("Failed to load /api/config:", err);
if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
map.setView([lat, lng], zoom);
window.configBoundsApplied = true;
setTimeout(()=>map.invalidateSize(),100);
}
if (mapInterval > 0) startPacketFetcher();
}
initMapPolling();
// ---------------------- Load Nodes + Edges ----------------------
fetch('/api/nodes?days_active=3').then(r=>r.json()).then(data=>{
if(!data.nodes) return;
nodes = data.nodes.map(n=>({
key: n.node_id!==null?n.node_id:n.id,
id: n.id,
node_id: n.node_id,
lat: n.last_lat?n.last_lat/1e7:null,
long: n.last_long?n.last_long/1e7:null,
long_name: n.long_name||"",
short_name: n.short_name||"",
channel: n.channel||"",
hw_model: n.hw_model||"",
role: n.role||"",
firmware: n.firmware||"",
last_update: n.last_update||"",
isRouter: n.role? n.role.toLowerCase().includes("router"):false
}));
nodes.forEach(n=>{ nodeMap.set(n.key,n); if(n.channel) channelSet.add(n.channel); });
renderNodesOnMap();
createChannelFilters();
return fetch('/api/edges');
}).then(r=>r?r.json():null).then(data=>{
if(data && data.edges) edgesData=data.edges;
}).catch(console.error);
/* ------------ Load Nodes + Edges ------------ */
fetch('/api/nodes?days_active=3')
.then(r=>r.json())
.then(data=>{
nodes = (data.nodes||[]).map(n=>({
key: n.node_id ?? n.id,
node_id: n.node_id,
lat: n.last_lat ? n.last_lat/1e7 : null,
long: n.last_long ? n.last_long/1e7 : null,
long_name: n.long_name||"",
short_name: n.short_name||"",
channel: n.channel||"",
hw_model: n.hw_model||"",
role: n.role||"",
firmware: n.firmware||"",
last_update: n.last_update||"",
isRouter: (n.role||"").toLowerCase().includes("router")
}));
nodes.forEach(n=>{
nodeMap.set(n.key,n);
if(n.channel) channelSet.add(n.channel);
});
renderNodesOnMap();
createChannelFilters();
return fetch('/api/edges');
})
.then(r=>r.json())
.then(data=>{ edgesData=data.edges||[]; });
/* ------------ Render Nodes ------------ */
// ---------------------- Render Nodes ----------------------
function renderNodesOnMap(){
const bounds = L.latLngBounds();
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
const color = hashToColor(node.channel);
const opts = { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 };
const marker = L.circleMarker([node.lat,node.long],opts).addTo(map);
const radius = node.isRouter ? 9 : 7;
const marker = L.circleMarker([node.lat,node.long],{
radius, color:"white", fillColor:color, fillOpacity:1, weight:0.7
}).addTo(map);
marker.nodeId = node.key;
marker.originalColor = color;
markerById[node.key] = marker;
const popup = `<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>
${node.last_update? `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`:""}
${node.firmware? `<b>Firmware:</b> ${node.firmware}<br>`:""}`;
marker.on('click',()=>{ onNodeClick(node); marker.bindPopup(popup).openPopup(); setTimeout(()=>marker.closePopup(),3000); });
const popup = `
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
<b data-translate-lang="channel_label"></b> ${node.channel}<br>
<b data-translate-lang="model_label"></b> ${node.hw_model}<br>
<b data-translate-lang="role_label"></b> ${node.role}<br>
${
node.last_update
? `<b data-translate-lang="last_seen"></b> ${timeAgo(node.last_update)}<br>`
: ""
}
${
node.firmware
? `<b data-translate-lang="firmware"></b> ${node.firmware}<br>`
: ""
}
`;
marker.on('click', ()=>{
onNodeClick(node);
marker.bindPopup(popup).openPopup();
setTimeout(()=>applyTranslationsMap(),10);
});
bounds.extend(marker.getLatLng());
});
if(!window.configBoundsApplied && bounds.isValid()){
if(bounds.isValid()){
map.fitBounds(bounds);
setTimeout(()=>map.invalidateSize(),100);
}
}
// ---------------------- Render Edges ----------------------
/* ------------ Edges ------------ */
function onNodeClick(node){
selectedNodeId = node.key;
edgeLayer.clearLayers();
edgesData.forEach(edge=>{
if(edge.from!==node.key && edge.to!==node.key) return;
const f=nodeMap.get(edge.from), t=nodeMap.get(edge.to);
if(!f||!t||isInvalidCoord(f)||isInvalidCoord(t)) return;
const color=edge.type==="neighbor"?"gray":"orange";
const l=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
const line=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
if(edge.type==="traceroute"){
L.polylineDecorator(l,{patterns:[{offset:'100%',repeat:0,symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{stroke:true,color}})}]}).addTo(edgeLayer);
L.polylineDecorator(line,{
patterns:[{
offset:'100%', repeat:0,
symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{color}})
}]
}).addTo(edgeLayer);
}
});
}
map.on('click',e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; } });
// ---------------------- Packet Blinking ----------------------
map.on('click',e=>{
if(!e.originalEvent.target.classList.contains('leaflet-interactive')){
edgeLayer.clearLayers();
selectedNodeId=null;
}
});
/* ------------ Blinking ------------ */
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){ clearInterval(activeBlinks.get(marker)); marker.setStyle({fillColor:marker.originalColor}); if(marker.tooltip) map.removeLayer(marker.tooltip); }
let blinkCount=0;
const portName = portMap[portnum]||`Port ${portnum}`;
const tooltip = L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng()).addTo(map);
if(activeBlinks.has(marker)){
clearInterval(activeBlinks.get(marker));
marker.setStyle({fillColor:marker.originalColor});
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
const portLabel = portMap[portnum] || `Port ${portnum}`;
const tooltip = L.tooltip({
permanent:true, direction:'top',
offset:[0,-marker.options.radius-5],
className:'blinking-tooltip'
})
.setContent(`${longName} (${portLabel})`)
.setLatLng(marker.getLatLng())
.addTo(map);
marker.tooltip = tooltip;
const interval = setInterval(()=>{
if(map.hasLayer(marker)){ marker.setStyle({fillColor: blinkCount%2===0?'yellow':marker.originalColor}); marker.bringToFront(); }
let blinkCount=0;
const interval=setInterval(()=>{
if(!map.hasLayer(marker)) return;
marker.setStyle({fillColor: blinkCount%2===0 ? 'yellow' : marker.originalColor});
blinkCount++;
if(blinkCount>7){ clearInterval(interval); marker.setStyle({fillColor:marker.originalColor}); map.removeLayer(tooltip); activeBlinks.delete(marker); }
if(blinkCount>7){
clearInterval(interval);
marker.setStyle({fillColor:marker.originalColor});
map.removeLayer(tooltip);
activeBlinks.delete(marker);
}
},500);
activeBlinks.set(marker,interval);
}
// ---------------------- Channel Filters ----------------------
/* ------------ Channel Filters ------------ */
function createChannelFilters(){
const filterContainer = document.getElementById("filter-container");
const savedState = JSON.parse(localStorage.getItem("mapFilters") || "{}");
const saved = JSON.parse(localStorage.getItem("mapFilters") || "{}");
channelSet.forEach(channel=>{
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.className = "filter-checkbox";
checkbox.id = `filter-channel-${channel}`;
checkbox.checked = savedState[channel] !== false;
checkbox.addEventListener("change", saveFiltersToLocalStorage);
checkbox.addEventListener("change", updateNodeVisibility);
filterContainer.appendChild(checkbox);
const cb = document.createElement("input");
cb.type="checkbox";
cb.className="filter-checkbox";
cb.id=`filter-${channel}`;
cb.checked = saved[channel] !== false;
cb.addEventListener("change",saveFilters);
cb.addEventListener("change",updateNodeVisibility);
const label = document.createElement("label");
label.htmlFor = checkbox.id;
label.htmlFor = cb.id;
label.innerText = channel;
label.style.color = hashToColor(channel);
filterContainer.appendChild(cb);
filterContainer.appendChild(label);
});
const routerOnly = document.getElementById("filter-routers-only");
routerOnly.checked = savedState["routersOnly"] || false;
routerOnly.addEventListener("change", saveFiltersToLocalStorage);
routerOnly.addEventListener("change", updateNodeVisibility);
routerOnly.checked = saved["routersOnly"] || false;
routerOnly.addEventListener("change",saveFilters);
routerOnly.addEventListener("change",updateNodeVisibility);
updateNodeVisibility();
}
function saveFiltersToLocalStorage(){
function saveFilters(){
const state = {};
channelSet.forEach(ch => {
const el = document.getElementById(`filter-channel-${ch}`);
state[ch] = el.checked;
channelSet.forEach(ch=>{
state[ch] = document.getElementById(`filter-${ch}`).checked;
});
state["routersOnly"] = document.getElementById("filter-routers-only").checked;
localStorage.setItem("mapFilters", JSON.stringify(state));
localStorage.setItem("mapFilters",JSON.stringify(state));
}
function updateNodeVisibility(){
const showRoutersOnly = document.getElementById("filter-routers-only").checked;
const activeChannels = Array.from(channelSet).filter(ch=>document.getElementById(`filter-channel-${ch}`).checked);
const showRouters = document.getElementById("filter-routers-only").checked;
const activeChannels = [...channelSet].filter(ch =>
document.getElementById(`filter-${ch}`).checked
);
nodes.forEach(n=>{
const marker = markerById[n.key];
if(marker){
const visible = (!showRoutersOnly || n.isRouter) && activeChannels.includes(n.channel);
if(visible) map.addLayer(marker); else map.removeLayer(marker);
const show =
(!showRouters || n.isRouter) &&
activeChannels.includes(n.channel);
if(show) map.addLayer(marker);
else map.removeLayer(marker);
}
});
}
// ---------------------- Share / Reset ----------------------
function shareCurrentView() {
const center = map.getCenter();
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
/* ------------ Share + Reset ------------ */
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
navigator.clipboard.writeText(shareUrl).then(() => {
const button = document.getElementById('share-button');
const originalText = button.textContent;
button.textContent = '✓ Link Copied!';
button.style.backgroundColor = '#2196F3';
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '#4CAF50';
}, 2000);
}).catch(() => alert('Share this link:\n' + shareUrl));
function shareCurrentView() {
const c = map.getCenter();
const url = `${window.location.origin}/map?lat=${c.lat.toFixed(6)}&lng=${c.lng.toFixed(6)}&zoom=${map.getZoom()}`;
navigator.clipboard.writeText(url).then(()=>{
const b=document.getElementById('share-button');
const t=b.textContent;
b.textContent='✓';
setTimeout(()=>b.textContent=t,1500);
});
}
function resetFiltersToDefaults(){
document.getElementById("filter-routers-only").checked = false;
channelSet.forEach(ch=>document.getElementById(`filter-channel-${ch}`).checked = true);
saveFiltersToLocalStorage();
document.getElementById("filter-routers-only").checked=false;
channelSet.forEach(ch=>{
document.getElementById(`filter-${ch}`).checked=true;
});
saveFilters();
updateNodeVisibility();
}
/* ------------ INIT ------------ */
document.addEventListener("DOMContentLoaded", loadTranslationsMap);
</script>
{% endblock %}

View File

@@ -23,20 +23,29 @@
.channel { font-style: italic; color: #bbb; }
.channel a { font-style: normal; color: #999; }
@keyframes flash { 0% { background-color: #ffe066; } 100% { background-color: inherit; } }
.chat-packet.flash { animation: flash 3.5s ease-out; }
.replying-to { font-size: 0.8em; color: #aaa; margin-top: 2px; padding-left: 10px; }
.replying-to .reply-preview { color: #aaa; }
#weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; }
#total-count { margin-bottom: 10px; font-style: italic; color: #ccc; }
{% endblock %}
{% block body %}
<div class="container">
<div id="weekly-message">Loading weekly message...</div>
<div id="total-count">Total messages: 0</div>
<!-- ⭐ NET TITLE WITH ICON ⭐ -->
<div class="container px-2">
<h2 style="color:white; margin:0 0 10px 0;">
<span class="icon">💬</span>
<span data-translate-lang="net_title"></span>
</h2>
</div>
<!-- Weekly network message -->
<div id="weekly-message"></div>
<!-- Total message count -->
<div id="total-count">
<span data-translate-lang="total_messages">Total messages:</span>
<span id="total-count-value">0</span>
</div>
<div id="chat-container">
<div class="container" id="chat-log"></div>
@@ -45,140 +54,180 @@
<script>
document.addEventListener("DOMContentLoaded", async () => {
const chatContainer = document.querySelector("#chat-log");
const totalCountEl = document.querySelector("#total-count");
const weeklyMessageEl = document.querySelector("#weekly-message");
if (!chatContainer || !totalCountEl || !weeklyMessageEl) {
console.error("Required elements not found");
const totalCountValueEl = document.querySelector("#total-count-value");
if (!chatContainer || !weeklyMessageEl || !totalCountValueEl) {
console.error("Required elements missing");
return;
}
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatTranslations = {};
let netTranslations = {};
let netTag = "";
function updateTotalCount() {
totalCountEl.textContent = `Total messages: ${renderedPacketIds.size}`;
}
/* -----------------------------------
Escape HTML safely
----------------------------------- */
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text ?? "";
return div.innerHTML;
}
function applyTranslations(translations, root = document) {
/* -----------------------------------
Apply translations
----------------------------------- */
function applyTranslations(trans, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
if (trans[key]) el.textContent = trans[key];
});
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
const key = el.dataset.translateLangTitle;
if (translations[key]) el.title = translations[key];
if (trans[key]) el.title = trans[key];
});
}
/* -----------------------------------
Update count
----------------------------------- */
function updateTotalCount() {
totalCountValueEl.textContent = renderedPacketIds.size;
}
/* -----------------------------------
Render single packet
----------------------------------- */
function renderPacket(packet) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
packetMap.set(packet.id, packet);
const date = new Date(packet.import_time_us / 1000);
const formattedTime = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true });
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2, "0")}/${date.getDate().toString().padStart(2, "0")}/${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
let replyHtml = "";
if (packet.reply_id) {
const parent = packetMap.get(packet.reply_id);
if (parent) {
replyHtml = `<div class="replying-to">
<div class="reply-preview">
<i data-translate-lang="replying_to"></i>
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
${escapeHtml(parent.payload || "")}
</div>
</div>`;
} else {
replyHtml = `<div class="replying-to">
<i data-translate-lang="replying_to"></i>
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>`;
}
}
const timeStr = date.toLocaleTimeString([], {
hour: "numeric",
minute: "2-digit",
second: "2-digit",
hour12: true
});
const dateStr =
`${String(date.getMonth()+1).padStart(2,"0")}/`+
`${String(date.getDate()).padStart(2,"0")}/`+
date.getFullYear();
const timestamp = `${timeStr} - ${dateStr}`;
const fromName =
(packet.long_name || "").trim() ||
`${netTranslations.node_fallback} ${packet.from_node_id}`;
const div = document.createElement("div");
div.className = "row chat-packet";
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time_us}">${formattedTimestamp}</span>
<span class="col-2 timestamp" title="${packet.import_time_us}">
${timestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" data-translate-lang-title="view_packet_details">✉️</a>
<a href="/packet/${packet.id}"
data-translate-lang-title="view_packet_details">✉️</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/packet_list/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
${escapeHtml(fromName)}
</a>
</span>
<span class="col-5 message">${escapeHtml(packet.payload)}${replyHtml}</span>
<span class="col-5 message">
${escapeHtml(packet.payload).replace(/\n/g,"<br>")}
</span>
`;
chatContainer.prepend(div);
applyTranslations(chatTranslations, div);
applyTranslations(netTranslations, div);
updateTotalCount();
}
/* -----------------------------------
Sort descending by time
----------------------------------- */
function renderPacketsEnsureDescending(packets) {
if (!Array.isArray(packets) || packets.length === 0) return;
const sortedDesc = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
for (let i = sortedDesc.length - 1; i >= 0; i--) renderPacket(sortedDesc[i]);
if (!packets || !packets.length) return;
const sorted = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
for (let i = sorted.length - 1; i >= 0; i--) {
renderPacket(sorted[i]);
}
}
/* -----------------------------------
Fetch initial net-tagged packets
----------------------------------- */
async function fetchInitialPackets(tag) {
if (!tag) {
console.warn("No net_tag defined, skipping packet fetch.");
return;
}
if (!tag) return;
try {
console.log("Fetching packets for netTag:", tag);
const sixDaysAgoMs = Date.now() - (6 * 24 * 60 * 60 * 1000);
const sixDaysAgoMs = Date.now() - 6*24*60*60*1000;
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
const resp = await fetch(`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`);
const url =
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`;
const resp = await fetch(url);
const data = await resp.json();
console.log("Packets received:", data?.packets?.length);
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
if (data?.packets?.length)
renderPacketsEnsureDescending(data.packets);
} catch (err) {
console.error("Initial fetch error:", err);
}
}
/* -----------------------------------
Load translations from section=net
----------------------------------- */
async function loadTranslations(cfg) {
try {
const langCode = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatTranslations = await res.json();
applyTranslations(chatTranslations, document);
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=net`);
netTranslations = await res.json();
applyTranslations(netTranslations, document);
} catch (err) {
console.error("Chat translation load failed:", err);
console.error("Failed loading translations", err);
}
}
// --- MAIN LOGIC ---
/* -----------------------------------
MAIN
----------------------------------- */
try {
const cfg = await window._siteConfigPromise; // ✅ Already fetched by base.html
const cfg = await window._siteConfigPromise;
const site = cfg?.site || {};
// Populate from config
netTag = site.net_tag || "";
weeklyMessageEl.textContent = site.weekly_net_message || "Weekly message not set.";
weeklyMessageEl.textContent = site.weekly_net_message || "";
await loadTranslations(cfg);
await fetchInitialPackets(netTag);
} catch (err) {
console.error("Initialization failed:", err);
weeklyMessageEl.textContent = "Failed to load site config.";
weeklyMessageEl.textContent =
netTranslations.failed_to_load_site_config ||
"Failed to load site config.";
}
});
</script>
{% endblock %}

View File

@@ -16,7 +16,7 @@
z-index: 1;
}
/* --- Node Info (3-column compact grid) --- */
/* --- Node Info --- */
.node-info {
background-color: #1f2226;
border: 1px solid #3a3f44;
@@ -32,10 +32,7 @@
grid-row-gap: 6px;
}
.node-info div {
padding: 2px 0;
}
.node-info div { padding: 2px 0; }
.node-info strong {
color: #9fd4ff;
font-weight: 600;
@@ -60,7 +57,6 @@
font-weight: bold;
border-bottom: 1px solid #333;
font-size: 1rem;
letter-spacing: 0.5px;
}
.chart-actions button {
background: rgba(255,255,255,0.05);
@@ -70,7 +66,6 @@
font-size: 0.8rem;
padding: 2px 6px;
cursor: pointer;
transition: background 0.2s;
}
.chart-actions button:hover {
color: #fff;
@@ -103,19 +98,16 @@
font-size: 0.75rem;
color: #fff;
}
.port-1 { background-color: #007bff; }
.port-3 { background-color: #28a745; }
.port-4 { background-color: #ffc107; color:#000; }
.port-5 { background-color: #dc3545; }
.port-6 { background-color: #20c997; }
.port-67 { background-color: #17a2b8; }
.port-70 { background-color: #ff7043; }
.port-71 { background-color: #ff66cc; }
.port-0 { background-color: #6c757d; }
.to-mqtt { font-style: italic; color: #aaa; }
.payload-row { display: none; background-color: #1b1e22; }
.payload-cell { padding: 8px 12px; font-family: monospace; white-space: pre-wrap; color: #b0bec5; border-top: none; }
.payload-cell {
padding: 8px 12px;
font-family: monospace;
white-space: pre-wrap;
color: #b0bec5;
}
.packet-table tr.expanded + .payload-row { display: table-row; }
.toggle-btn { cursor: pointer; color: #aaa; margin-right: 6px; }
.toggle-btn:hover { color: #fff; }
@@ -131,98 +123,101 @@
width:90%; height:85%; padding:10px;
}
/* Link next to port tag */
/* Inline link */
.inline-link {
margin-left: 6px;
font-weight: bold;
text-decoration: none;
color: #9fd4ff;
}
.inline-link:hover {
color: #c7e6ff;
}
.inline-link:hover { color: #c7e6ff; }
{% endblock %}
{% block body %}
<div class="container">
<h5 class="mb-3">📡 Specifications: <span id="nodeLabel"></span></h5>
<h5 class="mb-3">
📡 <span data-translate-lang="specifications">Specifications:</span>
<span id="nodeLabel"></span>
</h5>
<!-- Node Info (3 column grid) -->
<!-- Node Info -->
<div id="node-info" class="node-info">
<div><strong>Node ID:</strong> <span id="info-node-id"></span></div>
<div><strong>Long Name:</strong> <span id="info-long-name"></span></div>
<div><strong>Short Name:</strong> <span id="info-short-name"></span></div>
<div><strong data-translate-lang="node_id">Node ID:</strong> <span id="info-node-id"></span></div>
<div><strong data-translate-lang="long_name">Long Name:</strong> <span id="info-long-name"></span></div>
<div><strong data-translate-lang="short_name">Short Name:</strong> <span id="info-short-name"></span></div>
<div><strong>Hardware Model:</strong> <span id="info-hw-model"></span></div>
<div><strong>Firmware:</strong> <span id="info-firmware"></span></div>
<div><strong>Role:</strong> <span id="info-role"></span></div>
<div><strong data-translate-lang="hw_model">Hardware Model:</strong> <span id="info-hw-model"></span></div>
<div><strong data-translate-lang="firmware">Firmware:</strong> <span id="info-firmware"></span></div>
<div><strong data-translate-lang="role">Role:</strong> <span id="info-role"></span></div>
<div><strong>Channel:</strong> <span id="info-channel"></span></div>
<div><strong>Latitude:</strong> <span id="info-lat"></span></div>
<div><strong>Longitude:</strong> <span id="info-lon"></span></div>
<div><strong data-translate-lang="channel">Channel:</strong> <span id="info-channel"></span></div>
<div><strong data-translate-lang="latitude">Latitude:</strong> <span id="info-lat"></span></div>
<div><strong data-translate-lang="longitude">Longitude:</strong> <span id="info-lon"></span></div>
<div><strong>Last Update:</strong> <span id="info-last-update"></span></div>
<div><strong data-translate-lang="last_update">Last Update:</strong> <span id="info-last-update"></span></div>
</div>
<!-- Map -->
<div id="map" style="min-height:400px;"></div>
<div id="map"></div>
<!-- Charts -->
<!-- Battery Chart -->
<div id="battery_voltage_container" class="chart-container">
<div class="chart-header">
🔋 Battery & Voltage
🔋 <span data-translate-lang="battery_voltage">Battery & Voltage</span>
<div class="chart-actions">
<button onclick="expandChart('battery_voltage')">Expand</button>
<button onclick="exportCSV('battery_voltage')">Export CSV</button>
<button onclick="expandChart('battery_voltage')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('battery_voltage')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_battery_voltage" style="height:260px;"></div>
</div>
<!-- Air/Channel -->
<div id="air_channel_container" class="chart-container">
<div class="chart-header">
📶 Air & Channel Utilization
📶 <span data-translate-lang="air_channel">Air & Channel Utilization</span>
<div class="chart-actions">
<button onclick="expandChart('air_channel')">Expand</button>
<button onclick="exportCSV('air_channel')">Export CSV</button>
<button onclick="expandChart('air_channel')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('air_channel')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_air_channel" style="height:260px;"></div>
</div>
<!-- Env Metrics -->
<div id="env_chart_container" class="chart-container" style="display:none;">
<div class="chart-header">
🌡️ Environment Metrics
🌡️ <span data-translate-lang="environment">Environment Metrics</span>
<div class="chart-actions">
<button onclick="expandChart('environment')">Expand</button>
<button onclick="exportCSV('environment')">Export CSV</button>
<button onclick="expandChart('environment')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('environment')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_environment" style="height:260px;"></div>
</div>
<!-- Neighbors chart -->
<!-- Neighbor chart -->
<div id="neighbor_chart_container" class="chart-container" style="display:none;">
<div class="chart-header">
📡 Neighbors (Signal-to-Noise)
📡 <span data-translate-lang="neighbors_chart">Neighbors (Signal-to-Noise)</span>
<div class="chart-actions">
<button onclick="expandChart('neighbors')">Expand</button>
<button onclick="exportCSV('neighbors')">Export CSV</button>
<button onclick="expandChart('neighbors')" data-translate-lang="expand">Expand</button>
<button onclick="exportCSV('neighbors')" data-translate-lang="export_csv">Export CSV</button>
</div>
</div>
<div id="chart_neighbors" style="height:260px;"></div>
</div>
<!-- Table -->
<!-- Packets -->
<table class="packet-table">
<thead>
<tr>
<th>Time</th>
<th>Packet ID</th>
<th>From</th>
<th>To</th>
<th>Port</th>
<th data-translate-lang="time">Time</th>
<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="port">Port</th>
</tr>
</thead>
<tbody id="packet_list"></tbody>
@@ -241,7 +236,46 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script>
/* ======================================================
NODE PAGE TRANSLATION (isolated from base)
====================================================== */
let nodeTranslations = {};
async function loadTranslationsNode() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=node`);
nodeTranslations = await res.json();
applyTranslationsNode(nodeTranslations);
} catch (err) {
console.error("Node translation load failed", err);
}
}
function applyTranslationsNode(dict, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (dict[key]) {
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = dict[key];
} else {
el.textContent = dict[key];
}
}
});
}
/* ======================================================
EXISTING NODE LOGIC (UNCHANGED)
====================================================== */
let nodeMap = {}, nodePositions = {}, map, markers = {};
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
let allNodes = [];
@@ -270,7 +304,8 @@ async function loadNodes(){
nodePositions[n.node_id] = [n.last_lat / 1e7, n.last_long / 1e7];
}
nodeMap[4294967295] = "All";
nodeMap[4294967295] = nodeTranslations.all_broadcast || "All";
document.getElementById("nodeLabel").textContent = nodeMap[fromNodeId] || fromNodeId;
} catch (err) {
@@ -319,27 +354,78 @@ async function loadNodeInfo(){
}
}
// --- Helpers ---
function nodeLink(id){
if (id === 4294967295) return `<span class="to-mqtt">All</span>`;
if (id === 1) return `<span class="to-mqtt">Direct to MQTT</span>`;
return `<a href="/node/${id}" style="text-decoration:underline; color:inherit;">${nodeMap[id] || id}</a>`;
function nodeLink(id) {
// Broadcast to everyone (ALL)
if (id === 4294967295) {
return `<span class="to-mqtt" data-translate-lang="all_broadcast">
${nodeTranslations.all_broadcast || "All"}
</span>`;
}
// Direct to MQTT broker
if (id === 1) {
return `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
${nodeTranslations.direct_to_mqtt || "Direct to MQTT"}
</span>`;
}
// Normal node → clickable link
return `<a href="/node/${id}"
style="text-decoration:underline; color:inherit;">
${nodeMap[id] || id}
</a>`;
}
function portLabel(p){
const names = {
0: "UNKNOWN APP",
1: "Text",
3: "Position",
4: "Node Info",
5: "Routing",
6: "Admin",
function portLabel(p) {
const PORT_COLOR_MAP = {
0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
68: "#fd7e14",
69: "#6f42c1",
70: "#ff4444",
71: "#ff66cc",
72: "#00cc99",
73: "#9999ff",
74: "#cc00cc",
75: "#ffbb33",
76: "#00bcd4",
77: "#8bc34a",
78: "#795548"
};
const PORT_LABEL_MAP = {
0: "UNKNOWN",
1: "Text",
3: "Position",
4: "Node Info",
5: "Routing",
6: "Admin",
65: "Store & Forward",
67: "Telemetry",
70: "Traceroute",
71: "Neighbor"
};
const label = names[p] || "Unknown";
return `<span class="port-tag port-${p}">${label}</span>`;
const color = PORT_COLOR_MAP[p] || "#6c757d";
const label = PORT_LABEL_MAP[p] || `Port ${p}`;
return `
<span class="port-tag"
style="background-color:${color}"
data-no-translate>
${label}
</span>
<span class="text-secondary">(${p})</span>
`;
}
function formatLocalTime(us){
return new Date(us / 1000).toLocaleString([], {
month: "2-digit",
@@ -868,11 +954,13 @@ document.addEventListener("click", e => {
// --- Init ---
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsNode(); // <-- REQUIRED
requestAnimationFrame(async () => {
await loadNodes();
await loadNodeInfo();
await loadTrack(); // builds track & decides whether to show map
await loadPackets(); // table + neighbor overlay (if map exists)
await loadTrack();
await loadPackets();
await loadTelemetryCharts();
await loadNeighborChart();
ensureMapVisible();
@@ -881,5 +969,6 @@ document.addEventListener("DOMContentLoaded", async () => {
window.addEventListener("focus", ensureMapVisible);
});
});
</script>
{% endblock %}

View File

@@ -1,12 +1,25 @@
{% extends "base.html" %}
{% block css %}
<style>
table {
width: 80%;
border-collapse: collapse;
margin: 1em auto;
}
/* Ensure table is centered visually */
#node-list {
display: flex;
justify-content: center;
}
#node-list table {
margin-left: auto;
margin-right: auto;
}
th, td {
padding: 10px;
border: 1px solid #333;
@@ -114,49 +127,112 @@ select, .export-btn, .search-box, .clear-btn {
background-color: #ff6b6b;
color: white;
}
</style>
{% endblock %}
{% block body %}
<div class="filter-container">
<input type="text" id="search-box" class="search-box" placeholder="Search by name or ID..." />
<select id="role-filter"><option value="">All Roles</option></select>
<select id="channel-filter"><option value="">All Channels</option></select>
<select id="hw-filter"><option value="">All HW Models</option></select>
<select id="firmware-filter"><option value="">All Firmware</option></select>
<input
type="text"
id="search-box"
class="search-box"
data-translate-lang="search_placeholder"
placeholder="Search by name or ID..."
/>
<button class="favorites-btn" id="favorites-btn">⭐ Show Favorites</button>
<button class="export-btn" id="export-btn">Export CSV</button>
<button class="clear-btn" id="clear-btn">Clear Filters</button>
<select id="role-filter">
<option value="" data-translate-lang="all_roles">All Roles</option>
</select>
<select id="channel-filter">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
<select id="hw-filter">
<option value="" data-translate-lang="all_hw">All HW Models</option>
</select>
<select id="firmware-filter">
<option value="" data-translate-lang="all_firmware">All Firmware</option>
</select>
<button class="favorites-btn" id="favorites-btn" data-translate-lang="show_favorites">
⭐ Show Favorites
</button>
<button class="export-btn" id="export-btn" data-translate-lang="export_csv">
Export CSV
</button>
<button class="clear-btn" id="clear-btn" data-translate-lang="clear_filters">
Clear Filters
</button>
</div>
<div class="count-container">
Showing <span id="node-count">0</span> nodes
<span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span>
</div>
<div id="node-list">
<table>
<thead>
<tr>
<th>Short <span class="sort-icon"></span></th>
<th>Long Name <span class="sort-icon"></span></th>
<th>HW Model <span class="sort-icon"></span></th>
<th>Firmware <span class="sort-icon"></span></th>
<th>Role <span class="sort-icon"></span></th>
<th>Last Latitude <span class="sort-icon"></span></th>
<th>Last Longitude <span class="sort-icon"></span></th>
<th>Channel <span class="sort-icon"></span></th>
<th>Last Seen <span class="sort-icon"></span></th>
<th> </th>
<th data-translate-lang="short_name">Short <span class="sort-icon"></span></th>
<th data-translate-lang="long_name">Long Name <span class="sort-icon"></span></th>
<th data-translate-lang="hw_model">HW Model <span class="sort-icon"></span></th>
<th data-translate-lang="firmware">Firmware <span class="sort-icon"></span></th>
<th data-translate-lang="role">Role <span class="sort-icon"></span></th>
<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="last_seen">Last Seen <span class="sort-icon"></span></th>
<th data-translate-lang="favorite"></th>
</tr>
</thead>
<tbody id="node-table-body">
<tr><td colspan="10" style="text-align:center; color:white;">Loading nodes...</td></tr>
<tr>
<td colspan="10" style="text-align:center; color:white;" data-translate-lang="loading_nodes">
Loading nodes...
</td>
</tr>
</tbody>
</table>
</div>
<script>
// =====================================================
// TRANSLATIONS
// =====================================================
let nodelistTranslations = {};
function applyTranslationsNodelist() {
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (nodelistTranslations[key]) {
if (el.tagName === "INPUT" && el.placeholder) {
el.placeholder = nodelistTranslations[key];
} else {
el.textContent = nodelistTranslations[key];
}
}
});
}
async function loadTranslationsNodelist() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=nodelist`);
nodelistTranslations = await res.json();
applyTranslationsNodelist();
} catch (err) {
console.error("Failed to load nodelist translations:", err);
}
}
// =====================================================
// GLOBALS
// =====================================================
@@ -171,55 +247,55 @@ const keyMap = [
"last_lat","last_long","channel","last_seen_us"
];
// =====================================================
// FAVORITES SYSTEM (localStorage)
// =====================================================
// FAVORITES
function getFavorites() {
const favorites = localStorage.getItem('nodelist_favorites');
return favorites ? JSON.parse(favorites) : [];
}
function saveFavorites(favs) {
localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
}
function toggleFavorite(nodeId) {
let favorites = getFavorites();
const index = favorites.indexOf(nodeId);
if (index > -1) {
favorites.splice(index, 1);
} else {
favorites.push(nodeId);
}
saveFavorites(favorites);
// Note: applyFilters() will be called by the event listener after this function returns
let favs = getFavorites();
const idx = favs.indexOf(nodeId);
if (idx >= 0) favs.splice(idx, 1);
else favs.push(nodeId);
saveFavorites(favs);
}
function isFavorite(nodeId) {
return getFavorites().includes(nodeId);
}
// =====================================================
// "TIME AGO" FORMATTER
// =====================================================
// TIME AGO (translated)
function timeAgo(usTimestamp) {
if (!usTimestamp) return "N/A";
const ms = usTimestamp / 1000;
const diff = Date.now() - ms;
if (diff < 60000) return "just now";
// < 1 minute
if (diff < 60000) return nodelistTranslations.time_just_now || "just now";
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins} min ago`;
if (mins < 60)
return `${mins} ${nodelistTranslations.time_min_ago || "min ago"}`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs} hr ago`;
if (hrs < 24)
return `${hrs} ${nodelistTranslations.time_hr_ago || "hr ago"}`;
const days = Math.floor(hrs / 24);
return `${days} day${days > 1 ? "s" : ""} ago`;
if (days === 1)
return `1 ${nodelistTranslations.time_day_ago || "day ago"}`;
return `${days} ${nodelistTranslations.time_days_ago || "days ago"}`;
}
// =====================================================
// DOM LOADED: FETCH NODES
// DOM LOADED
// =====================================================
document.addEventListener("DOMContentLoaded", async function() {
await loadTranslationsNodelist();
const tbody = document.getElementById("node-table-body");
const roleFilter = document.getElementById("role-filter");
const channelFilter = document.getElementById("channel-filter");
@@ -236,13 +312,17 @@ document.addEventListener("DOMContentLoaded", async function() {
if (!res.ok) throw new Error("Failed to fetch nodes");
const data = await res.json();
allNodes = data.nodes;
allNodes = data.nodes.map(n => ({
...n,
firmware: n.firmware || n.firmware_version || ""
}));
populateFilters(allNodes);
renderTable(allNodes);
updateSortIcons();
} catch (err) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color:red;">Error loading nodes: ${err.message}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color:red;">${nodelistTranslations.error_loading_nodes || "Error loading nodes"}</td></tr>`;
}
roleFilter.addEventListener("change", applyFilters);
@@ -254,7 +334,6 @@ document.addEventListener("DOMContentLoaded", async function() {
clearBtn.addEventListener("click", clearFilters);
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
// STAR CLICK HANDLER
tbody.addEventListener("click", e => {
if (e.target.classList.contains('favorite-star')) {
const nodeId = parseInt(e.target.dataset.nodeId);
@@ -268,12 +347,9 @@ document.addEventListener("DOMContentLoaded", async function() {
e.target.textContent = "★";
}
toggleFavorite(nodeId);
// Reapply filters after toggling favorite
applyFilters();
}
});
// SORTING
headers.forEach((th, index) => {
th.addEventListener("click", () => {
let key = keyMap[index];
@@ -283,9 +359,6 @@ document.addEventListener("DOMContentLoaded", async function() {
});
});
// =====================================================
// FILTER POPULATION
// =====================================================
function populateFilters(nodes) {
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
@@ -311,57 +384,50 @@ document.addEventListener("DOMContentLoaded", async function() {
});
}
// =====================================================
// FAVORITES FILTER
// =====================================================
function toggleFavoritesFilter() {
showOnlyFavorites = !showOnlyFavorites;
favoritesBtn.textContent = showOnlyFavorites ? "⭐ Show All" : "⭐ Show Favorites";
favoritesBtn.textContent = showOnlyFavorites
? (nodelistTranslations.show_all || "⭐ Show All")
: (nodelistTranslations.show_favorites || "⭐ Show Favorites");
favoritesBtn.classList.toggle("active", showOnlyFavorites);
applyFilters();
}
// =====================================================
// APPLY FILTERS + SORT
// =====================================================
function applyFilters() {
const searchTerm = searchBox.value.trim().toLowerCase();
let filtered = allNodes.filter(node => {
const roleMatch = !roleFilter.value || node.role === roleFilter.value;
const channelMatch = !channelFilter.value || node.channel === channelFilter.value;
const hwMatch = !hwFilter.value || node.hw_model === hwFilter.value;
const firmwareMatch = !firmwareFilter.value || node.firmware === firmwareFilter.value;
let filtered = allNodes.filter(n => {
const roleMatch = !roleFilter.value || n.role === roleFilter.value;
const channelMatch = !channelFilter.value || n.channel === channelFilter.value;
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
let searchMatch = true;
if (searchTerm) {
searchMatch =
(node.long_name && node.long_name.toLowerCase().includes(searchTerm)) ||
(node.short_name && node.short_name.toLowerCase().includes(searchTerm)) ||
(node.node_id != null && String(node.node_id).toLowerCase().includes(searchTerm)) ||
(node.id != null && String(node.id).toLowerCase().includes(searchTerm));
}
const searchMatch =
!searchTerm ||
(n.long_name && n.long_name.toLowerCase().includes(searchTerm)) ||
(n.short_name && n.short_name.toLowerCase().includes(searchTerm)) ||
n.node_id.toString().includes(searchTerm);
const favMatch = !showOnlyFavorites || isFavorite(node.node_id);
const favMatch = !showOnlyFavorites || isFavorite(n.node_id);
return roleMatch && channelMatch && hwMatch && firmwareMatch && searchMatch && favMatch;
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
});
filtered = sortNodes(filtered, sortColumn, sortAsc);
renderTable(filtered);
updateSortIcons();
}
// =====================================================
// RENDER TABLE
// =====================================================
function renderTable(nodes) {
tbody.innerHTML = "";
if (!nodes.length) {
tbody.innerHTML = `<tr><td colspan="10" style="text-align:center; color:white;">No nodes found</td></tr>`;
tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:white;">
${nodelistTranslations.no_nodes_found || "No nodes found"}
</td>
</tr>`;
countSpan.textContent = 0;
return;
}
@@ -382,7 +448,9 @@ document.addEventListener("DOMContentLoaded", async function() {
<td>${node.channel || "N/A"}</td>
<td>${timeAgo(node.last_seen_us)}</td>
<td style="text-align:center;">
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">${star}</span>
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
${star}
</span>
</td>
`;
tbody.appendChild(row);
@@ -391,9 +459,6 @@ document.addEventListener("DOMContentLoaded", async function() {
countSpan.textContent = nodes.length;
}
// =====================================================
// CLEAR FILTERS
// =====================================================
function clearFilters() {
roleFilter.value = "";
channelFilter.value = "";
@@ -403,24 +468,26 @@ document.addEventListener("DOMContentLoaded", async function() {
sortColumn = "short_name";
sortAsc = true;
showOnlyFavorites = false;
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.textContent = nodelistTranslations.show_favorites || "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
renderTable(allNodes);
updateSortIcons();
}
// =====================================================
// EXPORT CSV
// =====================================================
function exportToCSV() {
const rows = [];
const headerList = Array.from(headers).map(h => `"${h.innerText.replace(/▲|▼/g,'')}"`);
const headerList = Array.from(headers).map(h =>
`"${h.innerText.replace(/▲|▼/g,'')}"`
);
rows.push(headerList.join(","));
const trs = tbody.querySelectorAll("tr");
trs.forEach(tr => {
const cells = Array.from(tr.children).map(td => `"${td.innerText.replace(/"/g,'""')}"`);
const cells = Array.from(tr.children).map(td =>
`"${td.innerText.replace(/"/g,'""')}"`
);
rows.push(cells.join(","));
});
@@ -431,37 +498,26 @@ document.addEventListener("DOMContentLoaded", async function() {
a.click();
}
// =====================================================
// SORT NODES
// =====================================================
function sortNodes(nodes, key, asc) {
return [...nodes].sort((a, b) => {
let A = a[key];
let B = b[key];
function sortNodes(nodes, key, asc) {
return [...nodes].sort((a, b) => {
let A = a[key];
let B = b[key];
if (key === "last_seen_us") {
A = A || 0;
B = B || 0;
}
if (A < B) return asc ? -1 : 1;
if (A > B) return asc ? 1 : -1;
return 0;
});
}
// Normalize null/undefined/empty to a sortable string
A = (A ?? "").toString().toLowerCase();
B = (B ?? "").toString().toLowerCase();
// Force "n/a" to sort last
if (A === "" || A === "n/a") A = "~";
if (B === "" || B === "n/a") B = "~";
if (A < B) return asc ? -1 : 1;
if (A > B) return asc ? 1 : -1;
return 0;
});
}
// =====================================================
// SORT ICONS
// =====================================================
function updateSortIcons() {
headers.forEach((th, i) => {
const span = th.querySelector(".sort-icon");
if (!span) return;
span.textContent = keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
span.textContent =
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
});
}
});

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Packet Details{%endblock%}
{% block title %}Packet Details{% endblock %}
{% block css %}
{{ super() }}
@@ -48,7 +48,7 @@
display: none;
}
/* --- SOURCE MARKER (slightly bigger) --- */
/* --- SOURCE MARKER --- */
.source-marker {
width: 24px;
height: 24px;
@@ -97,26 +97,27 @@
{% block body %}
<div class="container mt-4 mb-5 packet-container">
<div id="loading">Loading packet information...</div>
<div id="loading" data-translate-lang="loading">Loading packet information...</div>
<div id="packet-card" class="packet-card d-none"></div>
<div id="map"></div>
<div id="seen-container" class="mt-4 d-none">
<h5 style="color:#ccc; margin:15px 0 10px 0;">
📡 Seen By <span id="seen-count" style="color:#4da6ff;"></span>
📡 <span data-translate-lang="seen_by">Seen By</span>
<span id="seen-count" style="color:#4da6ff;"></span>
</h5>
<div class="table-responsive">
<table class="table table-dark table-sm seen-table">
<thead>
<tr>
<th>Gateway</th>
<th>RSSI</th>
<th>SNR</th>
<th>Hop</th>
<th>Channel</th>
<th>Time</th>
<th data-translate-lang="gateway">Gateway</th>
<th data-translate-lang="rssi">RSSI</th>
<th data-translate-lang="snr">SNR</th>
<th data-translate-lang="hops">Hops</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="time">Time</th>
</tr>
</thead>
<tbody id="seen-table-body"></tbody>
@@ -126,8 +127,39 @@
</div>
<script>
/* ======================================================
PACKET PAGE TRANSLATION
====================================================== */
let packetTranslations = {};
async function loadTranslationsPacket() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=packet`);
packetTranslations = await res.json();
applyTranslationsPacket(packetTranslations);
} catch (err) {
console.error("Packet translations failed:", err);
}
}
function applyTranslationsPacket(dict, root = document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (dict[key]) el.textContent = dict[key];
});
}
/* ======================================================
PACKET PAGE MAIN
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsPacket(); // <-- IMPORTANT
const packetCard = document.getElementById("packet-card");
const loading = document.getElementById("loading");
const mapDiv = document.getElementById("map");
@@ -140,12 +172,12 @@ document.addEventListener("DOMContentLoaded", async () => {
----------------------------------------------*/
const match = window.location.pathname.match(/\/packet\/(\d+)/);
if (!match) {
loading.textContent = "Invalid packet URL";
loading.textContent = packetTranslations.invalid_url || "Invalid packet URL";
return;
}
const packetId = match[1];
/* PORT NAME MAP */
/* PORT LABELS (NOT TRANSLATED) */
const PORT_NAMES = {
0:"UNKNOWN APP",
1:"Text",
@@ -164,28 +196,31 @@ document.addEventListener("DOMContentLoaded", async () => {
const packetRes = await fetch(`/api/packets?packet_id=${packetId}`);
const packetData = await packetRes.json();
if (!packetData.packets.length) {
loading.textContent = "Packet not found.";
loading.textContent = packetTranslations.not_found || "Packet not found.";
return;
}
const p = packetData.packets[0];
/* ---------------------------------------------
Fetch all nodes
Load nodes for names & positions
----------------------------------------------*/
const nodesRes = await fetch("/api/nodes");
const nodesData = await nodesRes.json();
const nodeLookup = {};
(nodesData.nodes || []).forEach(n => nodeLookup[n.node_id] = n);
const fromNodeObj = nodeLookup[p.from_node_id];
const toNodeObj = nodeLookup[p.to_node_id];
const fromNodeObj = nodeLookup[p.from_node_id];
const toNodeObj = nodeLookup[p.to_node_id];
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
const toNodeLabel =
p.to_node_id == 4294967295 ? "All" : (toNodeObj?.long_name || p.to_node_id);
p.to_node_id == 4294967295
? (packetTranslations.all_broadcast || "All")
: (toNodeObj?.long_name || p.to_node_id);
/* ---------------------------------------------
Parse payload for lat/lon if this *packet* is a position packet
Parse payload for lat/lon
----------------------------------------------*/
let lat = null, lon = null;
const parsed = {};
@@ -195,14 +230,14 @@ document.addEventListener("DOMContentLoaded", async () => {
const [k, v] = line.split(":").map(x=>x.trim());
if (k && v !== undefined) {
parsed[k] = v;
if (k === "latitude_i") lat = Number(v) / 1e7;
if (k === "latitude_i") lat = Number(v) / 1e7;
if (k === "longitude_i") lon = Number(v) / 1e7;
}
});
}
/* ---------------------------------------------
Render packet header & details
Render card
----------------------------------------------*/
const time = p.import_time_us
? new Date(p.import_time_us / 1000).toLocaleString()
@@ -216,42 +251,47 @@ document.addEventListener("DOMContentLoaded", async () => {
packetCard.innerHTML = `
<div class="card-header">
<span>Packet ID: <i>${p.id}</i></span>
<span>
<span data-translate-lang="packet_id_label">${packetTranslations.packet_id_label || "Packet ID:"}</span>
<i>${p.id}</i>
</span>
<small>${time}</small>
</div>
<div class="card-body">
<dl>
<dt>From Node:</dt>
<dt data-translate-lang="from_node">${packetTranslations.from_node || "From Node"}:</dt>
<dd><a href="/node/${p.from_node_id}">${fromNodeLabel}</a></dd>
<dt>To Node:</dt>
<dt data-translate-lang="to_node">${packetTranslations.to_node || "To Node"}:</dt>
<dd>${
p.to_node_id === 4294967295
? `<i>All</i>`
? `<i data-translate-lang="all_broadcast">${packetTranslations.all_broadcast || "All"}</i>`
: p.to_node_id === 1
? `<i>Direct to MQTT</i>`
? `<i data-translate-lang="direct_to_mqtt">${packetTranslations.direct_to_mqtt || "Direct to MQTT"}</i>`
: `<a href="/node/${p.to_node_id}">${toNodeLabel}</a>`
}</dd>
<dt data-translate-lang="channel">${packetTranslations.channel || "Channel"}:</dt>
<dd>${p.channel ?? "—"}</dd>
<dt>Channel:</dt><dd>${p.channel ?? "—"}</dd>
<dt>Port:</dt>
<dt data-translate-lang="port">${packetTranslations.port || "Port"}:</dt>
<dd><i>${PORT_NAMES[p.portnum] || "UNKNOWN APP"}</i> (${p.portnum})</dd>
<dt>Raw Payload:</dt>
<dt data-translate-lang="raw_payload">${packetTranslations.raw_payload || "From Raw Payload"}:</dt>
<dd><pre>${escapeHtml(p.payload ?? "—")}</pre></dd>
${
telemetryExtras.length
? `<dt>Decoded Telemetry</dt>
? `<dt data-translate-lang="decoded_telemetry">${packetTranslations.decoded_telemetry || "Decoded Telemetry"}</dt>
<dd><pre>${telemetryExtras.join("\n")}</pre></dd>`
: ""
}
${
lat && lon
? `<dt>Location:</dt><dd>${lat.toFixed(6)}, ${lon.toFixed(6)}</dd>`
? `<dt data-translate-lang="location">${packetTranslations.location || "Location:"}</dt>
<dd>${lat.toFixed(6)}, ${lon.toFixed(6)}</dd>`
: ""
}
</dl>
@@ -262,22 +302,18 @@ document.addEventListener("DOMContentLoaded", async () => {
packetCard.classList.remove("d-none");
/* ---------------------------------------------
Map initialization
Map setup
----------------------------------------------*/
const map = L.map("map");
mapDiv.style.display = "block";
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19
}).addTo(map);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 })
.addTo(map);
const allBounds = [];
/* ---------------------------------------------
ALWAYS SHOW SOURCE POSITION
Priority:
1) position from packet payload
2) fallback: last_lat/last_long from /api/nodes
Determine packet source location
----------------------------------------------*/
let srcLat = lat;
let srcLon = lon;
@@ -304,135 +340,123 @@ document.addEventListener("DOMContentLoaded", async () => {
sourceMarker.bindPopup(`
<div style="font-size:0.9em">
<b>Packet Source</b><br>
<b data-translate-lang="packet_source">${packetTranslations.packet_source || "Packet Source"}</b><br>
Lat: ${srcLat.toFixed(6)}<br>
Lon: ${srcLon.toFixed(6)}<br>
From Node: ${fromNodeLabel}<br>
Channel: ${p.channel ?? "—"}<br>
Port: ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
<span data-translate-lang="from_node">${packetTranslations.from_node || "From Node:"}</span> ${fromNodeLabel}<br>
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${p.channel ?? "—"}<br>
<span data-translate-lang="port">${packetTranslations.port || "Port:"}</span> ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
</div>
`);
} else {
map.setView([0,0], 2);
}
/* ---------------------------------------------
Color for hop indicator markers (warm → cold)
Colors for hops (warm → cold)
----------------------------------------------*/
function hopColor(hopValue){
const colors = [
"#ff3b30",
"#ff6b22",
"#ff9f0c",
"#ffd60a",
"#87d957",
"#57d9c4",
"#3db2ff",
"#1e63ff"
"#ff3b30","#ff6b22","#ff9f0c","#ffd60a",
"#87d957","#57d9c4","#3db2ff","#1e63ff"
];
let h = Number(hopValue);
if (isNaN(h)) return "#aaa";
if (h < 0) h = 0;
if (h > 7) h = 7;
return colors[h];
}
/* Distance helper */
function haversine(lat1,lon1,lat2,lon2){
const R=6371;
const dLat=(lat2-lat1)*Math.PI/180;
const dLon=(lon2-lon1)*Math.PI/180;
const a=Math.sin(dLat/2)**2+
Math.cos(lat1*Math.PI/180)*
Math.cos(lat2*Math.PI/180)*
Math.sin(dLon/2)**2;
return R*(2*Math.atan2(Math.sqrt(a),Math.sqrt(1-a)));
return colors[Math.min(Math.max(h, 0), 7)];
}
/* ---------------------------------------------
Fetch packets_seen
Distance helper
----------------------------------------------*/
function haversine(lat1, lon1, lat2, lon2){
const R = 6371;
const dLat = (lat2-lat1)*Math.PI/180;
const dLon = (lon2-lon1)*Math.PI/180;
const a = Math.sin(dLat/2)**2 +
Math.cos(lat1*Math.PI/180)*
Math.cos(lat2*Math.PI/180)*
Math.sin(dLon/2)**2;
return R * (2*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
}
/* ---------------------------------------------
Load packets_seen
----------------------------------------------*/
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
const seenData = await seenRes.json();
const seenList = seenData.seen ?? [];
/* sort by hop_start descending (warm → cold) */
const seenSorted = seenList.slice().sort((a,b)=>{
const A=a.hop_start??-999;
const B=b.hop_start??-999;
return B-A;
return (b.hop_start ?? -999) - (a.hop_start ?? -999);
});
if (seenSorted.length){
seenContainer.classList.remove("d-none");
seenCountSpan.textContent=`(${seenSorted.length} gateways)`;
seenCountSpan.textContent = `(${seenSorted.length})`;
}
/* ---------------------------------------------
Gateway markers and seen table
Render gateway table + map markers
----------------------------------------------*/
seenTableBody.innerHTML = seenSorted.map(s=>{
const node=nodeLookup[s.node_id];
const label=node?(node.long_name||node.node_id):s.node_id;
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
const timeStr = s.import_time_us
? new Date(s.import_time_us/1000).toLocaleTimeString()
: "—";
if(node?.last_lat && node.last_long){
const rlat=node.last_lat/1e7;
const rlon=node.last_long/1e7;
allBounds.push([rlat,rlon]);
const start = Number(s.hop_start ?? 0);
const limit = Number(s.hop_limit ?? 0);
const hopValue = start - limit;
if (node?.last_lat && node.last_long){
const rlat = node.last_lat/1e7;
const rlon = node.last_long/1e7;
allBounds.push([rlat, rlon]);
const hopValue = (s.hop_start ?? 0) - (s.hop_limit ?? 0);
const color = hopColor(hopValue);
const iconHtml = `
<div style="
background:${color};
width:24px;
height:24px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:11px;
font-weight:700;
border:2px solid rgba(0,0,0,0.35);
box-shadow:0 0 5px rgba(0,0,0,0.45);
">${hopValue}</div>`;
const marker=L.marker([rlat,rlon],{
icon:L.divIcon({
html:iconHtml,
className:"",
const marker = L.marker([rlat,rlon],{
icon: L.divIcon({
html: `
<div style="
background:${color};
width:24px; height:24px;
border-radius:50%;
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:11px;
font-weight:700;
border:2px solid rgba(0,0,0,0.35);
box-shadow:0 0 5px rgba(0,0,0,0.45);
">${hopValue}</div>`,
className: "",
iconSize:[24,24],
iconAnchor:[12,12]
})
}).addTo(map);
let distKm=null,distMi=null;
if(srcLat&&srcLon){
distKm=haversine(srcLat,srcLon,rlat,rlon);
distMi=distKm*0.621371;
let distKm = null, distMi = null;
if (srcLat && srcLon){
distKm = haversine(srcLat, srcLon, rlat, rlon);
distMi = distKm * 0.621371;
}
marker.bindPopup(`
<div style="font-size:0.9em">
<b>${node?.long_name || s.node_id}</b><br>
Node ID: <a href="/node/${s.node_id}">${s.node_id}</a><br>
<b>${label}</b><br>
<span data-translate-lang="node_id_short">${packetTranslations.node_id_short || "Node ID"}</span>:
<a href="/node/${s.node_id}">${s.node_id}</a><br>
HW: ${node?.hw_model ?? "—"}<br>
Channel: ${s.channel ?? "—"}<br><br>
<b>Signal</b><br>
<span data-translate-lang="channel">${packetTranslations.channel || "Channel"}</span>: ${s.channel ?? "—"}<br><br>
<b data-translate-lang="signal">${packetTranslations.signal || "Signal"}</b><br>
RSSI: ${s.rx_rssi ?? "—"}<br>
SNR: ${s.rx_snr ?? "—"}<br><br>
<b>Hops</b>: ${hopValue}<br>
<b>Distance</b><br>
<b data-translate-lang="hops">${packetTranslations.hops || "Hops"}</b>: ${hopValue}<br>
<b data-translate-lang="distance">${packetTranslations.distance || "Distance"}:</b><br>
${
distKm
? `${distKm.toFixed(2)} km (${distMi.toFixed(2)} mi)`
@@ -454,17 +478,17 @@ document.addEventListener("DOMContentLoaded", async () => {
}).join("");
/* ---------------------------------------------
Fit map to all markers
Fit map around all markers
----------------------------------------------*/
if(allBounds.length>0){
map.fitBounds(allBounds,{padding:[40,40]});
if (allBounds.length > 0){
map.fitBounds(allBounds, { padding:[40,40] });
}
/* ---------------------------------------------
Escape HTML
Escape HTML helper
----------------------------------------------*/
function escapeHtml(unsafe) {
return (unsafe??"").replace(/[&<"'>]/g,m=>({
return (unsafe ?? "").replace(/[&<"'>]/g, m => ({
"&":"&amp;",
"<":"&lt;",
">":"&gt;",
@@ -475,4 +499,5 @@ document.addEventListener("DOMContentLoaded", async () => {
});
</script>
{% endblock %}

View File

@@ -15,7 +15,7 @@
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
gap: 20px;
margin-bottom: 15px;
}
@@ -38,14 +38,6 @@
color: #ddd;
}
.filter-bar {
display: flex;
flex-direction: row;
align-items: center;
gap: 20px;
}
table th { background-color: #333; }
table tbody tr:nth-child(odd) { background-color: #272b2f; }
table tbody tr:nth-child(even) { background-color: #212529; }
@@ -65,37 +57,42 @@
{% block body %}
<h1>Top Nodes Traffic</h1>
<h1 data-translate-lang="top_traffic_nodes">Top Nodes Traffic</h1>
<div class="top-container">
<div class="filter-bar">
<div>
<label for="channelFilter">Channel:</label>
<select id="channelFilter" class="form-select form-select-sm" style="width:auto;"></select>
<div class="filter-bar">
<div>
<label for="channelFilter" data-translate-lang="channel">Channel:</label>
<select id="channelFilter" class="form-select form-select-sm" style="width:auto;"></select>
</div>
<div>
<label for="nodeSearch" data-translate-lang="search">Search:</label>
<input id="nodeSearch" type="text" class="form-control form-control-sm"
placeholder="Search nodes..."
data-translate-lang="search_placeholder"
style="width:180px; display:inline-block;">
</div>
</div>
<div>
<label for="nodeSearch">Search:</label>
<input id="nodeSearch" type="text" class="form-control form-control-sm"
placeholder="Search nodes..."
style="width:180px; display:inline-block;">
<!-- ⭐ ADDED NODE COUNT ⭐ -->
<div id="count-container" style="margin-bottom:10px; font-weight:bold;">
<span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span>
</div>
</div>
<div class="table-responsive">
<table id="nodesTable">
<thead>
<tr>
<th>Long Name</th>
<th>Short Name</th>
<th>Channel</th>
<th>Sent (24h)</th>
<th>Seen (24h)</th>
<th>Avg Gateways</th>
<th data-translate-lang="long_name">Long Name</th>
<th data-translate-lang="short_name">Short Name</th>
<th data-translate-lang="channel">Channel</th>
<th data-translate-lang="packets_sent">Sent (24h)</th>
<th data-translate-lang="times_seen">Seen (24h)</th>
<th data-translate-lang="avg_gateways">Avg Gateways</th>
</tr>
</thead>
<tbody></tbody>
@@ -105,74 +102,105 @@
</div>
<script>
let allNodes = [];
/* ======================================================
TOP PAGE TRANSLATION (isolated from base)
====================================================== */
let topTranslations = {};
async function loadChannels() {
try {
const res = await fetch("/api/channels");
const data = await res.json();
const channels = data.channels || [];
function applyTranslationsTop(dict, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (!dict[key]) return;
const select = document.getElementById("channelFilter");
// Default LongFast first
if (channels.includes("LongFast")) {
const opt = document.createElement("option");
opt.value = "LongFast";
opt.textContent = "LongFast";
select.appendChild(opt);
}
for (const ch of channels) {
if (ch === "LongFast") continue;
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
}
select.addEventListener("change", renderTable);
} catch (err) {
console.error("Error loading channels:", err);
// input placeholder support
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
el.placeholder = dict[key];
} else {
el.textContent = dict[key];
}
}
});
}
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
const data = await res.json();
allNodes = data.nodes || [];
} catch (err) {
console.error("Error loading nodes:", err);
async function loadTranslationsTop() {
try {
const cfg = await window._siteConfigPromise;
const lang = cfg?.site?.language || "en";
const res = await fetch(`/api/lang?lang=${lang}&section=top`);
topTranslations = await res.json();
applyTranslationsTop(topTranslations);
} catch (err) {
console.error("TOP translation load failed:", err);
}
}
/* ======================================================
PAGE LOGIC
====================================================== */
let allNodes = [];
async function loadChannels() {
try {
const res = await fetch("/api/channels");
const data = await res.json();
const channels = data.channels || [];
const select = document.getElementById("channelFilter");
// LongFast first
if (channels.includes("LongFast")) {
const opt = document.createElement("option");
opt.value = "LongFast";
opt.textContent = "LongFast";
select.appendChild(opt);
}
}
async function fetchNodeStats(nodeId) {
try {
const url = `/api/stats/count?from_node=${nodeId}&period_type=day&length=1`;
const res = await fetch(url);
const data = await res.json();
const sent = data.total_packets || 0;
const seen = data.total_seen || 0;
const avg = seen / Math.max(sent, 1);
return {
sent,
seen,
avg: avg
};
} catch (err) {
console.error("Stat error", err);
return { sent: 0, seen: 0, avg: 0 };
for (const ch of channels) {
if (ch === "LongFast") continue;
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
}
}
function avgClass(v) {
if (v >= 10) return "good-x"; // Very strong node
if (v >= 2) return "ok-x"; // Normal node
return "bad-x"; // Weak node
select.addEventListener("change", renderTable);
} catch (err) {
console.error("Error loading channels:", err);
}
}
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
const data = await res.json();
allNodes = data.nodes || [];
} catch (err) {
console.error("Error loading nodes:", err);
}
}
async function fetchNodeStats(nodeId) {
try {
const res = await fetch(`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`);
const data = await res.json();
const sent = data.total_packets || 0;
const seen = data.total_seen || 0;
const avg = seen / Math.max(sent, 1);
return { sent, seen, avg };
} catch (err) {
console.error("Stat error:", err);
return { sent: 0, seen: 0, avg: 0 };
}
}
function avgClass(v) {
if (v >= 10) return "good-x";
if (v >= 2) return "ok-x";
return "bad-x";
}
async function renderTable() {
const tbody = document.querySelector("#nodesTable tbody");
@@ -181,10 +209,10 @@ async function renderTable() {
const channel = document.getElementById("channelFilter").value;
const searchText = document.getElementById("nodeSearch").value.trim().toLowerCase();
// Filter nodes by channel FIRST
// Filter by channel
let filtered = allNodes.filter(n => n.channel === channel);
// Then apply search
// Filter by search
if (searchText !== "") {
filtered = filtered.filter(n =>
(n.long_name && n.long_name.toLowerCase().includes(searchText)) ||
@@ -193,12 +221,10 @@ async function renderTable() {
);
}
// --- Create placeholder rows ---
// Placeholder rows first
const rowRefs = filtered.map(n => {
const tr = document.createElement("tr");
tr.addEventListener("click", () => {
window.location.href = `/node/${n.node_id}`;
});
tr.addEventListener("click", () => window.location.href = `/node/${n.node_id}`);
const tdLong = document.createElement("td");
const a = document.createElement("a");
@@ -215,13 +241,13 @@ async function renderTable() {
tdChannel.textContent = n.channel || "";
const tdSent = document.createElement("td");
tdSent.textContent = "Loading...";
tdSent.textContent = "...";
const tdSeen = document.createElement("td");
tdSeen.textContent = "Loading...";
tdSeen.textContent = "...";
const tdAvg = document.createElement("td");
tdAvg.textContent = "Loading...";
tdAvg.textContent = "...";
tr.appendChild(tdLong);
tr.appendChild(tdShort);
@@ -235,50 +261,49 @@ async function renderTable() {
return { node: n, tr, tdSent, tdSeen, tdAvg };
});
// --- Stats fetch ---
// Fetch stats
const statsList = await Promise.all(
rowRefs.map(ref => fetchNodeStats(ref.node.node_id))
);
// --- Update + cleanup empty nodes ---
// Update rows
let combined = rowRefs.map((ref, i) => {
const stats = statsList[i];
ref.tdSent.textContent = stats.sent;
ref.tdSeen.textContent = stats.seen;
ref.tdAvg.innerHTML = `<span class="${avgClass(stats.avg)}">${stats.avg.toFixed(1)}</span>`;
ref.tdAvg.innerHTML =
`<span class="${avgClass(stats.avg)}">${stats.avg.toFixed(1)}</span>`;
return {
tr: ref.tr,
sent: stats.sent,
seen: stats.seen
};
return { tr: ref.tr, sent: stats.sent, seen: stats.seen };
});
// Remove nodes with no traffic
// Remove nodes with no activity
combined = combined.filter(r => !(r.sent === 0 && r.seen === 0));
// Sort by traffic (seen)
// Sort by seen
combined.sort((a, b) => b.seen - a.seen);
// Rebuild table
tbody.innerHTML = "";
for (const r of combined) {
tbody.appendChild(r.tr);
}
for (const r of combined) tbody.appendChild(r.tr);
// ⭐ UPDATE COUNT ⭐
document.getElementById("node-count").textContent = combined.length;
}
(async () => {
/* ======================================================
INITIALIZE PAGE
====================================================== */
document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsTop(); // ⭐ MUST run first
await loadNodes();
await loadChannels();
document.getElementById("channelFilter").value = "LongFast";
document.getElementById("channelFilter").value = "LongFast";
document.getElementById("nodeSearch").addEventListener("input", renderTable);
renderTable();
})();
});
</script>
{% endblock %}

View File

@@ -421,45 +421,63 @@ async def api_stats_count(request):
async def api_edges(request):
since = datetime.datetime.now() - datetime.timedelta(hours=48)
filter_type = request.query.get("type")
edges = {}
traceroute_count = 0
neighbor_packet_count = 0
edges_added_tr = 0
edges_added_neighbor = 0
# Only build traceroute edges if requested
# --- Traceroute edges ---
if filter_type in (None, "traceroute"):
async for tr in store.get_traceroutes(since):
traceroute_count += 1
try:
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
except Exception as e:
logger.error(f"Error decoding Traceroute {tr.id}: {e}")
print(f" ERROR decoding traceroute {tr.id}: {e}")
continue
# Build full path
path = [tr.packet.from_node_id] + list(route.route)
path.append(tr.packet.to_node_id if tr.done else tr.gateway_node_id)
for a, b in zip(path, path[1:], strict=False):
edges[(a, b)] = "traceroute"
if (a, b) not in edges:
edges[(a, b)] = "traceroute"
edges_added_tr += 1
# Only build neighbor edges if requested
# --- Neighbor edges ---
if filter_type in (None, "neighbor"):
packets = await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=since)
packets = await store.get_packets(portnum=71)
neighbor_packet_count = len(packets)
for packet in packets:
packet_id = getattr(packet, "id", "?")
try:
_, neighbor_info = decode_payload.decode(packet)
for node in neighbor_info.neighbors:
edges.setdefault((node.node_id, packet.from_node_id), "neighbor")
except Exception as e:
logger.error(
f"Error decoding NeighborInfo packet {getattr(packet, 'id', '?')}: {e}"
)
print(f" ERROR decoding NeighborInfo packet {packet_id}: {e}")
continue
# Convert edges dict to list format for JSON response
for node in neighbor_info.neighbors:
edge = (node.node_id, packet.from_node_id)
if edge not in edges:
edges[edge] = "neighbor"
edges_added_neighbor += 1
# Convert to list
edges_list = [
{"from": frm, "to": to, "type": edge_type} for (frm, to), edge_type in edges.items()
{"from": frm, "to": to, "type": edge_type}
for (frm, to), edge_type in edges.items()
]
return web.json_response({"edges": edges_list})
@routes.get("/api/config")
async def api_config(request):
try: