From 954cd4653dd9faa36be5868352889c3eedc4bae3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Wed, 15 Oct 2025 18:02:52 -0700 Subject: [PATCH 1/3] fixing node graph selector --- dbcleanup.log | 14 +++++++ meshview/templates/nodegraph.html | 68 +++++++++++++++++++++---------- meshview/templates/top.html | 49 ++++++++++++++++------ 3 files changed, 97 insertions(+), 34 deletions(-) create mode 100644 dbcleanup.log diff --git a/dbcleanup.log b/dbcleanup.log new file mode 100644 index 0000000..dea5664 --- /dev/null +++ b/dbcleanup.log @@ -0,0 +1,14 @@ +2025-10-14 20:13:45,221 [INFO] Daily cleanup is disabled by configuration. +2025-10-14 20:25:47,645 [INFO] Daily cleanup is disabled by configuration. +2025-10-14 20:34:48,026 [INFO] Daily cleanup is disabled by configuration. +2025-10-14 21:11:16,069 [INFO] Daily cleanup is disabled by configuration. +2025-10-14 21:19:58,777 [INFO] Daily cleanup is disabled by configuration. +2025-10-14 21:20:29,595 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 10:28:37,193 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 15:54:56,829 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 15:59:16,304 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 16:27:05,307 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 16:29:14,882 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 17:04:31,298 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 17:11:28,215 [INFO] Daily cleanup is disabled by configuration. +2025-10-15 18:00:31,833 [INFO] Daily cleanup is disabled by configuration. diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index f71cf58..b75b466 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -216,6 +216,7 @@ const edges = [ { source: "{{ edge.from }}", // edge source as string target: "{{ edge.to }}", // edge target as string + type: {{ edge.type | tojson }}, originalColor: colors.edge[{{edge.type | tojson}}], lineStyle: { color: colors.edge[{{edge.type | tojson}}], @@ -228,32 +229,55 @@ const edges = [ let filteredNodes = []; let filteredEdges = []; let lastSelectedNode = null; -const nodeChannelSet = [...new Set(nodes.map(n => n.channel).filter(Boolean))]; -let channelOptions = [...nodeChannelSet].sort(); -async function fetchChannelOptionsFromAPI() { - try { - const res = await fetch('/api/channels?period_type=day&length=30'); - if (!res.ok) return []; - const data = await res.json(); - if (!data || !Array.isArray(data.channels)) return []; - return data.channels.filter(ch => typeof ch === 'string' && ch.trim().length > 0); - } catch (err) { - console.error('Channel fetch failed:', err); - return []; +const normalizeChannel = (value) => { + const trimmed = (value || '').toString().trim(); + return trimmed || 'Unknown'; +}; + +const channelByNodeId = new Map(); +nodes.forEach(n=>{ + const key = String(n.name ?? n.node_id ?? ''); + if(key){ + channelByNodeId.set(key, normalizeChannel(n.channel)); } +}); + +const tracerouteChannelCounts = new Map(); +edges.forEach(edge=>{ + if(edge.type === 'traceroute'){ + const weight = typeof edge.weight === 'number' ? edge.weight : 1; + const fromChannel = channelByNodeId.get(String(edge.source)); + const toChannel = channelByNodeId.get(String(edge.target)); + if(fromChannel){ + tracerouteChannelCounts.set(fromChannel, (tracerouteChannelCounts.get(fromChannel) || 0) + weight); + } + if(toChannel){ + tracerouteChannelCounts.set(toChannel, (tracerouteChannelCounts.get(toChannel) || 0) + weight); + } + } +}); + +let channelOptions = []; +if (tracerouteChannelCounts.size) { + channelOptions = Array.from( + [...tracerouteChannelCounts.entries()] + .filter(([_, count]) => count > 0) + .map(([channel]) => channel) + ).sort(); } -async function initializeChannelOptions() { - const fetched = await fetchChannelOptionsFromAPI(); - const merged = new Set([...nodeChannelSet, ...fetched]); - if (hasChannelSelection(selectedChannel)) { - merged.add(selectedChannel); - } - channelOptions = Array.from(merged).sort(); - if (!hasChannelSelection(selectedChannel) && channelOptions.length) { - selectedChannel = channelOptions[0]; - } +if (!channelOptions.length) { + channelOptions = Array.from(new Set(nodes.map(n=>normalizeChannel(n.channel)))).sort(); +} + +if (hasChannelSelection(selectedChannel) && channelOptions.length && !channelOptions.includes(selectedChannel)) { + channelOptions.push(selectedChannel); + channelOptions.sort(); +} + +if (!hasChannelSelection(selectedChannel) && channelOptions.length) { + selectedChannel = channelOptions[0]; } function populateChannelDropdown() { diff --git a/meshview/templates/top.html b/meshview/templates/top.html index cb72ea1..2be17a2 100644 --- a/meshview/templates/top.html +++ b/meshview/templates/top.html @@ -223,20 +223,45 @@ function updateStatsAndChart() { } // Sort table -function sortTable(n) { +function sortTable(columnIndex) { const table = document.getElementById("trafficTable"); - const rows = Array.from(table.rows).slice(1); - const header = table.rows[0].cells[n]; - const isNumeric = !isNaN(rows[0].cells[n].innerText.replace('%','')); - let sortedRows = rows.sort((a,b)=>{ - const valA = isNumeric ? parseFloat(a.cells[n].innerText.replace('%','')) : a.cells[n].innerText.toLowerCase(); - const valB = isNumeric ? parseFloat(b.cells[n].cells[n].innerText.replace('%','')) : b.cells[n].innerText.toLowerCase(); - return valA > valB ? 1 : -1; - }); - if(header.getAttribute('data-sort-direction')==='asc'){ sortedRows.reverse(); header.setAttribute('data-sort-direction','desc'); } - else header.setAttribute('data-sort-direction','asc'); const tbody = table.tBodies[0]; - sortedRows.forEach(row=>tbody.appendChild(row)); + const headerCell = table.tHead.rows[0].cells[columnIndex]; + const rows = Array.from(tbody.rows); + + if (!rows.length) return; + + const sampleText = rows[0].cells[columnIndex].innerText.trim().replace('%',''); + const isNumeric = sampleText !== '' && !isNaN(sampleText); + + const currentDirection = headerCell.getAttribute('data-sort-direction') || 'none'; + const sortAscending = currentDirection === 'desc' || currentDirection === 'none'; + + rows.sort((rowA, rowB) => { + const textA = rowA.cells[columnIndex].innerText.trim(); + const textB = rowB.cells[columnIndex].innerText.trim(); + + let valueA = isNumeric ? parseFloat(textA.replace('%','')) : textA.toLowerCase(); + let valueB = isNumeric ? parseFloat(textB.replace('%','')) : textB.toLowerCase(); + + if (isNumeric) { + valueA = isNaN(valueA) ? Number.NEGATIVE_INFINITY : valueA; + valueB = isNaN(valueB) ? Number.NEGATIVE_INFINITY : valueB; + } + + if (valueA > valueB) return 1; + if (valueA < valueB) return -1; + return 0; + }); + + if (!sortAscending) { + rows.reverse(); + } + + Array.from(table.tHead.rows[0].cells).forEach(cell => cell.removeAttribute('data-sort-direction')); + headerCell.setAttribute('data-sort-direction', sortAscending ? 'asc' : 'desc'); + + rows.forEach(row => tbody.appendChild(row)); } // Initialize From c5a1009877dcafdfea06c428344c3bf0b9c1e647 Mon Sep 17 00:00:00 2001 From: Nathan Date: Thu, 16 Oct 2025 01:07:49 -0700 Subject: [PATCH 2/3] added channel filtering min_packets, and allowlist, fixed javascript error, new sample.config.ini sections --- dbcleanup.log | 14 -------------- meshview/store.py | 24 +++++++++++++++++++----- meshview/templates/nodegraph.html | 30 +++++++++++++----------------- meshview/web.py | 22 +++++++++++++++++++++- sample.config.ini | 13 +++++++++++++ 5 files changed, 66 insertions(+), 37 deletions(-) delete mode 100644 dbcleanup.log diff --git a/dbcleanup.log b/dbcleanup.log deleted file mode 100644 index dea5664..0000000 --- a/dbcleanup.log +++ /dev/null @@ -1,14 +0,0 @@ -2025-10-14 20:13:45,221 [INFO] Daily cleanup is disabled by configuration. -2025-10-14 20:25:47,645 [INFO] Daily cleanup is disabled by configuration. -2025-10-14 20:34:48,026 [INFO] Daily cleanup is disabled by configuration. -2025-10-14 21:11:16,069 [INFO] Daily cleanup is disabled by configuration. -2025-10-14 21:19:58,777 [INFO] Daily cleanup is disabled by configuration. -2025-10-14 21:20:29,595 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 10:28:37,193 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 15:54:56,829 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 15:59:16,304 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 16:27:05,307 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 16:29:14,882 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 17:04:31,298 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 17:11:28,215 [INFO] Daily cleanup is disabled by configuration. -2025-10-15 18:00:31,833 [INFO] Daily cleanup is disabled by configuration. diff --git a/meshview/store.py b/meshview/store.py index 8060469..39a3c37 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -384,11 +384,15 @@ async def get_packet_stats( } -async def get_channels_in_period(period_type: str = "hour", length: int = 24): +async def get_channels_in_period(period_type: str = "hour", length: int = 24, min_packets: int = 5, allowlist: list[str] | None = None): """ - Returns a list of distinct channels used in packets over a given period. + Returns a list of distinct channels used in packets over a given period, + filtered to only include channels with at least min_packets packets. + period_type: "hour" or "day" length: number of hours or days to look back + min_packets: minimum number of packets a channel must have to be included (default: 5) + allowlist: optional list of allowed channel names. If None or contains '*', all channels are allowed """ now = datetime.now() @@ -400,13 +404,23 @@ async def get_channels_in_period(period_type: str = "hour", length: int = 24): raise ValueError("period_type must be 'hour' or 'day'") async with database.async_session() as session: + # Count packets per channel and filter by minimum packet count q = ( - select(Packet.channel) + select(Packet.channel, func.count(Packet.id).label('packet_count')) .where(Packet.import_time >= start_time) - .distinct() + .where(Packet.channel.isnot(None)) + .group_by(Packet.channel) + .having(func.count(Packet.id) >= min_packets) .order_by(Packet.channel) ) result = await session.execute(q) - channels = [row[0] for row in result if row[0] is not None] + channels = [row[0] for row in result] + + # Apply allowlist filtering if specified + if allowlist and '*' not in allowlist: + # Filter to only include channels in the allowlist (case-insensitive) + allowlist_lower = [ch.lower() for ch in allowlist] + channels = [ch for ch in channels if ch.lower() in allowlist_lower] + return channels diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html index b75b466..f885cf2 100644 --- a/meshview/templates/nodegraph.html +++ b/meshview/templates/nodegraph.html @@ -243,25 +243,23 @@ nodes.forEach(n=>{ } }); -const tracerouteChannelCounts = new Map(); +const channelCounts = new Map(); edges.forEach(edge=>{ - if(edge.type === 'traceroute'){ - const weight = typeof edge.weight === 'number' ? edge.weight : 1; - const fromChannel = channelByNodeId.get(String(edge.source)); - const toChannel = channelByNodeId.get(String(edge.target)); - if(fromChannel){ - tracerouteChannelCounts.set(fromChannel, (tracerouteChannelCounts.get(fromChannel) || 0) + weight); - } - if(toChannel){ - tracerouteChannelCounts.set(toChannel, (tracerouteChannelCounts.get(toChannel) || 0) + weight); - } + const weight = typeof edge.weight === 'number' ? edge.weight : 1; + const fromChannel = channelByNodeId.get(String(edge.source)); + const toChannel = channelByNodeId.get(String(edge.target)); + if(fromChannel){ + channelCounts.set(fromChannel, (channelCounts.get(fromChannel) || 0) + weight); + } + if(toChannel){ + channelCounts.set(toChannel, (channelCounts.get(toChannel) || 0) + weight); } }); let channelOptions = []; -if (tracerouteChannelCounts.size) { +if (channelCounts.size) { channelOptions = Array.from( - [...tracerouteChannelCounts.entries()] + [...channelCounts.entries()] .filter(([_, count]) => count > 0) .map(([channel]) => channel) ).sort(); @@ -388,10 +386,8 @@ function searchNode(){ else alert("Node not found in current channel!"); } -initializeChannelOptions().then(() => { - populateChannelDropdown(); - filterByChannel(true); -}); +populateChannelDropdown(); +filterByChannel(true); window.addEventListener('resize', ()=>chart.resize()); {% endblock %} diff --git a/meshview/web.py b/meshview/web.py index 728ab45..f7ba0eb 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -1510,9 +1510,29 @@ async def get_config(request): async def api_channels(request: web.Request): period_type = request.query.get("period_type", "hour") length = int(request.query.get("length", 24)) + + # Get min_packets from config, with fallback to query param or default + config_min_packets = CONFIG.get("site", {}).get("min_packets_for_channel", "5") + try: + default_min_packets = int(config_min_packets) + except (ValueError, TypeError): + default_min_packets = 5 + + min_packets = int(request.query.get("min_packets", default_min_packets)) + + # Get channel allowlist from config + allowlist_str = CONFIG.get("site", {}).get("channel_allowlist", "*") + if allowlist_str and allowlist_str.strip(): + # Parse comma-separated list, or use '*' for all + if allowlist_str.strip() == "*": + allowlist = None # None means all channels allowed + else: + allowlist = [ch.strip() for ch in allowlist_str.split(",") if ch.strip()] + else: + allowlist = None try: - channels = await store.get_channels_in_period(period_type, length) + channels = await store.get_channels_in_period(period_type, length, min_packets, allowlist) return web.json_response({"channels": channels}) except Exception as e: return web.json_response({"channels": [], "error": str(e)}) diff --git a/sample.config.ini b/sample.config.ini index 1931931..0d4e3ca 100644 --- a/sample.config.ini +++ b/sample.config.ini @@ -1,6 +1,8 @@ # ------------------------- # Server Configuration # ------------------------- +# important: no leading spaces on configuration lines. + [server] # The address to bind the server to. Use * to listen on all interfaces. bind = * @@ -59,6 +61,17 @@ firehose_interal=3 weekly_net_message = Weekly Mesh check-in. We will keep it open on every Wednesday from 5:00pm for checkins. The message format should be (LONG NAME) - (CITY YOU ARE IN) #BayMeshNet. net_tag = #BayMeshNet +# Channel filtering configuration +# Minimum number of packets required for a channel to appear in dropdowns +min_packets_for_channel = 5 + +# Channel allowlist: comma-separated list of channels to display, or * for all channels +# Use * to show all channels with sufficient packets +# Or specify channels like: LongFast,MediumSlow,MediumFast,ShortTurbo,LongSlow,ShortFast,ShortSlow,VLongSlow +channel_allowlist = * +# Examples of common Meshtastic channels: +#channel_allowlist = LongFast,MediumSlow,MediumFast,ShortTurbo,LongSlow,ShortFast,ShortSlow,VLongSlow + # ------------------------- # MQTT Broker Configuration # ------------------------- From d56ee8f4c5d6e3050168a5155fd901709d108c18 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 17 Oct 2025 15:31:18 -0700 Subject: [PATCH 3/3] ruff fixes --- meshview/store.py | 13 +- meshview/web.py | 347 +++++++++++++++++++++++++++------------------- 2 files changed, 212 insertions(+), 148 deletions(-) diff --git a/meshview/store.py b/meshview/store.py index 9085717..9472127 100644 --- a/meshview/store.py +++ b/meshview/store.py @@ -354,11 +354,16 @@ async def get_packet_stats( } -async def get_channels_in_period(period_type: str = "hour", length: int = 24, min_packets: int = 5, allowlist: list[str] | None = None): +async def get_channels_in_period( + period_type: str = "hour", + length: int = 24, + min_packets: int = 5, + allowlist: list[str] | None = None, +): """ Returns a list of distinct channels used in packets over a given period, filtered to only include channels with at least min_packets packets. - + period_type: "hour" or "day" length: number of hours or days to look back min_packets: minimum number of packets a channel must have to be included (default: 5) @@ -386,11 +391,11 @@ async def get_channels_in_period(period_type: str = "hour", length: int = 24, mi result = await session.execute(q) channels = [row[0] for row in result] - + # Apply allowlist filtering if specified if allowlist and '*' not in allowlist: # Filter to only include channels in the allowlist (case-insensitive) allowlist_lower = [ch.lower() for ch in allowlist] channels = [ch for ch in channels if ch.lower() in allowlist_lower] - + return channels diff --git a/meshview/web.py b/meshview/web.py index 71fb59c..df97053 100644 --- a/meshview/web.py +++ b/meshview/web.py @@ -30,9 +30,20 @@ logging.basicConfig( logger = logging.getLogger(__name__) SEQ_REGEX = re.compile(r"seq \d+") -SOFTWARE_RELEASE = "2.0.7 ~ 10-17-25" +SOFTWARE_RELEASE = "2.0.7 ~ 09-17-25" CONFIG = config.CONFIG +ACTIVITY_FILTERS = [ + ("1h", "Last 1 hour", timedelta(hours=1)), + ("8h", "Last 8 hours", timedelta(hours=8)), + ("1d", "Last 1 day", timedelta(days=1)), + ("3d", "Last 3 days", timedelta(days=3)), + ("7d", "Last 7 days", timedelta(days=7)), + ("total", "All time", None), +] +ACTIVITY_OPTIONS = {value: window for value, _label, window in ACTIVITY_FILTERS} +DEFAULT_ACTIVITY_OPTION = "1d" + env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape()) # Start Database @@ -186,6 +197,16 @@ def format_timestamp(timestamp): env.filters["node_id_to_hex"] = node_id_to_hex env.filters["format_timestamp"] = format_timestamp + +def resolve_activity_window(raw_value: str | None, default_key: str = DEFAULT_ACTIVITY_OPTION): + default_key = default_key if default_key in ACTIVITY_OPTIONS else DEFAULT_ACTIVITY_OPTION + if raw_value: + normalized = raw_value.strip().lower() + if normalized in ACTIVITY_OPTIONS: + return normalized, ACTIVITY_OPTIONS[normalized] + return default_key, ACTIVITY_OPTIONS[default_key] + + routes = web.RouteTableDef() @@ -425,15 +446,27 @@ async def packet_details(request): @routes.get("/firehose") async def packet_details_firehose(request): - portnum = request.query.get("portnum") - if portnum: - portnum = int(portnum) - packets = await store.get_packets(portnum=portnum, limit=10) + portnum_value = request.query.get("portnum") + channel = request.query.get("channel") + + portnum = None + if portnum_value: + try: + portnum = int(portnum_value) + except ValueError: + logger.warning("Invalid portnum '%s' provided to /firehose", portnum_value) + + packets = await store.get_packets(portnum=portnum, limit=10, channel=channel) + ui_packets = [Packet.from_model(p) for p in packets] + latest_time = ui_packets[0].import_time.isoformat() if ui_packets else None + template = env.get_template("firehose.html") return web.Response( text=template.render( - packets=(Packet.from_model(p) for p in packets), + packets=ui_packets, portnum=portnum, + channel=channel, + last_time=latest_time, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), @@ -454,8 +487,20 @@ async def firehose_updates(request): logger.error(f"Failed to parse last_time '{last_time_str}': {e}") last_time = None + portnum_value = request.query.get("portnum") + channel = request.query.get("channel") + + portnum = None + if portnum_value: + try: + portnum = int(portnum_value) + except ValueError: + logger.warning("Invalid portnum '%s' provided to /firehose/updates", portnum_value) + # Query packets after last_time (microsecond precision) - packets = await store.get_packets(after=last_time, limit=10) + packets = await store.get_packets( + after=last_time, limit=10, portnum=portnum, channel=channel + ) # Convert to UI model ui_packets = [Packet.from_model(p) for p in packets] @@ -1159,6 +1204,24 @@ async def net(request): @routes.get("/map") async def map(request): try: + activity_param = request.query.get("active") + if not activity_param: + legacy_days = request.query.get("days_active") + if legacy_days and legacy_days.isdigit(): + activity_param = "total" if legacy_days == "0" else f"{legacy_days}d" + + selected_activity, activity_window = resolve_activity_window(activity_param) + + nodes = await store.get_nodes(active_within=activity_window) + + # Filter out nodes with no latitude + nodes = [node for node in nodes if node.last_lat is not None] + + # Optional datetime formatting + for node in nodes: + if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime): + node.last_update = node.last_update.isoformat() + # Parse optional URL parameters for custom view map_center_lat = request.query.get("lat") map_center_lng = request.query.get("lng") @@ -1180,18 +1243,21 @@ async def map(request): return web.Response( text=template.render( - custom_view=custom_view, + nodes=nodes, + custom_view=custom_view, + activity_filters=ACTIVITY_FILTERS, + selected_activity=selected_activity, + site_config=CONFIG, + SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), content_type="text/html", ) - except Exception as e: - print(f"/map route error: {e}") - return web.Response( - text="An error occurred while processing your request.", - status=500, - content_type="text/plain", - ) - + except Exception: + return web.Response( + text="An error occurred while processing your request.", + status=500, + content_type="text/plain", + ) @routes.get("/stats") @@ -1319,22 +1385,26 @@ async def chat(request): # Assuming the route URL structure is /nodegraph @routes.get("/nodegraph") async def nodegraph(request): - nodes = await store.get_nodes(days_active=3) # Fetch nodes for the given channel - node_ids = set() + activity_param = request.query.get("active") + if not activity_param: + legacy_days = request.query.get("days_active") + if legacy_days and legacy_days.isdigit(): + activity_param = "total" if legacy_days == "0" else f"{legacy_days}d" + + selected_activity, activity_window = resolve_activity_window(activity_param) + + nodes = await store.get_nodes(active_within=activity_window) + + active_node_ids = {node.node_id for node in nodes} edges_map = defaultdict( lambda: {"weight": 0, "type": None} ) # weight is based on the number of traceroutes and neighbor info packets - used_nodes = set() # This will track nodes involved in edges (including traceroutes) since = datetime.timedelta(hours=48) traceroutes = [] # Fetch traceroutes async for tr in store.get_traceroutes(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) @@ -1350,19 +1420,12 @@ async def nodegraph(request): edge_pair = (path[i], path[i + 1]) edges_map[edge_pair]["weight"] += 1 edges_map[edge_pair]["type"] = "traceroute" - used_nodes.add(path[i]) # Add all nodes in the traceroute path - used_nodes.add(path[i + 1]) # Add all nodes in the traceroute path # Fetch NeighborInfo packets for packet in await store.get_packets(portnum=PortNum.NEIGHBORINFO_APP, after=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) - edge_pair = (node.node_id, packet.from_node_id) edges_map[edge_pair]["weight"] += 1 edges_map[edge_pair]["type"] = "neighbor" @@ -1370,7 +1433,14 @@ async def nodegraph(request): logger.error(f"Error decoding NeighborInfo packet: {e}") # Convert edges_map to a list of dicts with colors - max_weight = max(i['weight'] for i in edges_map.values()) if edges_map else 1 + filtered_edge_items = [ + ((frm, to), info) + for (frm, to), info in edges_map.items() + if frm in active_node_ids and to in active_node_ids + ] + max_weight = ( + max(info["weight"] for _, info in filtered_edge_items) if filtered_edge_items else 1 + ) edges = [ { "from": frm, @@ -1378,23 +1448,51 @@ async def nodegraph(request): "type": info["type"], "weight": max([info['weight'] / float(max_weight) * 10, 1]), } - for (frm, to), info in edges_map.items() + for (frm, to), info in filtered_edge_items ] # Filter nodes to only include those involved in edges (including traceroutes) - nodes_with_edges = [node for node in nodes if node.node_id in used_nodes] + connected_node_ids = {node_id for edge in edges for node_id in (edge["from"], edge["to"])} + nodes_with_edges = [node for node in nodes if node.node_id in connected_node_ids] template = env.get_template("nodegraph.html") return web.Response( text=template.render( nodes=nodes_with_edges, edges=edges, # Pass edges with color info + activity_filters=ACTIVITY_FILTERS, + selected_activity=selected_activity, site_config=CONFIG, SOFTWARE_RELEASE=SOFTWARE_RELEASE, ), content_type="text/html", ) + +# Show basic details about the site on the site +@routes.get("/config") +async def get_config(request): + try: + site = CONFIG.get("site", {}) + mqtt = CONFIG.get("mqtt", {}) + + return web.json_response( + { + "Server": site.get("domain", ""), + "Title": site.get("title", ""), + "Message": site.get("message", ""), + "MQTT Server": mqtt.get("server", ""), + "Topics": json.loads(mqtt.get("topics", "[]")), + "Release": SOFTWARE_RELEASE, + "Time": datetime.datetime.now().isoformat(), + }, + dumps=lambda obj: json.dumps(obj, indent=2), + ) + + except (json.JSONDecodeError, TypeError): + return web.json_response({"error": "Invalid configuration format"}, status=500) + + # API Section ####################################################################### # How this works @@ -1408,16 +1506,16 @@ async def nodegraph(request): async def api_channels(request: web.Request): period_type = request.query.get("period_type", "hour") length = int(request.query.get("length", 24)) - + # Get min_packets from config, with fallback to query param or default config_min_packets = CONFIG.get("site", {}).get("min_packets_for_channel", "5") try: default_min_packets = int(config_min_packets) except (ValueError, TypeError): default_min_packets = 5 - + min_packets = int(request.query.get("min_packets", default_min_packets)) - + # Get channel allowlist from config allowlist_str = CONFIG.get("site", {}).get("channel_allowlist", "*") if allowlist_str and allowlist_str.strip(): @@ -1442,6 +1540,7 @@ async def api_chat(request): # Parse query params limit_str = request.query.get("limit", "20") since_str = request.query.get("since") + channel_filter = request.query.get("channel") # Clamp limit between 1 and 200 try: @@ -1461,7 +1560,9 @@ async def api_chat(request): packets = await store.get_packets( node_id=0xFFFFFFFF, portnum=PortNum.TEXT_MESSAGE_APP, + after=since, limit=limit, + channel=channel_filter, ) ui_packets = [Packet.from_model(p) for p in packets] @@ -1531,17 +1632,20 @@ async def api_nodes(request): role = request.query.get("role") channel = request.query.get("channel") hw_model = request.query.get("hw_model") - days_active = request.query.get("days_active") + activity_param = request.query.get("active") + if not activity_param: + legacy_days = request.query.get("days_active") + if legacy_days and legacy_days.isdigit(): + activity_param = "total" if legacy_days == "0" else f"{legacy_days}d" - if days_active: - try: - days_active = int(days_active) - except ValueError: - days_active = None + _, activity_window = resolve_activity_window(activity_param, default_key="total") # Fetch nodes from database using your get_nodes function nodes = await store.get_nodes( - role=role, channel=channel, hw_model=hw_model, days_active=days_active + role=role, + channel=channel, + hw_model=hw_model, + active_within=activity_window, ) # Prepare the JSON response @@ -1577,6 +1681,19 @@ async def api_packets(request): limit = int(request.query.get("limit", 50)) since_str = request.query.get("since") since_time = None + channel_values = [] + + # Support repeated ?channel=foo&channel=bar and comma-separated values + if "channel" in request.query: + raw_channels = request.query.getall("channel", []) + if not raw_channels: + raw_value = request.query.get("channel") + if raw_value: + raw_channels = [raw_value] + for raw in raw_channels: + if raw: + parts = [part.strip() for part in raw.split(",") if part.strip()] + channel_values.extend(parts) # Parse 'since' timestamp if provided if since_str: @@ -1586,7 +1703,14 @@ async def api_packets(request): logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}") # Fetch last N packets - packets = await store.get_packets(limit=limit, after=since_time) + if not channel_values: + channel_filter = None + elif len(channel_values) == 1: + channel_filter = channel_values[0] + else: + channel_filter = channel_values + + packets = await store.get_packets(limit=limit, after=since_time, channel=channel_filter) packets = [Packet.from_model(p) for p in packets] # Build JSON response (no raw_payload) @@ -1662,6 +1786,41 @@ async def api_stats(request): return web.json_response(stats) +@routes.get("/api/stats/summary") +async def api_stats_summary(request): + channel = request.query.get("channel") or None + total_packets = await store.get_total_packet_count(channel=channel) + total_nodes = await store.get_total_node_count(channel=channel) + total_packets_seen = await store.get_total_packet_seen_count(channel=channel) + + return web.json_response( + { + "total_packets": total_packets, + "total_nodes": total_nodes, + "total_packets_seen": total_packets_seen, + } + ) + + +@routes.get("/api/config") +async def api_config(request): + try: + site = CONFIG.get("site", {}) + safe_site = { + "map_interval": site.get("map_interval", 3), + "firehose_interval": site.get("firehose_interval", 3), + "map_top_left_lat": site.get("map_top_left_lat", 3), + "map_top_left_lon": site.get("map_top_left_lon", 3), + "map_bottom_right_lat": site.get("map_bottom_right_lat", 3), + "map_bottom_right_lon": site.get("map_bottom_right_lon", 3), + } + safe_config = {"site": safe_site} + + return web.json_response(safe_config) + except Exception as e: + return web.json_response({"error": str(e)}, status=500) + + @routes.get("/api/edges") async def api_edges(request): since = datetime.datetime.now() - datetime.timedelta(hours=48) @@ -1704,106 +1863,6 @@ async def api_edges(request): return web.json_response({"edges": edges_list}) -@routes.get("/api/config") -async def api_config(request): - try: - # ------------------ Helpers ------------------ - def get(section, key, default=None): - """Safe getter for both dict and ConfigParser.""" - if isinstance(section, dict): - return section.get(key, default) - return section.get(key, fallback=default) - - def get_bool(section, key, default=False): - val = get(section, key, default) - if isinstance(val, bool): - return "true" if val else "false" - if isinstance(val, str): - return "true" if val.lower() in ("1", "true", "yes", "on") else "false" - return "true" if bool(val) else "false" - - def get_float(section, key, default=0.0): - try: - return float(get(section, key, default)) - except Exception: - return float(default) - - def get_int(section, key, default=0): - try: - return int(get(section, key, default)) - except Exception: - return default - - def get_str(section, key, default=""): - val = get(section, key, default) - return str(val) if val is not None else str(default) - - # ------------------ SITE ------------------ - site = CONFIG.get("site", {}) - safe_site = { - "domain": get_str(site, "domain", ""), - "language": get_str(site, "language", "en"), - "title": get_str(site, "title", ""), - "message": get_str(site, "message", ""), - "starting": get_str(site, "starting", "/chat"), - "nodes": get_bool(site, "nodes", True), - "conversations": get_bool(site, "conversations", True), - "everything": get_bool(site, "everything", True), - "graphs": get_bool(site, "graphs", True), - "stats": get_bool(site, "stats", True), - "net": get_bool(site, "net", True), - "map": get_bool(site, "map", True), - "top": get_bool(site, "top", True), - "map_top_left_lat": get_float(site, "map_top_left_lat", 39.0), - "map_top_left_lon": get_float(site, "map_top_left_lon", -123.0), - "map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0), - "map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0), - "map_interval": get_int(site, "map_interval", 3), - "firehose_interval": get_int(site, "firehose_interval", 3), - "weekly_net_message": get_str(site, "weekly_net_message", "Weekly Mesh check-in message."), - "net_tag": get_str(site, "net_tag", "#BayMeshNet"), - "version": str(SOFTWARE_RELEASE), - } - - # ------------------ MQTT ------------------ - mqtt = CONFIG.get("mqtt", {}) - topics_raw = get(mqtt, "topics", []) - import json - if isinstance(topics_raw, str): - try: - topics = json.loads(topics_raw) - except Exception: - topics = [topics_raw] - elif isinstance(topics_raw, list): - topics = topics_raw - else: - topics = [] - - safe_mqtt = { - "server": get_str(mqtt, "server", ""), - "topics": topics, - } - - # ------------------ CLEANUP ------------------ - cleanup = CONFIG.get("cleanup", {}) - safe_cleanup = { - "enabled": get_bool(cleanup, "enabled", False), - "days_to_keep": get_str(cleanup, "days_to_keep", "14"), - "hour": get_str(cleanup, "hour", "2"), - "minute": get_str(cleanup, "minute", "0"), - "vacuum": get_bool(cleanup, "vacuum", False), - } - - safe_config = { - "site": safe_site, - "mqtt": safe_mqtt, - "cleanup": safe_cleanup, - } - - return web.json_response(safe_config) - except Exception as e: - return web.json_response({"error": str(e)}, status=500) - @routes.get("/api/lang") async def api_lang(request):