@@ -99,8 +115,20 @@ async function loadTranslations() {
}
// Initialize map AFTER translations are loaded
-loadTranslations().then(() => {
+loadTranslations().then(async () => {
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 channelSet = new Set();
+ let channelList = [];
const portMap = {1: "Text", 67: "Telemetry", 3: "Position", 70: "Traceroute", 4: "Node Info", 71: "Neighbour Info", 73: "Map Report"};
@@ -162,18 +192,37 @@ loadTranslations().then(() => {
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 = node.channel;
- channels.add(category);
+ let category = channelKey(node.channel);
+ channelSet.add(category);
let color = hashToColor(category);
let popupContent = `
@@ -209,6 +258,10 @@ loadTranslations().then(() => {
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';
@@ -225,7 +278,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) {
@@ -234,16 +287,13 @@ loadTranslations().then(() => {
});
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) {
- const filters = JSON.parse(stored);
- console.log('Filters loaded from localStorage:', filters);
- return filters;
+ return JSON.parse(stored);
}
} catch (error) {
console.error('Error loading filters from localStorage:', error);
@@ -251,25 +301,34 @@ loadTranslations().then(() => {
return null;
}
+ function renderChannelFilters(savedFilters) {
+ const filterContainer = document.getElementById("filter-container");
+ filterContainer.querySelectorAll('label[data-channel-filter="true"]').forEach(el => el.remove());
+ channelList.forEach(channel => {
+ let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
+ let color = hashToColor(channel);
+ let label = document.createElement('label');
+ label.style.color = color;
+ label.setAttribute('data-channel-filter', 'true');
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.className = 'filter-checkbox';
+ checkbox.id = filterId;
+ const shouldCheck = savedFilters ? savedFilters.channels?.[channel] !== false : true;
+ checkbox.checked = shouldCheck;
+ checkbox.addEventListener("change", updateMarkers);
+ label.appendChild(checkbox);
+ label.append(` ${channel}`);
+ filterContainer.appendChild(label);
+ });
+ }
+
function resetFiltersToDefaults() {
localStorage.removeItem(FILTER_STORAGE_KEY);
- console.log('Filters reset to defaults');
-
- // Reset routers only filter
document.getElementById("filter-routers-only").checked = false;
-
- // 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;
- }
- });
-
+ renderChannelFilters(null);
updateMarkers();
- // Show feedback to user
const button = document.getElementById('reset-filters-button');
const originalText = button.textContent;
button.textContent = '✓ Filters Reset!';
@@ -281,51 +340,44 @@ loadTranslations().then(() => {
}, 2000);
}
+ window.resetFiltersToDefaults = resetFiltersToDefaults;
+
// ---- Filters ----
const filterLabel = document.getElementById("filter-routers-label");
filterLabel.textContent = t.show_routers_only || "Show 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=`
${channel}`;
- filterContainer.appendChild(label);
- });
-
- // Load saved filters from localStorage
+ const routersOnlyCheckbox = document.getElementById("filter-routers-only");
const savedFilters = loadFiltersFromLocalStorage();
if (savedFilters) {
- // 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.checked = savedFilters.routersOnly || false;
}
+ routersOnlyCheckbox.addEventListener("change", updateMarkers);
+ renderChannelFilters(savedFilters);
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(true);
+ }
}
- document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers));
+ function getActiveChannels() {
+ return channelList.filter(channel => {
+ if (channel === 'Unknown') return false;
+ let checkbox = document.getElementById(`filter-${channel.replace(/\s+/g,'-').toLowerCase()}`);
+ return checkbox ? checkbox.checked : true;
+ });
+ }
// Apply initial filters (from localStorage or defaults)
updateMarkers();
@@ -338,7 +390,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!';
@@ -451,33 +507,78 @@ loadTranslations().then(() => {
// ---- 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(){
- 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));
+ 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);
+ });
}
function fetchNewPackets(){
if(!lastImportTime) return;
- 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);
- }
+ 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);
});
- if(latestSeen) lastImportTime=latestSeen;
- }).catch(err=>console.error(err));
}
let packetInterval=null;
- function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } }
+ 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 stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
- document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); });
- if(mapInterval>0) startPacketFetcher();
+ 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);
});
{% endblock %}
diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html
index 3d25625..f71cf58 100644
--- a/meshview/templates/nodegraph.html
+++ b/meshview/templates/nodegraph.html
@@ -87,6 +87,12 @@
{% endblock %}
diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html
index e045371..2b63d8c 100644
--- a/meshview/templates/stats.html
+++ b/meshview/templates/stats.html
@@ -77,14 +77,90 @@
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 %}
@@ -95,18 +171,25 @@
Mesh Statistics - Summary (all available in Database)
+
+
+
+
+
Total Nodes
-
{{ "{:,}".format(total_nodes) }}
+
{{ "{:,}".format(total_nodes) }}
Total Packets
-
{{ "{:,}".format(total_packets) }}
+
{{ "{:,}".format(total_packets) }}
Total Packets Seen
-
{{ "{:,}".format(total_packets_seen) }}
+
{{ "{:,}".format(total_packets_seen) }}
@@ -122,9 +205,6 @@
-
@@ -173,9 +253,29 @@
-
+
+
+
+
+
+
+
+
+ | Long Name |
+ Short Name |
+ Role |
+ Hardware |
+ Channel |
+ Last Seen |
+
+
+
+
+
No nodes found for the selected channel.
+
+
@@ -201,12 +301,28 @@ 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=${channel}`;
+ if(channel) url+=`&channel=${encodeURIComponent(channel)}`;
const res=await fetch(url);
if(!res.ok) return [];
const json=await res.json();
@@ -214,28 +330,212 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}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 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 [];}
+}
-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; }
+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=`
+
${node.long_name} |
+
${node.short_name} |
+
${node.role} |
+
${node.hw_model} |
+
${node.channel || "—"} |
+
${node.last_update_display} |
+ `;
+ 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();
+}
// --- Chart Rendering ---
-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 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 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; }
+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;
+}
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
- 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 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 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);
@@ -243,44 +543,137 @@ 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();
- 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
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)");
+ 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();
}
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
@@ -342,14 +735,6 @@ 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 ---
diff --git a/meshview/web.py b/meshview/web.py
index 54f015b..728ab45 100644
--- a/meshview/web.py
+++ b/meshview/web.py
@@ -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()
@@ -425,15 +447,27 @@ async def packet_details(request):
@routes.get("/firehose")
async def packet_details_firehose(request):
- portnum = request.query.get("portnum")
- if portnum:
- portnum = int(portnum)
- packets = await store.get_packets(portnum=portnum, limit=10)
+ portnum_value = request.query.get("portnum")
+ channel = request.query.get("channel")
+
+ portnum = None
+ if portnum_value:
+ try:
+ portnum = int(portnum_value)
+ except ValueError:
+ logger.warning("Invalid portnum '%s' provided to /firehose", portnum_value)
+
+ packets = await store.get_packets(portnum=portnum, limit=10, channel=channel)
+ ui_packets = [Packet.from_model(p) for p in packets]
+ latest_time = ui_packets[0].import_time.isoformat() if ui_packets else None
+
template = env.get_template("firehose.html")
return web.Response(
text=template.render(
- packets=(Packet.from_model(p) for p in packets),
+ packets=ui_packets,
portnum=portnum,
+ channel=channel,
+ last_time=latest_time,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
@@ -454,8 +488,18 @@ async def firehose_updates(request):
logger.error(f"Failed to parse last_time '{last_time_str}': {e}")
last_time = None
+ portnum_value = request.query.get("portnum")
+ channel = request.query.get("channel")
+
+ portnum = None
+ if portnum_value:
+ try:
+ portnum = int(portnum_value)
+ except ValueError:
+ logger.warning("Invalid portnum '%s' provided to /firehose/updates", portnum_value)
+
# Query packets after last_time (microsecond precision)
- packets = await store.get_packets(after=last_time, limit=10)
+ packets = await store.get_packets(after=last_time, limit=10, portnum=portnum, channel=channel)
# Convert to UI model
ui_packets = [Packet.from_model(p) for p in packets]
@@ -1159,7 +1203,15 @@ 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)
# Filter out nodes with no latitude
nodes = [node for node in nodes if node.last_lat is not None]
@@ -1192,6 +1244,8 @@ 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,
),
@@ -1330,22 +1384,26 @@ async def chat(request):
# Assuming the route URL structure is /nodegraph
@routes.get("/nodegraph")
async def nodegraph(request):
- nodes = await store.get_nodes(days_active=3) # Fetch nodes for the given channel
- node_ids = set()
+ activity_param = request.query.get("active")
+ if not activity_param:
+ legacy_days = request.query.get("days_active")
+ if legacy_days and legacy_days.isdigit():
+ activity_param = "total" if legacy_days == "0" else f"{legacy_days}d"
+
+ selected_activity, activity_window = resolve_activity_window(activity_param)
+
+ nodes = await store.get_nodes(active_within=activity_window)
+
+ active_node_ids = {node.node_id for node in nodes}
edges_map = defaultdict(
lambda: {"weight": 0, "type": None}
) # weight is based on the number of traceroutes and neighbor info packets
- used_nodes = set() # This will track nodes involved in edges (including traceroutes)
since = datetime.timedelta(hours=48)
traceroutes = []
# Fetch traceroutes
async for tr in store.get_traceroutes(since):
- node_ids.add(tr.gateway_node_id)
- node_ids.add(tr.packet.from_node_id)
- node_ids.add(tr.packet.to_node_id)
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
- node_ids.update(route.route)
path = [tr.packet.from_node_id]
path.extend(route.route)
@@ -1361,18 +1419,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
@@ -1381,7 +1433,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,
@@ -1389,17 +1450,22 @@ async def nodegraph(request):
"type": info["type"],
"weight": max([info['weight'] / float(max_weight) * 10, 1]),
}
- for (frm, to), info in edges_map.items()
+ for (frm, to), info in filtered_edge_items
]
# Filter nodes to only include those involved in edges (including traceroutes)
- nodes_with_edges = [node for node in nodes if node.node_id in used_nodes]
+ connected_node_ids = {
+ node_id for edge in edges for node_id in (edge["from"], edge["to"])
+ }
+ nodes_with_edges = [node for node in nodes if node.node_id in connected_node_ids]
template = env.get_template("nodegraph.html")
return web.Response(
text=template.render(
nodes=nodes_with_edges,
edges=edges, # Pass edges with color info
+ activity_filters=ACTIVITY_FILTERS,
+ selected_activity=selected_activity,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
@@ -1458,6 +1524,7 @@ async def api_chat(request):
# Parse query params
limit_str = request.query.get("limit", "20")
since_str = request.query.get("since")
+ channel_filter = request.query.get("channel")
# Clamp limit between 1 and 200
try:
@@ -1477,7 +1544,9 @@ async def api_chat(request):
packets = await store.get_packets(
node_id=0xFFFFFFFF,
portnum=PortNum.TEXT_MESSAGE_APP,
+ after=since,
limit=limit,
+ channel=channel_filter,
)
ui_packets = [Packet.from_model(p) for p in packets]
@@ -1547,17 +1616,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
@@ -1593,6 +1665,19 @@ async def api_packets(request):
limit = int(request.query.get("limit", 50))
since_str = request.query.get("since")
since_time = None
+ channel_values = []
+
+ # Support repeated ?channel=foo&channel=bar and comma-separated values
+ if "channel" in request.query:
+ raw_channels = request.query.getall("channel", [])
+ if not raw_channels:
+ raw_value = request.query.get("channel")
+ if raw_value:
+ raw_channels = [raw_value]
+ for raw in raw_channels:
+ if raw:
+ parts = [part.strip() for part in raw.split(",") if part.strip()]
+ channel_values.extend(parts)
# Parse 'since' timestamp if provided
if since_str:
@@ -1602,7 +1687,14 @@ async def api_packets(request):
logger.error(f"Failed to parse 'since' timestamp '{since_str}': {e}")
# Fetch last N packets
- packets = await store.get_packets(limit=limit, after=since_time)
+ if not channel_values:
+ channel_filter = None
+ elif len(channel_values) == 1:
+ channel_filter = channel_values[0]
+ else:
+ channel_filter = channel_values
+
+ packets = await store.get_packets(limit=limit, after=since_time, channel=channel_filter)
packets = [Packet.from_model(p) for p in packets]
# Build JSON response (no raw_payload)
@@ -1678,6 +1770,22 @@ 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: