Merge pull request #80 from pablorevilla-meshtastic/revert-78-10-15-25-bugs2

Revert "Add configurable channel filtering with allowlist and minimum packet threshold"
This commit is contained in:
Pablo Revilla
2025-10-17 18:40:48 -07:00
committed by GitHub
5 changed files with 159 additions and 296 deletions

View File

@@ -354,20 +354,11 @@ 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):
"""
Returns a list of distinct channels used in packets over a given period,
filtered to only include channels with at least min_packets packets.
Returns a list of distinct channels used in packets over a given period.
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()
@@ -379,23 +370,13 @@ async def get_channels_in_period(
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, func.count(Packet.id).label('packet_count'))
select(Packet.channel)
.where(Packet.import_time >= start_time)
.where(Packet.channel.isnot(None))
.group_by(Packet.channel)
.having(func.count(Packet.id) >= min_packets)
.distinct()
.order_by(Packet.channel)
)
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]
channels = [row[0] for row in result if row[0] is not None]
return channels

View File

@@ -193,7 +193,6 @@ 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}}],

View File

@@ -223,45 +223,20 @@ function updateStatsAndChart() {
}
// Sort table
function sortTable(columnIndex) {
function sortTable(n) {
const table = document.getElementById("trafficTable");
const tbody = table.tBodies[0];
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;
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 (!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));
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));
}
// Initialize

View File

@@ -30,20 +30,9 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
SEQ_REGEX = re.compile(r"seq \d+")
SOFTWARE_RELEASE = "2.0.7 ~ 09-17-25"
SOFTWARE_RELEASE = "2.0.7 ~ 10-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
@@ -197,16 +186,6 @@ 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()
@@ -446,27 +425,15 @@ async def packet_details(request):
@routes.get("/firehose")
async def packet_details_firehose(request):
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
portnum = request.query.get("portnum")
if portnum:
portnum = int(portnum)
packets = await store.get_packets(portnum=portnum, limit=10)
template = env.get_template("firehose.html")
return web.Response(
text=template.render(
packets=ui_packets,
packets=(Packet.from_model(p) for p in packets),
portnum=portnum,
channel=channel,
last_time=latest_time,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
@@ -487,20 +454,8 @@ 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, portnum=portnum, channel=channel
)
packets = await store.get_packets(after=last_time, limit=10)
# Convert to UI model
ui_packets = [Packet.from_model(p) for p in packets]
@@ -1204,24 +1159,6 @@ 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")
@@ -1243,21 +1180,18 @@ async def map(request):
return web.Response(
text=template.render(
nodes=nodes,
custom_view=custom_view,
activity_filters=ACTIVITY_FILTERS,
selected_activity=selected_activity,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
custom_view=custom_view,
),
content_type="text/html",
)
except Exception:
return web.Response(
text="An error occurred while processing your request.",
status=500,
content_type="text/plain",
)
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",
)
@routes.get("/stats")
@@ -1385,26 +1319,22 @@ async def chat(request):
# Assuming the route URL structure is /nodegraph
@routes.get("/nodegraph")
async def nodegraph(request):
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}
nodes = await store.get_nodes(days_active=3) # Fetch nodes for the given channel
node_ids = set()
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)
@@ -1420,12 +1350,19 @@ 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"
@@ -1433,14 +1370,7 @@ async def nodegraph(request):
logger.error(f"Error decoding NeighborInfo packet: {e}")
# Convert edges_map to a list of dicts with colors
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
)
max_weight = max(i['weight'] for i in edges_map.values()) if edges_map else 1
edges = [
{
"from": frm,
@@ -1448,51 +1378,23 @@ async def nodegraph(request):
"type": info["type"],
"weight": max([info['weight'] / float(max_weight) * 10, 1]),
}
for (frm, to), info in filtered_edge_items
for (frm, to), info in edges_map.items()
]
# Filter nodes to only include those involved in edges (including traceroutes)
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]
nodes_with_edges = [node for node in nodes if node.node_id in used_nodes]
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
@@ -1507,28 +1409,8 @@ 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, min_packets, allowlist)
channels = await store.get_channels_in_period(period_type, length)
return web.json_response({"channels": channels})
except Exception as e:
return web.json_response({"channels": [], "error": str(e)})
@@ -1540,7 +1422,6 @@ 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:
@@ -1560,9 +1441,7 @@ 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]
@@ -1632,20 +1511,17 @@ async def api_nodes(request):
role = request.query.get("role")
channel = request.query.get("channel")
hw_model = request.query.get("hw_model")
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"
days_active = request.query.get("days_active")
_, activity_window = resolve_activity_window(activity_param, default_key="total")
if days_active:
try:
days_active = int(days_active)
except ValueError:
days_active = None
# Fetch nodes from database using your get_nodes function
nodes = await store.get_nodes(
role=role,
channel=channel,
hw_model=hw_model,
active_within=activity_window,
role=role, channel=channel, hw_model=hw_model, days_active=days_active
)
# Prepare the JSON response
@@ -1681,19 +1557,6 @@ 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:
@@ -1703,14 +1566,7 @@ async def api_packets(request):
logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}")
# Fetch last N packets
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 = await store.get_packets(limit=limit, after=since_time)
packets = [Packet.from_model(p) for p in packets]
# Build JSON response (no raw_payload)
@@ -1786,41 +1642,6 @@ 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)
@@ -1863,6 +1684,106 @@ 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):

View File

@@ -1,8 +1,6 @@
# -------------------------
# Server Configuration
# -------------------------
# important: no leading spaces on configuration lines.
[server]
# The address to bind the server to. Use * to listen on all interfaces.
bind = *
@@ -61,17 +59,6 @@ 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
# -------------------------