Map: activity time filters

This commit is contained in:
Nathan
2025-10-11 21:21:10 -07:00
parent 257bf7ffac
commit b7752bc315
4 changed files with 261 additions and 47 deletions
+28 -3
View File
@@ -263,7 +263,13 @@ async def get_node_traffic(node_id: int):
return []
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
async def get_nodes(
role=None,
channel=None,
hw_model=None,
days_active=None,
active_within: timedelta | None = None,
):
"""
Fetches nodes from the database based on optional filtering criteria.
@@ -271,6 +277,8 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
role (str, optional): The role of the node (converted to uppercase for consistency).
channel (str, optional): The communication channel associated with the node.
hw_model (str, optional): The hardware model of the node.
days_active (int, optional): Legacy support for filtering by a number of days.
active_within (timedelta, optional): Filter nodes seen within the provided window.
Returns:
list: A list of Node objects that match the given criteria.
@@ -290,8 +298,12 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
if hw_model is not None:
query = query.where(Node.hw_model == hw_model)
if days_active is not None:
query = query.where(Node.last_update > datetime.now() - timedelta(days_active))
window = active_within
if window is None and days_active is not None:
window = timedelta(days=days_active)
if window is not None:
query = query.where(Node.last_update > datetime.now() - window)
# Exclude nodes where last_update is an empty string
query = query.where(Node.last_update != "")
@@ -360,6 +372,19 @@ async def get_packet_stats(
}
async def get_all_channels():
async with database.async_session() as session:
stmt = (
select(Node.channel)
.where(Node.channel.is_not(None))
.where(Node.channel != "")
.distinct()
.order_by(Node.channel.asc())
)
result = await session.execute(stmt)
return [row[0] for row in result]
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.
+74 -11
View File
@@ -23,10 +23,20 @@
#filter-container {
text-align: center;
margin-top: 10px;
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 8px;
}
.filter-checkbox {
margin: 0 10px;
}
#activity-range {
padding: 4px 8px;
border-radius: 4px;
border: 1px solid #ccc;
}
#share-button {
margin-left: 20px;
padding: 5px 15px;
@@ -72,6 +82,12 @@
{% block body %}
<div id="map" style="width: 100%; height: calc(100vh - 270px)"></div>
<div id="filter-container">
<label for="activity-range" id="activity-range-label">Active in:</label>
<select id="activity-range">
{% for value, label, _window in activity_filters %}
<option value="{{ value }}" {% if value == selected_activity %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> <span id="filter-routers-label">Show Routers Only</span>
</div>
<div style="text-align: center; margin-top: 5px;">
@@ -101,6 +117,18 @@ async function loadTranslations() {
// Initialize map AFTER translations are loaded
loadTranslations().then(() => {
const t = window.mapTranslations || {};
const activitySelect = document.getElementById("activity-range");
const activityLabel = document.getElementById("activity-range-label");
if (activityLabel) {
activityLabel.textContent = t.active_within || "Active in:";
}
if (activitySelect) {
activitySelect.addEventListener("change", () => {
const url = new URL(window.location.href);
url.searchParams.set("active", activitySelect.value);
window.location.href = url.toString();
});
}
// ---- Map Setup ----
var map = L.map('map');
@@ -135,6 +163,8 @@ loadTranslations().then(() => {
}{{ "," if not loop.last else "" }}
{% endfor %}
];
const providedChannels = {{ all_channels | default([], true) | tojson }};
const channelSet = new Set(providedChannels.filter(ch => ch));
const portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"};
@@ -162,18 +192,24 @@ loadTranslations().then(() => {
return color;
}
function channelKey(channel) {
if (typeof channel === 'string' && channel.trim().length > 0) {
return channel;
}
return 'Unknown';
}
const nodeMap = new Map();
nodes.forEach(n => nodeMap.set(n.id, n));
function isInvalidCoord(node) { return !node || !node.lat || !node.long || node.lat===0 || node.long===0 || Number.isNaN(node.lat) || Number.isNaN(node.long); }
// ---- Marker Plotting ----
var bounds = L.latLngBounds();
var channels = new Set();
nodes.forEach(node => {
if (!isInvalidCoord(node)) {
let category = node.channel;
channels.add(category);
let category = channelKey(node.channel);
channelSet.add(category);
let color = hashToColor(category);
let popupContent = `<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
@@ -209,6 +245,8 @@ loadTranslations().then(() => {
if (customView) map.setView([customView.lat,customView.lng],customView.zoom);
else map.fitBounds(areaBounds);
const channelList = Array.from(channelSet).sort();
// ---- LocalStorage for Filter Preferences ----
const FILTER_STORAGE_KEY = 'meshview_map_filters';
@@ -225,7 +263,7 @@ loadTranslations().then(() => {
channels: {}
};
channels.forEach(channel => {
channelList.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
@@ -259,7 +297,7 @@ loadTranslations().then(() => {
document.getElementById("filter-routers-only").checked = false;
// Reset all channel filters to checked (default)
channels.forEach(channel => {
channelList.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
@@ -286,7 +324,7 @@ loadTranslations().then(() => {
filterLabel.textContent = t.show_routers_only || "Show Routers Only";
let filterContainer = document.getElementById("filter-container");
channels.forEach(channel => {
channelList.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
let color = hashToColor(channel);
let label = document.createElement('label');
@@ -302,7 +340,7 @@ loadTranslations().then(() => {
document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false;
// Apply channel filters
channels.forEach(channel => {
channelList.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox && savedFilters.channels.hasOwnProperty(channel)) {
@@ -314,15 +352,19 @@ loadTranslations().then(() => {
function updateMarkers() {
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
nodes.forEach(node => {
let category=node.channel;
let category = channelKey(node.channel);
let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
let shouldShow=checkbox.checked && (!showRoutersOnly || node.isRouter);
let shouldShow=(!checkbox || checkbox.checked) && (!showRoutersOnly || node.isRouter);
let marker=markerById[node.id];
if(marker) marker.setStyle({fillOpacity:shouldShow?1:0});
});
// Save filters to localStorage whenever they change
saveFiltersToLocalStorage();
if (!document.hidden) {
restartPacketFetcher();
}
}
document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers));
@@ -338,7 +380,11 @@ loadTranslations().then(() => {
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
const url = new URL(window.location.href);
url.searchParams.set('lat', lat);
url.searchParams.set('lng', lng);
url.searchParams.set('zoom', zoom);
const shareUrl = url.toString();
navigator.clipboard.writeText(shareUrl).then(()=>{
const orig = shareBtn.textContent;
shareBtn.textContent = '✓ Link Copied!';
@@ -474,8 +520,25 @@ loadTranslations().then(() => {
}).catch(err=>console.error(err));
}
let packetInterval=null;
function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } }
function startPacketFetcher(resetImportTime=true){
if(mapInterval<=0) return;
if(!packetInterval){
if(resetImportTime || !lastImportTime){
fetchLatestPacket();
}
packetInterval=setInterval(fetchNewPackets,mapInterval*1000);
if(!resetImportTime && lastImportTime){
fetchNewPackets();
}
}
}
function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
function restartPacketFetcher(){
if(mapInterval<=0) return;
stopPacketFetcher();
if(document.hidden) return;
startPacketFetcher(false);
}
document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); });
if(mapInterval>0) startPacketFetcher();
});
+68 -9
View File
@@ -87,6 +87,12 @@
<div id="mynetwork"></div>
<div class="search-container">
<label for="activity-range" style="color:#333;">Active in:</label>
<select id="activity-range">
{% for value, label, _window in activity_filters %}
<option value="{{ value }}" {% if value == selected_activity %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<label for="channel-select" style="color:#333;">Channel:</label>
<select id="channel-select" onchange="filterByChannel()"></select>
<input type="text" id="node-search" placeholder="Search node...">
@@ -123,6 +129,25 @@
</div>
<script>
const activityRangeSelect = document.getElementById('activity-range');
const availableChannelsFromServer = {{ all_channels | default([], true) | tojson }};
const urlParams = new URLSearchParams(window.location.search);
const channelParam = urlParams.get('channel');
let selectedChannel = {{ selected_channel | default('', true) | tojson }};
const hasChannelSelection = value => value !== null && value !== undefined && value !== '';
if (activityRangeSelect) {
activityRangeSelect.addEventListener('change', () => {
const url = new URL(window.location.href);
url.searchParams.set('active', activityRangeSelect.value);
if (hasChannelSelection(selectedChannel)) {
url.searchParams.set('channel', selectedChannel);
} else {
url.searchParams.delete('channel');
}
window.location.href = url.toString();
});
}
const chart = echarts.init(document.getElementById('mynetwork'));
const colors = {
@@ -204,28 +229,62 @@ const edges = [
let filteredNodes = [];
let filteredEdges = [];
let selectedChannel = 'LongFast';
let lastSelectedNode = null;
const nodeChannelSet = [...new Set(nodes.map(n => n.channel).filter(Boolean))];
const channelOptions = Array.from(new Set([
...availableChannelsFromServer.filter(Boolean),
...nodeChannelSet,
])).sort();
if (!selectedChannel || !channelOptions.includes(selectedChannel)) {
if (channelParam && channelOptions.includes(channelParam)) {
selectedChannel = channelParam;
} else if (nodeChannelSet.length) {
selectedChannel = nodeChannelSet[0];
} else if (channelOptions.length) {
selectedChannel = channelOptions[0];
} else {
selectedChannel = null;
}
}
function populateChannelDropdown() {
const sel = document.getElementById('channel-select');
const unique = [...new Set(nodes.map(n=>n.channel).filter(Boolean))].sort();
unique.forEach(ch=>{
channelOptions.forEach(ch=>{
const opt = document.createElement('option');
opt.value=ch; opt.text=ch;
if(ch==='LongFast') opt.selected=true;
if(selectedChannel && ch === selectedChannel) opt.selected=true;
sel.appendChild(opt);
});
selectedChannel = sel.value;
filterByChannel();
if (!selectedChannel && channelOptions.length) {
selectedChannel = channelOptions[0];
sel.value = selectedChannel;
}
filterByChannel(true);
}
function filterByChannel() {
selectedChannel = document.getElementById('channel-select').value;
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
function filterByChannel(isInitial=false) {
const sel = document.getElementById('channel-select');
if (sel) {
selectedChannel = sel.value || selectedChannel;
}
if (hasChannelSelection(selectedChannel)) {
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
} else {
filteredNodes = [...nodes];
}
const nodeSet = new Set(filteredNodes.map(n=>n.name));
filteredEdges = edges.filter(e=>nodeSet.has(e.source) && nodeSet.has(e.target));
lastSelectedNode=null;
if (!isInitial) {
const url = new URL(window.location.href);
if (hasChannelSelection(selectedChannel)) {
url.searchParams.set('channel', selectedChannel);
} else {
url.searchParams.delete('channel');
}
window.history.replaceState({}, '', url.toString());
}
updateChart();
}
+91 -24
View File
@@ -33,6 +33,17 @@ SEQ_REGEX = re.compile(r"seq \d+")
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,17 @@ 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()
@@ -1181,7 +1203,16 @@ async def net(request):
@routes.get("/map")
async def map(request):
try:
nodes = await store.get_nodes(days_active=3)
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)
all_channels = await store.get_all_channels()
# Filter out nodes with no latitude
nodes = [node for node in nodes if node.last_lat is not None]
@@ -1214,6 +1245,10 @@ async def map(request):
text=template.render(
nodes=nodes,
custom_view=custom_view,
activity_filters=ACTIVITY_FILTERS,
selected_activity=selected_activity,
default_activity=DEFAULT_ACTIVITY_OPTION,
all_channels=all_channels,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
@@ -1352,22 +1387,40 @@ 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)
all_channels = await store.get_all_channels()
channel_param = request.query.get("channel")
node_channel_candidates = sorted({node.channel for node in nodes if node.channel})
if channel_param and channel_param in node_channel_candidates:
selected_channel = channel_param
elif channel_param and channel_param in all_channels:
selected_channel = channel_param
elif node_channel_candidates:
selected_channel = node_channel_candidates[0]
elif all_channels:
selected_channel = all_channels[0]
else:
selected_channel = None
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)
@@ -1383,18 +1436,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
@@ -1403,7 +1450,16 @@ 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,
@@ -1411,17 +1467,25 @@ 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,
default_activity=DEFAULT_ACTIVITY_OPTION,
all_channels=all_channels,
selected_channel=selected_channel,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
@@ -1572,17 +1636,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