Merge pull request #77 from pablorevilla-meshtastic/revert-73-maphours-stacked

Revert "Maphours changes stacked with filtering additions"
This commit is contained in:
Pablo Revilla
2025-10-15 21:36:29 -07:00
committed by GitHub
7 changed files with 215 additions and 1146 deletions

View File

@@ -24,14 +24,7 @@ async def get_fuzzy_nodes(query):
return result.scalars()
async def get_packets(
node_id=None,
portnum=None,
after=None,
before=None,
limit=None,
channel: str | list[str] | tuple[str, ...] | None = None,
):
async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None):
async with database.async_session() as session:
q = select(Packet)
@@ -43,13 +36,6 @@ async def get_packets(
q = q.where(Packet.import_time > after)
if before:
q = q.where(Packet.import_time < before)
if channel:
if isinstance(channel, (list, tuple, set)):
lowered = [c.lower() for c in channel if isinstance(c, str) and c]
if lowered:
q = q.where(func.lower(Packet.channel).in_(lowered))
elif isinstance(channel, str):
q = q.where(func.lower(Packet.channel) == channel.lower())
q = q.order_by(Packet.import_time.desc())
@@ -161,21 +147,17 @@ async def get_mqtt_neighbors(since):
# We count the total amount of packages
# This is to be used by /stats in web.py
async def get_total_packet_count(channel: str | None = None) -> int:
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
if channel:
q = q.where(func.lower(Packet.channel) == channel.lower())
result = await session.execute(q)
return result.scalar() # Return the total count of packets
# We count the total amount of seen packets
async def get_total_packet_seen_count(channel: str | None = None) -> int:
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
if channel:
q = q.where(func.lower(PacketSeen.channel) == channel.lower())
result = await session.execute(q)
return result.scalar() # Return the` total count of seen packets
@@ -275,13 +257,7 @@ async def get_node_traffic(node_id: int):
return []
async def get_nodes(
role=None,
channel=None,
hw_model=None,
days_active=None,
active_within: timedelta | None = None,
):
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
"""
Fetches nodes from the database based on optional filtering criteria.
@@ -289,8 +265,6 @@ async def get_nodes(
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.
@@ -310,12 +284,8 @@ async def get_nodes(
if hw_model is not None:
query = query.where(Node.hw_model == hw_model)
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)
if days_active is not None:
query = query.where(Node.last_update > datetime.now() - timedelta(days_active))
# Exclude nodes where last_update is an empty string
query = query.where(Node.last_update != "")

View File

@@ -17,61 +17,19 @@
/* Nested reply style */
.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; }
.replying-to .reply-preview { color: #aaa; }
.filter-bar {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.filter-label {
color: #ccc;
font-size: 14px;
}
#chatChannelSelect {
padding: 4px 6px;
background: #444;
color: #fff;
border: none;
border-radius: 4px;
}
.status-message {
color: #bbb;
margin-bottom: 12px;
display: none;
}
{% endblock %}
{% block body %}
<div id="chat-container">
<div class="filter-bar">
<label for="chatChannelSelect" class="filter-label">Channel</label>
<select id="chatChannelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
</div>
<div id="chat-status" class="status-message"></div>
<div class="container" id="chat-log"></div>
</div>
<script>
const CHANNEL_PRESETS = ["LongFast", "MediumSlow"];
document.addEventListener("DOMContentLoaded", async () => {
const chatContainer = document.querySelector("#chat-log");
const channelSelect = document.getElementById("chatChannelSelect");
const statusMessage = document.getElementById("chat-status");
if (!chatContainer) {
console.error("#chat-log not found");
return;
}
if (!chatContainer) return console.error("#chat-log not found");
let lastTime = null;
let currentChannel = "";
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatTranslations = {};
@@ -94,74 +52,6 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
async function fetchChannels() {
try {
const res = await fetch("/api/channels");
if (!res.ok) return [];
const json = await res.json();
return Array.isArray(json.channels) ? json.channels : [];
} catch (err) {
console.error("Channel fetch failed:", err);
return [];
}
}
function populateChannelSelect(channels) {
if (!channelSelect) return;
const unique = Array.from(new Set((channels || []).filter(ch => typeof ch === "string" && ch.trim().length > 0))).sort((a, b) => a.localeCompare(b));
const prioritized = [];
CHANNEL_PRESETS.forEach(preset => {
const idx = unique.indexOf(preset);
if (idx >= 0) {
prioritized.push(unique[idx]);
unique.splice(idx, 1);
}
});
const ordered = [...new Set([...prioritized, ...unique])];
channelSelect.innerHTML = "";
const allOption = document.createElement("option");
allOption.value = "";
allOption.textContent = "All Channels";
allOption.setAttribute("data-translate-lang", "all_channels");
channelSelect.appendChild(allOption);
ordered.forEach(ch => {
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
channelSelect.appendChild(opt);
});
let preferred = currentChannel;
if (preferred && !ordered.includes(preferred)) {
preferred = "";
}
if (!preferred) {
preferred = CHANNEL_PRESETS.find(preset => ordered.includes(preset)) || "";
}
channelSelect.value = preferred;
currentChannel = channelSelect.value;
}
function clearChat() {
chatContainer.innerHTML = "";
renderedPacketIds.clear();
packetMap.clear();
lastTime = null;
updateEmptyState();
}
function updateEmptyState() {
if (!statusMessage) return;
if (chatContainer.childElementCount === 0) {
statusMessage.textContent = currentChannel
? `No messages for ${currentChannel} yet.`
: "No messages yet.";
statusMessage.style.display = "block";
} else {
statusMessage.textContent = "";
statusMessage.style.display = "none";
}
}
function renderPacket(packet, highlight = false) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
@@ -217,7 +107,6 @@ document.addEventListener("DOMContentLoaded", async () => {
applyTranslations(chatTranslations, div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
updateEmptyState();
}
function renderPacketsEnsureDescending(packets, highlight = false) {
@@ -226,28 +115,21 @@ document.addEventListener("DOMContentLoaded", async () => {
for (let i = sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight);
}
function buildChatUrl({ since } = {}) {
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit", "100");
if (since) url.searchParams.set("since", since);
if (currentChannel) url.searchParams.set("channel", currentChannel);
return url;
}
async function fetchInitial() {
try {
const resp = await fetch(buildChatUrl());
const resp = await fetch("/api/chat?limit=100");
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
lastTime = data?.latest_import_time || lastTime;
updateEmptyState();
} catch(err) { console.error("Initial fetch error:", err); }
}
async function fetchUpdates() {
if (!lastTime) return;
try {
const resp = await fetch(buildChatUrl({ since: lastTime }));
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit","100");
if(lastTime) url.searchParams.set("since", lastTime);
const resp = await fetch(url);
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets, true);
lastTime = data?.latest_import_time || lastTime;
@@ -263,21 +145,7 @@ document.addEventListener("DOMContentLoaded", async () => {
} catch(err){ console.error("Chat translation load failed:", err); }
}
if (channelSelect) {
channelSelect.addEventListener("change", async e => {
currentChannel = e.target.value;
clearChat();
await fetchInitial();
});
}
await loadTranslations();
const channels = await fetchChannels();
populateChannelSelect(channels);
if (chatTranslations && channelSelect) {
applyTranslations(chatTranslations, channelSelect.parentElement || channelSelect);
}
updateEmptyState();
await fetchInitial();
setInterval(fetchUpdates, 5000);
});

View File

@@ -11,38 +11,11 @@
padding: 2px 8px;
font-size: 0.85rem;
}
.filter-controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filter-label {
color: #ccc;
font-size: 14px;
margin-bottom: 0;
}
#firehoseChannelSelect {
padding: 4px 6px;
background: #444;
color: #fff;
border: none;
border-radius: 4px;
}
.status-message {
color: #bbb;
margin-top: 12px;
display: none;
}
{% endblock %}
{% block body %}
<div class="container">
<form class="d-flex align-items-center justify-content-center flex-wrap gap-2 mb-2">
<form class="d-flex align-items-center justify-content-between mb-2">
{% set options = {
1: "Text Message",
3: "Position",
@@ -52,155 +25,55 @@
70: "Trace Route",
}
%}
<div class="filter-controls">
<label for="firehoseChannelSelect" class="filter-label">Channel</label>
<select id="firehoseChannelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
</div>
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary">Pause</button>
</form>
<div class="row">
<div class="col-xs">
<div id="packet_list">
{% for packet in packets %}
{% include 'packet.html' %}
{% endfor %}
</div>
<div id="firehose-empty-state" class="status-message"{% if packets|length > 0 %} style="display:none;"{% endif %}>
<div class="col-xs" id="packet_list">
{% for packet in packets %}
{% include 'packet.html' %}
{% else %}
No packets found.
</div>
{% endfor %}
</div>
</div>
</div>
<script>
const CHANNEL_PRESETS = ["LongFast", "MediumSlow"];
let lastTime = {{ (last_time or None) | tojson }};
let portnum = {{ (portnum if portnum is not none else '') | tojson }};
let lastTime = null;
let portnum = "{{ portnum if portnum is not none else '' }}";
let updatesPaused = false;
let channelFilter = {{ (channel or '') | tojson }};
// Use firehose_interval from config (seconds), default to 3s if not set
let firehoseInterval = {{ site_config["site"]["firehose_interval"] | default(3) }};
const firehoseInterval = {{ site_config["site"]["firehose_interval"] | default(3) }};
if (firehoseInterval < 0) firehoseInterval = 0;
document.addEventListener("DOMContentLoaded", async () => {
const packetList = document.getElementById("packet_list");
const pauseBtn = document.getElementById("pause-button");
const channelSelect = document.getElementById("firehoseChannelSelect");
const emptyState = document.getElementById("firehose-empty-state");
function fetchUpdates() {
if (updatesPaused || firehoseInterval === 0) return;
function updateEmptyState() {
if (!emptyState || !packetList) return;
if (packetList.children.length > 0) {
emptyState.style.display = "none";
} else {
emptyState.style.display = "block";
}
}
const url = new URL("/firehose/updates", window.location.origin);
if (lastTime) url.searchParams.set("last_time", lastTime);
if (portnum) url.searchParams.set("portnum", portnum);
async function fetchChannels() {
try {
const res = await fetch("/api/channels");
if (!res.ok) return [];
const json = await res.json();
return Array.isArray(json.channels) ? json.channels : [];
} catch (err) {
console.error("Channel fetch failed:", err);
return [];
}
}
fetch(url)
.then(res => res.json())
.then(data => {
if (data.packets && data.packets.length > 0) {
lastTime = data.last_time;
const list = document.getElementById("packet_list");
function populateChannelSelect(channels) {
if (!channelSelect) return;
const unique = Array.from(new Set((channels || []).filter(ch => typeof ch === "string" && ch.trim().length > 0))).sort((a, b) => a.localeCompare(b));
const prioritized = [];
CHANNEL_PRESETS.forEach(preset => {
const idx = unique.indexOf(preset);
if (idx >= 0) {
prioritized.push(unique[idx]);
unique.splice(idx, 1);
for (const html of data.packets.reverse()) {
list.insertAdjacentHTML("afterbegin", html);
}
}
});
const ordered = [...new Set([...prioritized, ...unique])];
channelSelect.innerHTML = "";
const allOption = document.createElement("option");
allOption.value = "";
allOption.textContent = "All Channels";
allOption.setAttribute("data-translate-lang", "all_channels");
channelSelect.appendChild(allOption);
ordered.forEach(ch => {
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
channelSelect.appendChild(opt);
});
if (channelFilter && !ordered.includes(channelFilter)) {
const opt = document.createElement("option");
opt.value = channelFilter;
opt.textContent = channelFilter;
channelSelect.appendChild(opt);
}
channelSelect.value = channelFilter || "";
channelFilter = channelSelect.value;
}
function buildUpdatesUrl({ useLastTime = true } = {}) {
const url = new URL("/firehose/updates", window.location.origin);
if (useLastTime && lastTime) url.searchParams.set("last_time", lastTime);
if (portnum) url.searchParams.set("portnum", portnum);
if (channelFilter) url.searchParams.set("channel", channelFilter);
return url;
}
async function fetchUpdates({ force = false, reset = false } = {}) {
if (!force && (updatesPaused || firehoseInterval === 0)) return;
try {
const url = buildUpdatesUrl({ useLastTime: !reset });
const res = await fetch(url);
if (!res.ok) throw new Error(`Fetch failed with status ${res.status}`);
const data = await res.json();
if (reset && packetList) {
packetList.innerHTML = "";
}
if (Array.isArray(data.packets) && data.packets.length > 0 && packetList) {
data.packets.slice().reverse().forEach(html => {
packetList.insertAdjacentHTML("afterbegin", html);
});
if (data.last_time) lastTime = data.last_time;
} else if (reset) {
lastTime = data.last_time || null;
}
} catch (err) {
})
.catch(err => {
console.error("Update fetch failed:", err);
} finally {
updateEmptyState();
}
}
if (pauseBtn) {
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
});
}
}
if (channelSelect) {
channelSelect.addEventListener("change", async e => {
channelFilter = e.target.value;
lastTime = null;
if (packetList) packetList.innerHTML = "";
updateEmptyState();
await fetchUpdates({ force: true, reset: true });
});
}
const channels = await fetchChannels();
populateChannelSelect(channels);
updateEmptyState();
document.addEventListener("DOMContentLoaded", () => {
const pauseBtn = document.getElementById("pause-button");
const portnumSelector = document.querySelector('select[name="portnum"]');
if (portnumSelector) {
@@ -212,12 +85,15 @@ document.addEventListener("DOMContentLoaded", async () => {
});
}
pauseBtn.addEventListener("click", () => {
updatesPaused = !updatesPaused;
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
});
// Start fetching updates with configurable interval
await fetchUpdates({ force: true });
fetchUpdates();
if (firehoseInterval > 0) {
setInterval(() => {
fetchUpdates();
}, firehoseInterval * 1000);
setInterval(fetchUpdates, firehoseInterval * 1000);
}
});
</script>

View File

@@ -23,20 +23,10 @@
#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;
@@ -82,12 +72,6 @@
{% 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;">
@@ -115,20 +99,8 @@ async function loadTranslations() {
}
// Initialize map AFTER translations are loaded
loadTranslations().then(async () => {
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');
@@ -163,8 +135,6 @@ loadTranslations().then(async () => {
}{{ "," if not loop.last else "" }}
{% endfor %}
];
const channelSet = new Set();
let channelList = [];
const portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"};
@@ -192,37 +162,18 @@ loadTranslations().then(async () => {
return color;
}
function channelKey(channel) {
if (typeof channel === 'string' && channel.trim().length > 0) {
return channel;
}
return 'Unknown';
}
async function fetchAdditionalChannels() {
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 list fetch failed:', err);
return [];
}
}
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 = channelKey(node.channel);
channelSet.add(category);
let category = node.channel;
channels.add(category);
let color = hashToColor(category);
let popupContent = `<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
@@ -258,10 +209,6 @@ loadTranslations().then(async () => {
if (customView) map.setView([customView.lat,customView.lng],customView.zoom);
else map.fitBounds(areaBounds);
const extraChannels = await fetchAdditionalChannels();
extraChannels.forEach(raw => channelSet.add(channelKey(raw)));
channelList = Array.from(channelSet).sort();
// ---- LocalStorage for Filter Preferences ----
const FILTER_STORAGE_KEY = 'meshview_map_filters';
@@ -287,13 +234,16 @@ loadTranslations().then(async () => {
});
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters));
console.log('Filters saved to localStorage:', filters);
}
function loadFiltersFromLocalStorage() {
try {
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
const filters = JSON.parse(stored);
console.log('Filters loaded from localStorage:', filters);
return filters;
}
} catch (error) {
console.error('Error loading filters from localStorage:', error);
@@ -325,8 +275,20 @@ loadTranslations().then(async () => {
function resetFiltersToDefaults() {
localStorage.removeItem(FILTER_STORAGE_KEY);
console.log('Filters reset to defaults');
// Reset routers only filter
document.getElementById("filter-routers-only").checked = false;
renderChannelFilters(null);
// Reset all channel filters to checked (default)
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
checkbox.checked = true;
}
});
updateMarkers();
const button = document.getElementById('reset-filters-button');
@@ -340,35 +302,48 @@ loadTranslations().then(async () => {
}, 2000);
}
window.resetFiltersToDefaults = resetFiltersToDefaults;
// ---- Filters ----
const filterLabel = document.getElementById("filter-routers-label");
filterLabel.textContent = t.show_routers_only || "Show Routers Only";
const routersOnlyCheckbox = document.getElementById("filter-routers-only");
let filterContainer = document.getElementById("filter-container");
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
let color = hashToColor(channel);
let label = document.createElement('label');
label.style.color=color;
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
filterContainer.appendChild(label);
});
// Load saved filters from localStorage
const savedFilters = loadFiltersFromLocalStorage();
if (savedFilters) {
routersOnlyCheckbox.checked = savedFilters.routersOnly || false;
// Apply routers only filter
document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false;
// Apply channel filters
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox && savedFilters.channels.hasOwnProperty(channel)) {
checkbox.checked = savedFilters.channels[channel];
}
});
}
routersOnlyCheckbox.addEventListener("change", updateMarkers);
renderChannelFilters(savedFilters);
function updateMarkers() {
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
nodes.forEach(node => {
let category = channelKey(node.channel);
let category=node.channel;
let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
let shouldShow=(!checkbox || checkbox.checked) && (!showRoutersOnly || node.isRouter);
let shouldShow=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(true);
}
}
function getActiveChannels() {
@@ -390,11 +365,7 @@ loadTranslations().then(async () => {
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
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();
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
navigator.clipboard.writeText(shareUrl).then(()=>{
const orig = shareBtn.textContent;
shareBtn.textContent = '✓ Link Copied!';
@@ -507,78 +478,33 @@ loadTranslations().then(async () => {
// ---- Packet fetching ----
let lastImportTime=null;
const mapInterval={{ site_config["site"]["map_interval"]|default(3) }};
function buildPacketsUrl(base){
const active = getActiveChannels();
const url = new URL(base, window.location.origin);
url.searchParams.delete('channel');
if (active.length) {
active.forEach(ch => url.searchParams.append('channel', ch));
}
if (url.origin === window.location.origin) {
return url.pathname + (url.search || '') + (url.hash || '');
}
return url.toString();
}
function fetchLatestPacket(){
return fetch(buildPacketsUrl(`/api/packets?limit=1`))
.then(r=>r.json())
.then(data=>{
if(data.packets && data.packets.length>0){
lastImportTime=data.packets[0].import_time;
} else {
lastImportTime=new Date().toISOString();
}
})
.catch(err=>{
console.error('fetchLatestPacket failed:', err);
});
fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{
if(data.packets && data.packets.length>0) lastImportTime=data.packets[0].import_time;
else lastImportTime=new Date().toISOString();
}).catch(err=>console.error(err));
}
function fetchNewPackets(){
if(!lastImportTime) return;
const baseUrl = `/api/packets?since=${encodeURIComponent(lastImportTime)}`;
return fetch(buildPacketsUrl(baseUrl))
.then(r=>r.json())
.then(data=>{
if(!data.packets||data.packets.length===0) return;
let latestSeen=lastImportTime;
data.packets.forEach(packet=>{
if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time;
let marker=markerById[packet.from_node_id];
if(marker){
let nodeData=nodeMap.get(packet.from_node_id);
if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
}
});
if(latestSeen) lastImportTime=latestSeen;
})
.catch(err=>{
console.error('fetchNewPackets failed:', err);
fetch(`/api/packets?since=${lastImportTime}`).then(r=>r.json()).then(data=>{
if(!data.packets||data.packets.length===0) return;
let latestSeen=lastImportTime;
data.packets.forEach(packet=>{
if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time;
let marker=markerById[packet.from_node_id];
if(marker){
let nodeData=nodeMap.get(packet.from_node_id);
if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
}
});
if(latestSeen) lastImportTime=latestSeen;
}).catch(err=>console.error(err));
}
let packetInterval=null;
async function startPacketFetcher(resetImportTime=true){
if (mapInterval <= 0) return;
stopPacketFetcher();
if (resetImportTime) {
lastImportTime = null;
}
if (!lastImportTime) {
await fetchLatestPacket();
}
await fetchNewPackets();
packetInterval = setInterval(()=>{ fetchNewPackets(); }, mapInterval*1000);
}
function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } }
function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
async function restartPacketFetcher(resetImportTime=false){
if(mapInterval<=0) return;
if(document.hidden) return;
await startPacketFetcher(resetImportTime);
}
document.addEventListener("visibilitychange",function(){
if(document.hidden) stopPacketFetcher();
else restartPacketFetcher(false);
});
if(mapInterval>0) startPacketFetcher(true);
document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); });
if(mapInterval>0) startPacketFetcher();
});
</script>
{% endblock %}

View File

@@ -87,12 +87,6 @@
<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...">
@@ -129,23 +123,6 @@
</div>
<script>
const activityRangeSelect = document.getElementById('activity-range');
const urlParams = new URLSearchParams(window.location.search);
let selectedChannel = urlParams.get('channel') || '';
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 = {
@@ -227,80 +204,28 @@ const edges = [
let filteredNodes = [];
let filteredEdges = [];
let selectedChannel = 'LongFast';
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 [];
}
}
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];
}
}
function populateChannelDropdown() {
const sel = document.getElementById('channel-select');
if (!sel) return;
sel.innerHTML = '';
const allOpt = document.createElement('option');
allOpt.value = '';
allOpt.textContent = 'All Channels';
allOpt.setAttribute('data-translate-lang','all_channels');
sel.appendChild(allOpt);
channelOptions.forEach(ch=>{
const unique = [...new Set(nodes.map(n=>n.channel).filter(Boolean))].sort();
unique.forEach(ch=>{
const opt = document.createElement('option');
opt.value = ch;
opt.text = ch;
if (selectedChannel === ch) {
opt.selected = true;
}
opt.value=ch; opt.text=ch;
if(ch==='LongFast') opt.selected=true;
sel.appendChild(opt);
});
if (!hasChannelSelection(selectedChannel)) {
sel.value = '';
}
selectedChannel = sel.value;
filterByChannel();
}
function filterByChannel(isInitial=false) {
const sel = document.getElementById('channel-select');
if (sel) {
selectedChannel = sel.value;
}
if (hasChannelSelection(selectedChannel)) {
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
} else {
filteredNodes = [...nodes];
}
function filterByChannel() {
selectedChannel = document.getElementById('channel-select').value;
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
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();
}
@@ -364,10 +289,7 @@ function searchNode(){
else alert("Node not found in current channel!");
}
initializeChannelOptions().then(() => {
populateChannelDropdown();
filterByChannel(true);
});
populateChannelDropdown();
window.addEventListener('resize', ()=>chart.resize());
</script>
{% endblock %}

View File

@@ -77,90 +77,14 @@
font-weight: bold;
}
.table-wrapper {
max-height: 400px;
overflow: auto;
border: 1px solid #3a3d42;
border-radius: 6px;
margin-top: 10px;
}
.stats-table {
width: 100%;
border-collapse: collapse;
color: #ddd;
}
.stats-table th,
.stats-table td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid #3a3d42;
font-size: 13px;
white-space: nowrap;
}
.stats-table th {
cursor: pointer;
position: relative;
user-select: none;
color: #f0f0f0;
}
.stats-table th.sorted-asc::after,
.stats-table th.sorted-desc::after {
content: "";
position: absolute;
right: 8px;
top: 50%;
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
}
.stats-table th.sorted-asc::after {
border-bottom: 6px solid #66bb6a;
transform: translateY(-75%);
}
.stats-table th.sorted-desc::after {
border-top: 6px solid #66bb6a;
transform: translateY(-25%);
}
.stats-table tbody tr:hover {
background-color: #2f3338;
}
.empty-table {
padding: 16px;
text-align: center;
color: #aaa;
font-size: 14px;
}
.filter-bar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
margin-bottom: 20px;
}
.filter-label {
color: #ccc;
font-size: 14px;
}
#channelSelect {
margin-bottom: 8px;
padding: 4px 6px;
background:#444;
color:#fff;
border:none;
border-radius:4px;
}
{% endblock %}
{% block head %}
@@ -171,25 +95,18 @@
<div class="main-container">
<h2 class="main-header" data-translate-lang="mesh_stats_summary">Mesh Statistics - Summary (all available in Database)</h2>
<div class="filter-bar">
<label for="channelSelect" class="filter-label">Channel</label>
<select id="channelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
</div>
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_nodes">Total Nodes</p>
<div class="summary-count" id="summaryTotalNodes">{{ "{:,}".format(total_nodes) }}</div>
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_packets">Total Packets</p>
<div class="summary-count" id="summaryTotalPackets">{{ "{:,}".format(total_packets) }}</div>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
<div class="summary-count" id="summaryTotalPacketsSeen">{{ "{:,}".format(total_packets_seen) }}</div>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
</div>
</div>
@@ -205,6 +122,9 @@
<!-- Packet Types Pie Chart with Channel Selector -->
<div class="card-section">
<p class="section-header" data-translate-lang="packet_types_last_24h">Packet Types - Last 24 Hours</p>
<select id="channelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
<button class="expand-btn" data-chart="chart_packet_types" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_packet_types" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_packet_types" class="chart"></div>
@@ -253,29 +173,9 @@
<div class="card-section">
<p class="section-header" data-translate-lang="channel_breakdown">Channel Breakdown</p>
<button class="expand-btn" data-chart="chart_channel" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_channel" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="nodes_table_title">Nodes Overview</p>
<div class="table-wrapper">
<table class="stats-table" id="nodesTable">
<thead>
<tr>
<th data-sort-key="long_name">Long Name</th>
<th data-sort-key="short_name">Short Name</th>
<th data-sort-key="role">Role</th>
<th data-sort-key="hw_model">Hardware</th>
<th data-sort-key="channel">Channel</th>
<th data-sort-key="last_update">Last Seen</th>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="empty-table" id="nodesTableEmpty" style="display:none;">No nodes found for the selected channel.</div>
</div>
</div>
</div>
<!-- Modal for expanded charts -->
@@ -301,28 +201,12 @@ const PORTNUM_LABELS = {
71: "Neighbor Info"
};
const PORT_CONFIG = [
{ port: 1, color: '#ff5722', domId: 'chart_portnum_1', totalId: 'total_portnum_1' },
{ port: 3, color: '#2196f3', domId: 'chart_portnum_3', totalId: 'total_portnum_3' },
{ port: 4, color: '#9c27b0', domId: 'chart_portnum_4', totalId: 'total_portnum_4' },
{ port: 67, color: '#ffeb3b', domId: 'chart_portnum_67', totalId: 'total_portnum_67' },
{ port: 70, color: '#795548', domId: 'chart_portnum_70', totalId: 'total_portnum_70' },
{ port: 71, color: '#4caf50', domId: 'chart_portnum_71', totalId: 'total_portnum_71' },
];
const CHANNEL_PRESETS = ["LongFast", "MediumSlow"];
let currentChannel = "";
let nodeTableData = [];
let nodeTableSortKey = "last_update";
let nodeTableSortDirection = "desc";
// --- Fetch & Processing ---
async function fetchStats(period_type,length,portnum=null,channel=null){
try{
let url=`/api/stats?period_type=${period_type}&length=${length}`;
if(portnum!==null) url+=`&portnum=${portnum}`;
if(channel) url+=`&channel=${encodeURIComponent(channel)}`;
if(channel) url+=`&channel=${channel}`;
const res=await fetch(url);
if(!res.ok) return [];
const json=await res.json();
@@ -330,212 +214,28 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
async function fetchNodes(channel=null){
try{
const base="/api/nodes";
const url=channel?`${base}?channel=${encodeURIComponent(channel)}`:base;
const res=await fetch(url);
const json=await res.json();
return json.nodes||[];
}catch{return [];}
}
async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} }
async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
async function fetchChannels(){
try{
const res = await fetch("/api/channels");
const json = await res.json();
return json.channels || [];
}catch{return [];}
}
async function fetchSummary(channel=null){
try{
const base="/api/stats/summary";
const url=channel?`${base}?channel=${encodeURIComponent(channel)}`:base;
const res=await fetch(url);
if(!res.ok) return null;
return await res.json();
}catch{return null;}
}
function processCountField(nodes,field){
const counts={};
(nodes||[]).forEach(n=>{
const key=n?.[field]||"Unknown";
counts[key]=(counts[key]||0)+1;
});
return Object.entries(counts).map(([name,value])=>({name,value}));
}
function updateTotalCount(domId,data){
const el=document.getElementById(domId);
if(!el) return;
const dataset=Array.isArray(data)?data:[];
const total=dataset.reduce((acc,d)=>acc+(d?.count??d?.packet_count??0),0);
el.textContent=`Total: ${total.toLocaleString()}`;
}
function prepareTopN(data=[],n=20){
const sorted=[...(data||[])].sort((a,b)=>b.value-a.value);
const top=sorted.slice(0,n);
if(sorted.length>n){
const otherValue=sorted.slice(n).reduce((sum,item)=>sum+item.value,0);
top.push({name:"Other", value:otherValue});
}
return top;
}
function formatDateString(value){
if(!value) return "—";
const date = new Date(value);
if(Number.isNaN(date.getTime())) return value;
return `${date.toLocaleDateString()} ${date.toLocaleTimeString()}`;
}
function normalizeString(value){
return (value ?? "").toString().toLowerCase();
}
function applyNodeTableSort(render=true){
const dir = nodeTableSortDirection === "asc" ? 1 : -1;
nodeTableData.sort((a,b)=>{
let lhs=a[nodeTableSortKey];
let rhs=b[nodeTableSortKey];
if(nodeTableSortKey==="last_update"){
lhs = lhs ?? -Infinity;
rhs = rhs ?? -Infinity;
return (lhs - rhs) * dir;
}
const left = normalizeString(lhs);
const right = normalizeString(rhs);
if(left===right) return 0;
return left > right ? dir : -dir;
});
if(render) renderNodeTableRows();
}
function renderNodeTableRows(){
const tbody=document.querySelector("#nodesTable tbody");
const emptyMessage=document.getElementById("nodesTableEmpty");
if(!tbody) return;
tbody.innerHTML="";
if(!nodeTableData.length){
if(emptyMessage) emptyMessage.style.display="block";
return;
}
if(emptyMessage) emptyMessage.style.display="none";
nodeTableData.forEach(node=>{
const tr=document.createElement("tr");
tr.innerHTML=`
<td>${node.long_name}</td>
<td>${node.short_name}</td>
<td>${node.role}</td>
<td>${node.hw_model}</td>
<td>${node.channel || "—"}</td>
<td>${node.last_update_display}</td>
`;
tbody.appendChild(tr);
});
updateSortIndicators();
}
function setNodeTableData(rawNodes){
nodeTableData = (rawNodes||[]).map(n=>{
const lastUpdateRaw = n?.last_update ?? null;
const lastUpdateDate = lastUpdateRaw ? new Date(lastUpdateRaw) : null;
return {
long_name: n?.long_name || "—",
short_name: n?.short_name || "—",
role: n?.role || "Unknown",
hw_model: n?.hw_model || "Unknown",
channel: n?.channel || "",
last_update: lastUpdateDate ? lastUpdateDate.getTime() : null,
last_update_display: formatDateString(lastUpdateRaw),
};
});
applyNodeTableSort(false);
renderNodeTableRows();
}
function updateSortIndicators(){
document.querySelectorAll("#nodesTable thead th[data-sort-key]").forEach(th=>{
th.classList.remove("sorted-asc","sorted-desc");
if(th.dataset.sortKey === nodeTableSortKey){
th.classList.add(nodeTableSortDirection === "asc" ? "sorted-asc" : "sorted-desc");
}
});
}
function handleNodeTableSort(event){
const key=event.currentTarget?.dataset?.sortKey;
if(!key) return;
if(nodeTableSortKey===key){
nodeTableSortDirection = nodeTableSortDirection === "asc" ? "desc" : "asc";
}else{
nodeTableSortKey = key;
nodeTableSortDirection = key === "last_update" ? "desc" : "asc";
}
applyNodeTableSort();
}
function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); }
function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; }
function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; }
// --- Chart Rendering ---
function renderChart(domId,data,type,color){
const el=document.getElementById(domId);
if(!el) return null;
const existing=echarts.getInstanceByDom(el);
if(existing) existing.dispose();
const chart=echarts.init(el);
const source=Array.isArray(data)?data:[];
const periods=source.map(d=>{
const periodValue=d?.period;
return (periodValue||periodValue===0) ? String(periodValue) : '';
});
const counts=source.map(d=>d?.count??d?.packet_count??0);
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}},
yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}},
series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]
});
return chart;
}
function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; }
function renderPieChart(elId,data,name){
const el=document.getElementById(elId);
if(!el) return null;
const existing=echarts.getInstanceByDom(el);
if(existing) existing.dispose();
const chart=echarts.init(el);
const top20=prepareTopN(data,20);
chart.setOption({
backgroundColor:"#272b2f",
tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`},
series:[{
name:name,
type:"pie",
radius:["30%","70%"],
center:["50%","50%"],
avoidLabelOverlap:true,
itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2},
label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10},
labelLine:{show:true,length:10,length2:6},
data:top20
}]
});
return chart;
}
function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; }
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
const requests = PORT_CONFIG.map(async ({port}) => {
const data = await fetchStats('hour',24,port,channel);
const total = (data || []).reduce((sum,d)=>sum+(d?.count??d?.packet_count??0),0);
return {portnum: port, count: total};
const portnums = [1,3,4,67,70,71];
const requests = portnums.map(async pn => {
const data = await fetchStats('hour',24,pn,channel);
const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
return {portnum: pn, count: total};
});
const allData = await fetchStats('hour',24,null,channel);
const totalAll = (allData||[]).reduce((sum,d)=>sum+(d?.count??d?.packet_count??0),0);
const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
const results = await Promise.all(requests);
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
const other = Math.max(totalAll - trackedTotal,0);
@@ -543,137 +243,44 @@ async function fetchPacketTypeBreakdown(channel=null) {
return results;
}
function updateSummaryCards(summary){
if(!summary) return;
const nodesEl=document.getElementById("summaryTotalNodes");
const packetsEl=document.getElementById("summaryTotalPackets");
const packetsSeenEl=document.getElementById("summaryTotalPacketsSeen");
if(nodesEl) nodesEl.textContent=Number(summary.total_nodes||0).toLocaleString();
if(packetsEl) packetsEl.textContent=Number(summary.total_packets||0).toLocaleString();
if(packetsSeenEl) packetsSeenEl.textContent=Number(summary.total_packets_seen||0).toLocaleString();
}
function populateChannelSelect(channels){
const select=document.getElementById("channelSelect");
if(!select) return;
const unique=Array.from(new Set((channels||[]).filter(ch=>typeof ch==="string" && ch.trim().length>0))).sort((a,b)=>a.localeCompare(b));
const prioritized=[];
CHANNEL_PRESETS.forEach(preset=>{
const idx=unique.indexOf(preset);
if(idx>=0){
prioritized.push(unique[idx]);
unique.splice(idx,1);
}
});
const ordered=[...new Set([...prioritized, ...unique])];
select.innerHTML="";
const allOption=document.createElement("option");
allOption.value="";
allOption.textContent="All Channels";
allOption.setAttribute("data-translate-lang","all_channels");
select.appendChild(allOption);
ordered.forEach(ch=>{
const opt=document.createElement("option");
opt.value=ch;
opt.textContent=ch;
select.appendChild(opt);
});
let preferred=currentChannel;
if(!preferred){
preferred=CHANNEL_PRESETS.find(preset=>ordered.includes(preset))||"";
}
if(preferred && !ordered.includes(preferred)){
preferred="";
}
select.value=preferred;
currentChannel=select.value;
}
function setPortChart(port, chart){
switch(port){
case 1: chartPortnum1 = chart; break;
case 3: chartPortnum3 = chart; break;
case 4: chartPortnum4 = chart; break;
case 67: chartPortnum67 = chart; break;
case 70: chartPortnum70 = chart; break;
case 71: chartPortnum71 = chart; break;
default: break;
}
}
// --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1;
let chartHwModel, chartRole, chartChannel;
let chartPacketTypes;
let isRefreshing=false;
async function refreshDashboard(){
if(isRefreshing) return;
isRefreshing=true;
const channel=currentChannel||null;
try{
const [summary, dailyAllData, dailyPort1Data, hourlyAllData, portDataSets, nodes, packetTypesData] = await Promise.all([
fetchSummary(channel),
fetchStats('day',14,null,channel),
fetchStats('day',14,1,channel),
fetchStats('hour',24,null,channel),
Promise.all(PORT_CONFIG.map(cfg=>fetchStats('hour',24,cfg.port,channel))),
fetchNodes(channel),
fetchPacketTypeBreakdown(channel)
]);
updateSummaryCards(summary);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
PORT_CONFIG.forEach((cfg,idx)=>{
const data=portDataSets?.[idx]||[];
updateTotalCount(cfg.totalId,data);
const chart=renderChart(cfg.domId,data,'bar',cfg.color);
setPortChart(cfg.port,chart);
});
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
setNodeTableData(nodes);
const formatted=(packetTypesData||[]).filter(d=>d.count>0).map(d=>({
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
chartPacketTypes=renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
} finally {
isRefreshing=false;
}
}
async function init(){
const channels = await fetchChannels();
populateChannelSelect(channels);
const select=document.getElementById("channelSelect");
if(select && !select.dataset.listenerAttached){
select.addEventListener("change",async e=>{
currentChannel=e.target.value;
await refreshDashboard();
});
select.dataset.listenerAttached="true";
}
document.querySelectorAll("#nodesTable thead th[data-sort-key]").forEach(th=>{
if(!th.dataset.listenerAttached){
th.addEventListener("click",handleNodeTableSort);
th.dataset.listenerAttached="true";
}
});
await refreshDashboard();
const select = document.getElementById("channelSelect");
channels.forEach(ch=>{ const opt = document.createElement("option"); opt.value = ch; opt.textContent = ch; select.appendChild(opt); });
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
const dailyPort1Data=await fetchStats('day',14,1);
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
const portnums=[1,3,4,67,70,71];
const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50'];
const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71'];
const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71'];
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
for(let i=0;i<portnums.length;i++){ updateTotalCount(totalIds[i],allData[i]); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]); }
const nodes=await fetchNodes();
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
const packetTypesData = await fetchPacketTypeBreakdown();
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
}
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
@@ -735,6 +342,14 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
});
});
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
const packetTypesData = await fetchPacketTypeBreakdown(channel);
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
chartPacketTypes?.dispose();
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
});
init();
// --- Translation Loader ---

View File

@@ -33,17 +33,6 @@ 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
@@ -197,17 +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()
@@ -447,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,
),
@@ -488,18 +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]
@@ -1203,15 +1159,7 @@ 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)
nodes = await store.get_nodes(days_active=3)
# Filter out nodes with no latitude
nodes = [node for node in nodes if node.last_lat is not None]
@@ -1244,8 +1192,6 @@ async def map(request):
text=template.render(
nodes=nodes,
custom_view=custom_view,
activity_filters=ACTIVITY_FILTERS,
selected_activity=selected_activity,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
@@ -1384,26 +1330,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)
@@ -1419,12 +1361,18 @@ 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
@@ -1433,16 +1381,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,
@@ -1450,22 +1389,17 @@ 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,
),
@@ -1524,7 +1458,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:
@@ -1544,9 +1477,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]
@@ -1616,20 +1547,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
@@ -1665,19 +1593,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:
@@ -1687,14 +1602,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)
@@ -1770,22 +1678,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: