From 37ed0369e45529b3d0eb45ebfc6030696cdc72cd Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sat, 25 Jan 2025 18:21:26 -0800 Subject: [PATCH 1/5] Update to the data models to show new fields on DB --- meshview/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/meshview/models.py b/meshview/models.py index 63941a1..4c5a548 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -20,6 +20,7 @@ class Node(Base): role: Mapped[str] = mapped_column(nullable=True) last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) + channel: Mapped[str] class Packet(Base): @@ -36,6 +37,7 @@ class Packet(Base): ) payload: Mapped[bytes] import_time: Mapped[datetime] + channel: Mapped[str] class PacketSeen(Base): From 40310d782ae97b569d89affdf44d9b62ac7408ca Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sat, 25 Jan 2025 18:27:11 -0800 Subject: [PATCH 2/5] Update to the data models to show new fields on DB --- meshview/models.py | 2 +- meshview/web.py | 360 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 358 insertions(+), 4 deletions(-) diff --git a/meshview/models.py b/meshview/models.py index 4c5a548..8932032 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -9,7 +9,7 @@ from sqlalchemy import ForeignKey, BigInteger class Base(AsyncAttrs, DeclarativeBase): pass - +# Node class Node(Base): __tablename__ = "node" id: Mapped[str] = mapped_column(primary_key=True) diff --git a/meshview/web.py b/meshview/web.py index 7722e08..52080b3 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -1086,18 +1086,372 @@ async def stats(request): total_packets = await store.get_total_packet_count() total_nodes = await store.get_total_node_count() total_packets_seen = await store.get_total_packet_seen_count() + total_nodes_longfast = await store.get_total_node_count_longfast() + total_nodes_mediumslow = await store.get_total_node_count_mediumslow() + # Render the stats template with the total packet count template = env.get_template("stats.html") return web.Response( - text=template.render(total_packets=total_packets, total_nodes=total_nodes,total_packets_seen=total_packets_seen ), + text=template.render(total_packets=total_packets, total_nodes=total_nodes,total_packets_seen=total_packets_seen,total_nodes_longfast=total_nodes_longfast, total_nodes_mediumslow=total_nodes_mediumslow ), content_type="text/html", ) -# In the stats.html template, you would include something like: -#

Total Packets: {{ total_packets }}

+@routes.get("/graph/longfast") +async def graph_network_longfast(request): + try: + root = request.query.get("root") + depth = int(request.query.get("depth", 5)) + hours = int(request.query.get("hours", 24)) + minutes = int(request.query.get("minutes", 0)) + + # Validation + if root and not root.isdigit(): + return web.Response(status=400, text="Invalid root node ID.") + if depth < 1: + return web.Response(status=400, text="Depth must be at least 1.") + + since = datetime.timedelta(hours=hours, minutes=minutes) + + nodes = {} + node_ids = set() + traceroutes = [] + + # Fetch traceroutes + for tr in await store.get_traceroutes_longfast(since): + node_ids.add(tr.gateway_node_id) + node_ids.add(tr.packet.from_node_id) + node_ids.add(tr.packet.to_node_id) + route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route) + node_ids.update(route.route) + + path = [tr.packet.from_node_id] + path.extend(route.route) + if tr.done: + path.append(tr.packet.to_node_id) + else: + if path[-1] != tr.gateway_node_id: + path.append(tr.gateway_node_id) + traceroutes.append((tr, path)) + + edges = Counter() + edge_type = {} + used_nodes = set() + + # Fetch MQTT neighbors + for ps, p in await store.get_mqtt_neighbors_longfast(since): + node_ids.add(ps.node_id) + node_ids.add(p.from_node_id) + used_nodes.add(ps.node_id) + used_nodes.add(p.from_node_id) + edges[(p.from_node_id, ps.node_id)] += 1 + edge_type[(p.from_node_id, ps.node_id)] = 'sni' + + # Fetch NeighborInfo packets + for packet in await store.get_packets_longfast(portnum=PortNum.NEIGHBORINFO_APP, since=since): + try: + _, neighbor_info = decode_payload.decode(packet) + node_ids.add(packet.from_node_id) + used_nodes.add(packet.from_node_id) + for node in neighbor_info.neighbors: + node_ids.add(node.node_id) + used_nodes.add(node.node_id) + edges[(node.node_id, packet.from_node_id)] += 1 + edge_type[(node.node_id, packet.from_node_id)] = 'ni' + except Exception as e: + print(f"Error decoding NeighborInfo packet: {e}") + + # Retrieve node details concurrently + async with asyncio.TaskGroup() as tg: + for node_id in node_ids: + nodes[node_id] = tg.create_task(store.get_node(node_id)) + + tr_done = set() + for tr, path in traceroutes: + if tr.done: + if tr.packet_id in tr_done: + continue + else: + tr_done.add(tr.packet_id) + + for src, dest in zip(path, path[1:]): + used_nodes.add(src) + used_nodes.add(dest) + edges[(src, dest)] += 1 + edge_type[(src, dest)] = 'tr' + + # Helper to get node name + async def get_node_name(node_id): + try: + node = await nodes[node_id] + if node: + return f'[{node.short_name}] {node.long_name}\n{node_id_to_hex(node_id)}' + return node_id_to_hex(node_id) + except Exception as e: + return f"Error retrieving node: {str(e)}" + + # Filter nodes and edges if root is specified + if root: + new_used_nodes = set() + new_edges = Counter() + edge_map = {} + for src, dest in edges: + edge_map.setdefault(dest, []).append(src) + + queue = [int(root)] + for _ in range(depth): + next_queue = [] + for node in queue: + new_used_nodes.add(node) + for dest in edge_map.get(node, []): + new_used_nodes.add(dest) + new_edges[(dest, node)] += 1 + next_queue.append(dest) + queue = next_queue + + used_nodes = new_used_nodes + edges = new_edges + + # Create graph + graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + for node_id in used_nodes: + node = await nodes[node_id] + color = '#000000' + node_name = await get_node_name(node_id) + if node and node.role in ('ROUTER', 'ROUTER_CLIENT', 'REPEATER'): + color = '#0000FF' + elif node and node.role == 'CLIENT_MUTE': + color = '#00FF00' + graph.add_node(pydot.Node( + str(node_id), + label=node_name, + shape='box', + color=color, + href=f"/graph/network?root={node_id}&depth={depth-1}", + )) + + # Adjust edge visualization + if edges: + max_edge_count = edges.most_common(1)[0][1] + else: + max_edge_count = 1 + + size_ratio = 2. / max_edge_count + edge_added = set() + + for (src, dest), edge_count in edges.items(): + size = max(size_ratio * edge_count, .25) + arrowsize = max(size_ratio * edge_count, .5) + if edge_type[(src, dest)] in ('ni'): + color = '#FF0000' + elif edge_type[(src, dest)] in ('sni'): + color = '#00FF00' + else: + color = '#000000' + edge_dir = "forward" + if (dest, src) in edges and edge_type[(src, dest)] == edge_type[(dest, src)]: + edge_dir = "both" + edge_added.add((dest, src)) + + if (src, dest) not in edge_added: + edge_added.add((src, dest)) + graph.add_edge(pydot.Edge( + str(src), + str(dest), + color=color, + tooltip=f'{await get_node_name(src)} -> {await get_node_name(dest)}', + penwidth=1.85, + dir=edge_dir, + )) + + return web.Response( + body=graph.create_svg(), + content_type="image/svg+xml", + ) + + except Exception as e: + print(f"Error in graph_network_longfast: {e}") + return web.Response(status=500, text="Internal Server Error") + + + +@routes.get("/graph/mediumslow") +async def graph_network_mediumslow(request): + try: + root = request.query.get("root") + depth = int(request.query.get("depth", 5)) + hours = int(request.query.get("hours", 24)) + minutes = int(request.query.get("minutes", 0)) + + # Validation + if root and not root.isdigit(): + return web.Response(status=400, text="Invalid root node ID.") + if depth < 1: + return web.Response(status=400, text="Depth must be at least 1.") + + since = datetime.timedelta(hours=hours, minutes=minutes) + + nodes = {} + node_ids = set() + traceroutes = [] + + # Fetch traceroutes + for tr in await store.get_traceroutes_mediumslow(since): + node_ids.add(tr.gateway_node_id) + node_ids.add(tr.packet.from_node_id) + node_ids.add(tr.packet.to_node_id) + route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route) + node_ids.update(route.route) + + path = [tr.packet.from_node_id] + path.extend(route.route) + if tr.done: + path.append(tr.packet.to_node_id) + else: + if path[-1] != tr.gateway_node_id: + path.append(tr.gateway_node_id) + traceroutes.append((tr, path)) + + edges = Counter() + edge_type = {} + used_nodes = set() + + # Fetch MQTT neighbors + for ps, p in await store.get_mqtt_neighbors_mediumslow(since): + node_ids.add(ps.node_id) + node_ids.add(p.from_node_id) + used_nodes.add(ps.node_id) + used_nodes.add(p.from_node_id) + edges[(p.from_node_id, ps.node_id)] += 1 + edge_type[(p.from_node_id, ps.node_id)] = 'sni' + + # Fetch NeighborInfo packets + for packet in await store.get_packets_mediumslow(portnum=PortNum.NEIGHBORINFO_APP, since=since): + try: + _, neighbor_info = decode_payload.decode(packet) + node_ids.add(packet.from_node_id) + used_nodes.add(packet.from_node_id) + for node in neighbor_info.neighbors: + node_ids.add(node.node_id) + used_nodes.add(node.node_id) + edges[(node.node_id, packet.from_node_id)] += 1 + edge_type[(node.node_id, packet.from_node_id)] = 'ni' + except Exception as e: + print(f"Error decoding NeighborInfo packet: {e}") + + # Retrieve node details concurrently + async with asyncio.TaskGroup() as tg: + for node_id in node_ids: + nodes[node_id] = tg.create_task(store.get_node(node_id)) + + tr_done = set() + for tr, path in traceroutes: + if tr.done: + if tr.packet_id in tr_done: + continue + else: + tr_done.add(tr.packet_id) + + for src, dest in zip(path, path[1:]): + used_nodes.add(src) + used_nodes.add(dest) + edges[(src, dest)] += 1 + edge_type[(src, dest)] = 'tr' + + # Helper to get node name + async def get_node_name(node_id): + try: + node = await nodes[node_id] + if node: + return f'[{node.short_name}] {node.long_name}\n{node_id_to_hex(node_id)}' + return node_id_to_hex(node_id) + except Exception as e: + return f"Error retrieving node: {str(e)}" + + # Filter nodes and edges if root is specified + if root: + new_used_nodes = set() + new_edges = Counter() + edge_map = {} + for src, dest in edges: + edge_map.setdefault(dest, []).append(src) + + queue = [int(root)] + for _ in range(depth): + next_queue = [] + for node in queue: + new_used_nodes.add(node) + for dest in edge_map.get(node, []): + new_used_nodes.add(dest) + new_edges[(dest, node)] += 1 + next_queue.append(dest) + queue = next_queue + + used_nodes = new_used_nodes + edges = new_edges + + # Create graph + graph = pydot.Dot('network', graph_type="digraph", layout="neato", overlap="false", model='subset', esep="+5") + for node_id in used_nodes: + node = await nodes[node_id] + color = '#000000' + node_name = await get_node_name(node_id) + if node and node.role in ('ROUTER', 'ROUTER_CLIENT', 'REPEATER'): + color = '#0000FF' + elif node and node.role == 'CLIENT_MUTE': + color = '#00FF00' + graph.add_node(pydot.Node( + str(node_id), + label=node_name, + shape='box', + color=color, + href=f"/graph/mediumslow?root={node_id}&depth={depth-1}", + )) + + # Adjust edge visualization + if edges: + max_edge_count = edges.most_common(1)[0][1] + else: + max_edge_count = 1 + + size_ratio = 2. / max_edge_count + edge_added = set() + + for (src, dest), edge_count in edges.items(): + size = max(size_ratio * edge_count, .25) + arrowsize = max(size_ratio * edge_count, .5) + if edge_type[(src, dest)] in ('ni'): + color = '#FF0000' + elif edge_type[(src, dest)] in ('sni'): + color = '#00FF00' + else: + color = '#000000' + edge_dir = "forward" + if (dest, src) in edges and edge_type[(src, dest)] == edge_type[(dest, src)]: + edge_dir = "both" + edge_added.add((dest, src)) + + if (src, dest) not in edge_added: + edge_added.add((src, dest)) + graph.add_edge(pydot.Edge( + str(src), + str(dest), + color=color, + tooltip=f'{await get_node_name(src)} -> {await get_node_name(dest)}', + penwidth=1.85, + dir=edge_dir, + )) + + return web.Response( + body=graph.create_svg(), + content_type="image/svg+xml", + ) + + except Exception as e: + print(f"Error in graph_network_longfast: {e}") + return web.Response(status=500, text="Internal Server Error") From 94bd8ba423ecdb204261f00fb48a0880615c0cc1 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sat, 25 Jan 2025 18:27:49 -0800 Subject: [PATCH 3/5] Update to the data models to show new fields on DB --- meshview/templates/stats.html | 50 ++++++++++++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 4 deletions(-) diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html index a0812a6..3fc73d1 100644 --- a/meshview/templates/stats.html +++ b/meshview/templates/stats.html @@ -8,10 +8,52 @@ {% endblock %} {% block body %} -
-

Total Nodes: {{ total_nodes }}

-

Total Packets: {{ total_packets }}

-

Total MQTT Reports: {{ total_packets_seen }}

+ +
+

+ Mesh Statistics +

+ +
+

+ Total Nodes: + {{ total_nodes }} +

+
+ +
+

+ Total Nodes LongFast: + {{ total_nodes_longfast }} + + ({{ (total_nodes_longfast / total_nodes * 100) | round(2) }}%) + +

+
+ +
+

+ Total Nodes MediumSlow: + {{ total_nodes_mediumslow }} + + ({{ (total_nodes_mediumslow / total_nodes * 100) | round(2) }}%) + +

+
+ +
+

+ Total Packets: + {{ total_packets }} +

+
+ +
+

+ Total MQTT Reports: + {{ total_packets_seen }} +

+
From f1c7f9835315602ce1cfebcbeaf11859ebd45997 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sat, 25 Jan 2025 18:28:42 -0800 Subject: [PATCH 4/5] Update to the data models to show new fields on DB --- meshview/store.py | 257 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 231 insertions(+), 26 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index e5278db..97c13d2 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -12,30 +12,6 @@ from meshview.models import Packet, PacketSeen, Node, Traceroute from meshview import notify - -# We count the total amount of packages -# This is to be used by /stats in web.py -async def get_total_packet_count(): - async with database.async_session() as session: - q = select(func.count(Packet.id)) # Use SQLAlchemy's func to count packets - result = await session.execute(q) - return result.scalar() # Return the total count of packets - -# We count the total amount of nodes -async def get_total_node_count(): - async with database.async_session() as session: - q = select(func.count(Node.id)) # Use SQLAlchemy's func to count nodes - result = await session.execute(q) - return result.scalar() # Return the total count of nodes - -# We count the total amount of seen packets -async def get_total_packet_seen_count(): - async with database.async_session() as session: - q = select(func.count(PacketSeen.node_id)) # Use SQLAlchemy's func to count nodes - result = await session.execute(q) - return result.scalar() # Return the total count of seen packets - - async def process_envelope(topic, env): if not env.packet.id: return @@ -54,6 +30,7 @@ async def process_envelope(topic, env): payload=env.packet.SerializeToString(), # p.r. Here seems to be where the packet is imported on the Database and import time is set. import_time=datetime.datetime.now(), + channel=env.channel_id, ) session.add(packet) @@ -111,7 +88,7 @@ async def process_envelope(topic, env): node.short_name = user.short_name node.hw_model = hw_model node.role = role - # if need to update time of last update it may be here + # if need to update time of last update it may be here else: node = Node( @@ -121,7 +98,8 @@ async def process_envelope(topic, env): short_name=user.short_name, hw_model=hw_model, role=role, - # if need to update time of last update it may be here + channel=env.channel_id, + # if need to update time of last update it may be here ) session.add(node) @@ -293,6 +271,233 @@ async def get_mqtt_neighbors(since): ) return result +# In order to provide separate network graphs for LongFast and MediumSlow, I am duplicating the procedures. +# 3 procedures are needed. These would have to be replicated for any other network that we may need to use graphs. +# +# get_traceroutes_longfast +# get_packets_longfast +# get_mqtt_neighbors_longfast +# +# p.r. +# +# Get Traceroute for LongFast only +async def get_traceroutes_longfast(since): + async with database.async_session() as session: + result = await session.execute( + select(Traceroute) + .join(Packet) + .where( + (Traceroute.import_time > (datetime.datetime.now() - since)) + & (Packet.channel == "LongFast") + ) + .order_by(Traceroute.import_time) + ) + return result.scalars() + +# Get MQTT Neighbors for LongFast only +# p.r. +async def get_mqtt_neighbors_longfast(since): + async with database.async_session() as session: + result = await session.execute(select(PacketSeen, Packet) + .join(Packet) + .where( + (PacketSeen.hop_limit == PacketSeen.hop_start) + & (PacketSeen.hop_start != 0) + & (Packet.channel == "LongFast") + ) + + .options( + lazyload(Packet.from_node), + lazyload(Packet.to_node), + ) + ) + return result + +# Get Packets for LongFast only +# p.r. +async def get_packets_longfast(node_id=None, portnum=None, since=None, limit=500, before=None, after=None): + async with database.async_session() as session: + q = select(Packet) + + # Add condition for channel being "LongFast" + q = q.where(Packet.channel == "LongFast") + + if node_id: + q = q.where( + (Packet.from_node_id == node_id) | (Packet.to_node_id == node_id) + ) + if portnum: + q = q.where(Packet.portnum == portnum) + if since: + q = q.where(Packet.import_time > (datetime.datetime.now() - since)) + if before: + q = q.where(Packet.import_time < before) + if after: + q = q.where(Packet.import_time > after) + if limit is not None: + q = q.limit(limit) + + result = await session.execute(q.order_by(Packet.import_time.desc())) + return result.scalars() + +# Get Traceroute for mediumslow only +# p.r. +async def get_traceroutes_mediumslow(since): + async with database.async_session() as session: + result = await session.execute( + select(Traceroute) + .join(Packet) + .where( + (Traceroute.import_time > (datetime.datetime.now() - since)) + & (Packet.channel == "MediumSlow") + ) + .order_by(Traceroute.import_time) + ) + return result.scalars() + +# Get MQTT Neighbors for mediumslow only +# p.r. +async def get_mqtt_neighbors_mediumslow(since): + async with database.async_session() as session: + result = await session.execute(select(PacketSeen, Packet) + .join(Packet) + .where( + (PacketSeen.hop_limit == PacketSeen.hop_start) + & (PacketSeen.hop_start != 0) + & (Packet.channel == "MediumSlow") + ) + + .options( + lazyload(Packet.from_node), + lazyload(Packet.to_node), + ) + ) + return result + +# Get Packets for MediumSlow only +# p.r. +async def get_packets_mediumslow(node_id=None, portnum=None, since=None, limit=500, before=None, after=None): + async with database.async_session() as session: + q = select(Packet) + + # Add condition for channel being "MediumSlow" + q = q.where(Packet.channel == "MediumSlow") + + if node_id: + q = q.where( + (Packet.from_node_id == node_id) | (Packet.to_node_id == node_id) + ) + if portnum: + q = q.where(Packet.portnum == portnum) + if since: + q = q.where(Packet.import_time > (datetime.datetime.now() - since)) + if before: + q = q.where(Packet.import_time < before) + if after: + q = q.where(Packet.import_time > after) + if limit is not None: + q = q.limit(limit) + + result = await session.execute(q.order_by(Packet.import_time.desc())) + return result.scalars() +# We count the total amount of packages +# This is to be used by /stats in web.py +async def get_total_packet_count(): + async with database.async_session() as session: + q = select(func.count(Packet.id)) # Use SQLAlchemy's func to count packets + result = await session.execute(q) + return result.scalar() # Return the total count of packets + +# We count the total amount of nodes +async def get_total_node_count(): + async with database.async_session() as session: + q = select(func.count(Node.id)) # Use SQLAlchemy's func to count nodes + result = await session.execute(q) + return result.scalar() # Return the total count of nodes + +# We count the total amount of seen packets +async def get_total_packet_seen_count(): + async with database.async_session() as session: + q = select(func.count(PacketSeen.node_id)) # Use SQLAlchemy's func to count nodes + result = await session.execute(q) + return result.scalar() # Return the total count of seen packets + + +async def get_total_node_count_longfast() -> int: + """ + Retrieves the total count of nodes where the channel is equal to 'LongFast'. + + This function queries the database asynchronously to count the number of nodes + in the `Node` table that meet the condition `channel == 'LongFast'`. It uses + SQLAlchemy's asynchronous session management and query construction. + + Returns: + int: The total count of nodes with `channel == 'LongFast'`. + + Raises: + Exception: If an error occurs during the database query execution. + """ + try: + # Open an asynchronous session with the database + async with database.async_session() as session: + # Build the query to count nodes where channel == 'LongFast' + q = select(func.count(Node.id)).filter(Node.channel == 'LongFast') + + # Execute the query asynchronously and fetch the result + result = await session.execute(q) + + # Return the scalar value (the count of nodes) + return result.scalar() + except Exception as e: + # Log or handle the exception if needed (optional, replace with logging if necessary) + print(f"An error occurred: {e}") + return 0 # Return 0 or an appropriate fallback value in case of an error + + +async def get_total_node_count_mediumslow() -> int: + """ + Retrieves the total count of nodes where the channel is equal to 'MediumSlow'. + + This function queries the database asynchronously to count the number of nodes + in the `Node` table that meet the condition `channel == 'MediumSlow'`. It uses + SQLAlchemy's asynchronous session management and query construction. + + Returns: + int: The total count of nodes with `channel == 'MediumSlow'`. + + Raises: + Exception: If an error occurs during the database query execution. + """ + try: + # Open an asynchronous session with the database + async with database.async_session() as session: + # Build the query to count nodes where channel == 'LongFast' + q = select(func.count(Node.id)).filter(Node.channel == 'MediumSlow') + + # Execute the query asynchronously and fetch the result + result = await session.execute(q) + + # Return the scalar value (the count of nodes) + return result.scalar() + except Exception as e: + # Log or handle the exception if needed (optional, replace with logging if necessary) + print(f"An error occurred: {e}") + return 0 # Return 0 or an appropriate fallback value in case of an error + + +# Get Nodes for mediumslow only +# p.r. +async def get_nodes_mediumslow(): + async with database.async_session() as session: + result = await session.execute( + select(Node) + .where( + (Node.channel == "MediumSlow") + ) + ) + return result.scalars() + + From cedc809928a3e04f256384739d062c1327016611 Mon Sep 17 00:00:00 2001 From: Pablo Revilla Date: Sat, 25 Jan 2025 19:14:41 -0800 Subject: [PATCH 5/5] Added links for LF and MS network Graphs. --- meshview/templates/base.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/meshview/templates/base.html b/meshview/templates/base.html index 5e64342..c82bbc8 100644 --- a/meshview/templates/base.html +++ b/meshview/templates/base.html @@ -1,7 +1,7 @@ - MeshView - Bay Area Mesh - http://bayme.sh {% if node and node.short_name %}-- {{node.short_name}}{% endif %} + MeshView - Bay Area Mesh - http://meshview.bayme.sh {% if node and node.short_name %}-- {{node.short_name}}{% endif %} @@ -34,7 +34,7 @@
Bay Area Mesh - http://bayme.sh
-
+
Quick Links:  Search for a node  - Conversations - See everything  - Mesh Graph LG - MS - Stats

Loading...