@@ -103,480 +40,191 @@
crossorigin>
-{% endblock %}
\ No newline at end of file
+{% endblock %}
diff --git a/meshview/templates/nodegraph.html b/meshview/templates/nodegraph.html
index f885cf2..2c753d7 100644
--- a/meshview/templates/nodegraph.html
+++ b/meshview/templates/nodegraph.html
@@ -87,12 +87,6 @@
{% endblock %}
diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html
index 2b63d8c..e045371 100644
--- a/meshview/templates/stats.html
+++ b/meshview/templates/stats.html
@@ -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 @@
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) }}
@@ -205,6 +122,9 @@
+
@@ -253,29 +173,9 @@
-
+
-
-
-
-
-
-
-
- | Long Name |
- Short Name |
- Role |
- Hardware |
- Channel |
- Last Seen |
-
-
-
-
-
No nodes found for the selected channel.
-
-
@@ -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=`
-
${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();
-}
+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
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 ---
diff --git a/meshview/web.py b/meshview/web.py
index f7ba0eb..71fb59c 100644
--- a/meshview/web.py
+++ b/meshview/web.py
@@ -30,20 +30,9 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
SEQ_REGEX = re.compile(r"seq \d+")
-SOFTWARE_RELEASE = "2.0.7 ~ 09-17-25"
+SOFTWARE_RELEASE = "2.0.7 ~ 10-17-25"
CONFIG = config.CONFIG
-ACTIVITY_FILTERS = [
- ("1h", "Last 1 hour", timedelta(hours=1)),
- ("8h", "Last 8 hours", timedelta(hours=8)),
- ("1d", "Last 1 day", timedelta(days=1)),
- ("3d", "Last 3 days", timedelta(days=3)),
- ("7d", "Last 7 days", timedelta(days=7)),
- ("total", "All time", None),
-]
-ACTIVITY_OPTIONS = {value: window for value, _label, window in ACTIVITY_FILTERS}
-DEFAULT_ACTIVITY_OPTION = "1d"
-
env = Environment(loader=PackageLoader("meshview"), autoescape=select_autoescape())
# Start Database
@@ -197,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,24 +1159,6 @@ async def net(request):
@routes.get("/map")
async def map(request):
try:
- activity_param = request.query.get("active")
- if not activity_param:
- legacy_days = request.query.get("days_active")
- if legacy_days and legacy_days.isdigit():
- activity_param = "total" if legacy_days == "0" else f"{legacy_days}d"
-
- selected_activity, activity_window = resolve_activity_window(activity_param)
-
- nodes = await store.get_nodes(active_within=activity_window)
-
- # Filter out nodes with no latitude
- nodes = [node for node in nodes if node.last_lat is not None]
-
- # Optional datetime formatting
- for node in nodes:
- if hasattr(node, "last_update") and isinstance(node.last_update, datetime.datetime):
- node.last_update = node.last_update.isoformat()
-
# Parse optional URL parameters for custom view
map_center_lat = request.query.get("lat")
map_center_lng = request.query.get("lng")
@@ -1242,21 +1180,18 @@ async def map(request):
return web.Response(
text=template.render(
- nodes=nodes,
- custom_view=custom_view,
- activity_filters=ACTIVITY_FILTERS,
- selected_activity=selected_activity,
- site_config=CONFIG,
- SOFTWARE_RELEASE=SOFTWARE_RELEASE,
+ custom_view=custom_view,
),
content_type="text/html",
)
- except Exception:
- return web.Response(
- text="An error occurred while processing your request.",
- status=500,
- content_type="text/plain",
- )
+ except Exception as e:
+ print(f"/map route error: {e}")
+ return web.Response(
+ text="An error occurred while processing your request.",
+ status=500,
+ content_type="text/plain",
+ )
+
@routes.get("/stats")
@@ -1384,26 +1319,22 @@ async def chat(request):
# Assuming the route URL structure is /nodegraph
@routes.get("/nodegraph")
async def nodegraph(request):
- activity_param = request.query.get("active")
- if not activity_param:
- legacy_days = request.query.get("days_active")
- if legacy_days and legacy_days.isdigit():
- activity_param = "total" if legacy_days == "0" else f"{legacy_days}d"
-
- selected_activity, activity_window = resolve_activity_window(activity_param)
-
- nodes = await store.get_nodes(active_within=activity_window)
-
- active_node_ids = {node.node_id for node in nodes}
+ nodes = await store.get_nodes(days_active=3) # Fetch nodes for the given channel
+ node_ids = set()
edges_map = defaultdict(
lambda: {"weight": 0, "type": None}
) # weight is based on the number of traceroutes and neighbor info packets
+ used_nodes = set() # This will track nodes involved in edges (including traceroutes)
since = datetime.timedelta(hours=48)
traceroutes = []
# Fetch traceroutes
async for tr in store.get_traceroutes(since):
+ node_ids.add(tr.gateway_node_id)
+ node_ids.add(tr.packet.from_node_id)
+ node_ids.add(tr.packet.to_node_id)
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
+ node_ids.update(route.route)
path = [tr.packet.from_node_id]
path.extend(route.route)
@@ -1419,12 +1350,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 +1370,7 @@ async def nodegraph(request):
logger.error(f"Error decoding NeighborInfo packet: {e}")
# Convert edges_map to a list of dicts with colors
- filtered_edge_items = [
- ((frm, to), info)
- for (frm, to), info in edges_map.items()
- if frm in active_node_ids and to in active_node_ids
- ]
- max_weight = (
- max(info["weight"] for _, info in filtered_edge_items)
- if filtered_edge_items
- else 1
- )
+ max_weight = max(i['weight'] for i in edges_map.values()) if edges_map else 1
edges = [
{
"from": frm,
@@ -1450,53 +1378,23 @@ async def nodegraph(request):
"type": info["type"],
"weight": max([info['weight'] / float(max_weight) * 10, 1]),
}
- for (frm, to), info in filtered_edge_items
+ for (frm, to), info in edges_map.items()
]
# Filter nodes to only include those involved in edges (including traceroutes)
- connected_node_ids = {
- node_id for edge in edges for node_id in (edge["from"], edge["to"])
- }
- nodes_with_edges = [node for node in nodes if node.node_id in connected_node_ids]
+ nodes_with_edges = [node for node in nodes if node.node_id in used_nodes]
template = env.get_template("nodegraph.html")
return web.Response(
text=template.render(
nodes=nodes_with_edges,
edges=edges, # Pass edges with color info
- activity_filters=ACTIVITY_FILTERS,
- selected_activity=selected_activity,
site_config=CONFIG,
SOFTWARE_RELEASE=SOFTWARE_RELEASE,
),
content_type="text/html",
)
-
-# Show basic details about the site on the site
-@routes.get("/config")
-async def get_config(request):
- try:
- site = CONFIG.get("site", {})
- mqtt = CONFIG.get("mqtt", {})
-
- return web.json_response(
- {
- "Server": site.get("domain", ""),
- "Title": site.get("title", ""),
- "Message": site.get("message", ""),
- "MQTT Server": mqtt.get("server", ""),
- "Topics": json.loads(mqtt.get("topics", "[]")),
- "Release": SOFTWARE_RELEASE,
- "Time": datetime.datetime.now().isoformat(),
- },
- dumps=lambda obj: json.dumps(obj, indent=2),
- )
-
- except (json.JSONDecodeError, TypeError):
- return web.json_response({"error": "Invalid configuration format"}, status=500)
-
-
# API Section
#######################################################################
# How this works
@@ -1544,7 +1442,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:
@@ -1564,9 +1461,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]
@@ -1636,20 +1531,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
@@ -1685,19 +1577,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:
@@ -1707,14 +1586,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)
@@ -1790,41 +1662,6 @@ async def api_stats(request):
return web.json_response(stats)
-@routes.get("/api/stats/summary")
-async def api_stats_summary(request):
- channel = request.query.get("channel") or None
- total_packets = await store.get_total_packet_count(channel=channel)
- total_nodes = await store.get_total_node_count(channel=channel)
- total_packets_seen = await store.get_total_packet_seen_count(channel=channel)
-
- return web.json_response(
- {
- "total_packets": total_packets,
- "total_nodes": total_nodes,
- "total_packets_seen": total_packets_seen,
- }
- )
-
-
-@routes.get("/api/config")
-async def api_config(request):
- try:
- site = CONFIG.get("site", {})
- safe_site = {
- "map_interval": site.get("map_interval", 3),
- "firehose_interval": site.get("firehose_interval", 3),
- "map_top_left_lat": site.get("map_top_left_lat", 3),
- "map_top_left_lon": site.get("map_top_left_lon", 3),
- "map_bottom_right_lat": site.get("map_bottom_right_lat", 3),
- "map_bottom_right_lon": site.get("map_bottom_right_lon", 3),
- }
- safe_config = {"site": safe_site}
-
- return web.json_response(safe_config)
- except Exception as e:
- return web.json_response({"error": str(e)}, status=500)
-
-
@routes.get("/api/edges")
async def api_edges(request):
since = datetime.datetime.now() - datetime.timedelta(hours=48)
@@ -1867,6 +1704,106 @@ async def api_edges(request):
return web.json_response({"edges": edges_list})
+@routes.get("/api/config")
+async def api_config(request):
+ try:
+ # ------------------ Helpers ------------------
+ def get(section, key, default=None):
+ """Safe getter for both dict and ConfigParser."""
+ if isinstance(section, dict):
+ return section.get(key, default)
+ return section.get(key, fallback=default)
+
+ def get_bool(section, key, default=False):
+ val = get(section, key, default)
+ if isinstance(val, bool):
+ return "true" if val else "false"
+ if isinstance(val, str):
+ return "true" if val.lower() in ("1", "true", "yes", "on") else "false"
+ return "true" if bool(val) else "false"
+
+ def get_float(section, key, default=0.0):
+ try:
+ return float(get(section, key, default))
+ except Exception:
+ return float(default)
+
+ def get_int(section, key, default=0):
+ try:
+ return int(get(section, key, default))
+ except Exception:
+ return default
+
+ def get_str(section, key, default=""):
+ val = get(section, key, default)
+ return str(val) if val is not None else str(default)
+
+ # ------------------ SITE ------------------
+ site = CONFIG.get("site", {})
+ safe_site = {
+ "domain": get_str(site, "domain", ""),
+ "language": get_str(site, "language", "en"),
+ "title": get_str(site, "title", ""),
+ "message": get_str(site, "message", ""),
+ "starting": get_str(site, "starting", "/chat"),
+ "nodes": get_bool(site, "nodes", True),
+ "conversations": get_bool(site, "conversations", True),
+ "everything": get_bool(site, "everything", True),
+ "graphs": get_bool(site, "graphs", True),
+ "stats": get_bool(site, "stats", True),
+ "net": get_bool(site, "net", True),
+ "map": get_bool(site, "map", True),
+ "top": get_bool(site, "top", True),
+ "map_top_left_lat": get_float(site, "map_top_left_lat", 39.0),
+ "map_top_left_lon": get_float(site, "map_top_left_lon", -123.0),
+ "map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
+ "map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
+ "map_interval": get_int(site, "map_interval", 3),
+ "firehose_interval": get_int(site, "firehose_interval", 3),
+ "weekly_net_message": get_str(site, "weekly_net_message", "Weekly Mesh check-in message."),
+ "net_tag": get_str(site, "net_tag", "#BayMeshNet"),
+ "version": str(SOFTWARE_RELEASE),
+ }
+
+ # ------------------ MQTT ------------------
+ mqtt = CONFIG.get("mqtt", {})
+ topics_raw = get(mqtt, "topics", [])
+ import json
+ if isinstance(topics_raw, str):
+ try:
+ topics = json.loads(topics_raw)
+ except Exception:
+ topics = [topics_raw]
+ elif isinstance(topics_raw, list):
+ topics = topics_raw
+ else:
+ topics = []
+
+ safe_mqtt = {
+ "server": get_str(mqtt, "server", ""),
+ "topics": topics,
+ }
+
+ # ------------------ CLEANUP ------------------
+ cleanup = CONFIG.get("cleanup", {})
+ safe_cleanup = {
+ "enabled": get_bool(cleanup, "enabled", False),
+ "days_to_keep": get_str(cleanup, "days_to_keep", "14"),
+ "hour": get_str(cleanup, "hour", "2"),
+ "minute": get_str(cleanup, "minute", "0"),
+ "vacuum": get_bool(cleanup, "vacuum", False),
+ }
+
+ safe_config = {
+ "site": safe_site,
+ "mqtt": safe_mqtt,
+ "cleanup": safe_cleanup,
+ }
+
+ return web.json_response(safe_config)
+ except Exception as e:
+ return web.json_response({"error": str(e)}, status=500)
+
@routes.get("/api/lang")
async def api_lang(request):