diff --git a/add_relay_node_column.py b/add_relay_node_column.py
deleted file mode 100644
index e144f31..0000000
--- a/add_relay_node_column.py
+++ /dev/null
@@ -1,24 +0,0 @@
-"""
-Migration script to add relay_node column to packet_seen table.
-Run this once to update your database schema.
-"""
-
-import asyncio
-
-from meshview import database
-
-
-async def add_relay_node_column():
- """Add relay_node column to packet_seen table"""
- async with database.async_session() as session:
- # Add the column
- await session.execute("""
- ALTER TABLE packet_seen
- ADD COLUMN IF NOT EXISTS relay_node BIGINT
- """)
- await session.commit()
- print("Successfully added relay_node column to packet_seen table")
-
-
-if __name__ == "__main__":
- asyncio.run(add_relay_node_column())
diff --git a/alembic/versions/c6b1d8f2a9e3_add_relay_node_to_packet_seen.py b/alembic/versions/c6b1d8f2a9e3_add_relay_node_to_packet_seen.py
deleted file mode 100644
index 79013dd..0000000
--- a/alembic/versions/c6b1d8f2a9e3_add_relay_node_to_packet_seen.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""Add relay_node column to packet_seen
-
-Revision ID: c6b1d8f2a9e3
-Revises: 4f1d2a9c8b71
-Create Date: 2026-03-05 00:00:00.000000
-
-"""
-
-from collections.abc import Sequence
-
-import sqlalchemy as sa
-
-from alembic import op
-
-# revision identifiers, used by Alembic.
-revision: str = "c6b1d8f2a9e3"
-down_revision: str | None = "4f1d2a9c8b71"
-branch_labels: str | Sequence[str] | None = None
-depends_on: str | Sequence[str] | None = None
-
-
-def upgrade() -> None:
- conn = op.get_bind()
- inspector = sa.inspect(conn)
- packet_seen_columns = {col["name"] for col in inspector.get_columns("packet_seen")}
-
- if "relay_node" not in packet_seen_columns:
- with op.batch_alter_table("packet_seen", schema=None) as batch_op:
- batch_op.add_column(sa.Column("relay_node", sa.BigInteger(), nullable=True))
-
-
-def downgrade() -> None:
- conn = op.get_bind()
- inspector = sa.inspect(conn)
- packet_seen_columns = {col["name"] for col in inspector.get_columns("packet_seen")}
-
- if "relay_node" in packet_seen_columns:
- with op.batch_alter_table("packet_seen", schema=None) as batch_op:
- batch_op.drop_column("relay_node")
diff --git a/meshview/lang/en.json b/meshview/lang/en.json
index 71d95e6..4ab6368 100644
--- a/meshview/lang/en.json
+++ b/meshview/lang/en.json
@@ -127,7 +127,8 @@
"total_gateways": "Total Gateways",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
- "daily_snapshot_histogram": "Daily Network Snapshot Histogram (Last 30 Days)",
+ "daily_snapshot_packets_history": "Daily Packet Snapshot History (All Available Days)",
+ "daily_snapshot_nodes_gateways_history": "Daily Node/Gateway Snapshot History (All Available Days)",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
diff --git a/meshview/lang/es.json b/meshview/lang/es.json
index 0c33e9a..9795aad 100644
--- a/meshview/lang/es.json
+++ b/meshview/lang/es.json
@@ -123,7 +123,8 @@
"total_gateways": "Gateways Totales",
"total_packets": "Paquetes Totales",
"total_packets_seen": "Paquetes Totales Vistos",
- "daily_snapshot_histogram": "Histograma Diario de Instantáneas de Red (Últimos 30 Días)",
+ "daily_snapshot_packets_history": "Historial Diario de Instantáneas de Paquetes (Todos los Días Disponibles)",
+ "daily_snapshot_nodes_gateways_history": "Historial Diario de Instantáneas de Nodos/Gateways (Todos los Días Disponibles)",
"packets_per_day_all": "Paquetes por Día - Todos los Puertos (Últimos 14 Días)",
"packets_per_day_text": "Paquetes por Día - Mensajes de Texto (Puerto 1, Últimos 14 Días)",
"packets_per_hour_all": "Paquetes por Hora - Todos los Puertos",
diff --git a/meshview/models.py b/meshview/models.py
index 21c1ab3..b450336 100644
--- a/meshview/models.py
+++ b/meshview/models.py
@@ -74,7 +74,6 @@ class PacketSeen(Base):
rx_time: Mapped[int] = mapped_column(BigInteger, primary_key=True)
hop_limit: Mapped[int] = mapped_column(nullable=True)
hop_start: Mapped[int] = mapped_column(nullable=True)
- relay_node: Mapped[int] = mapped_column(BigInteger, nullable=True)
channel: Mapped[str] = mapped_column(nullable=True)
rx_snr: Mapped[float] = mapped_column(nullable=True)
rx_rssi: Mapped[int] = mapped_column(nullable=True)
diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py
index abedc49..e197134 100644
--- a/meshview/mqtt_store.py
+++ b/meshview/mqtt_store.py
@@ -206,7 +206,6 @@ async def process_envelope(topic, env):
"rx_rssi": env.packet.rx_rssi,
"hop_limit": env.packet.hop_limit,
"hop_start": env.packet.hop_start,
- "relay_node": env.packet.relay_node if env.packet.relay_node else None,
"topic": topic,
"import_time_us": now_us,
}
diff --git a/meshview/templates/packet.html b/meshview/templates/packet.html
index 48595d3..42e3122 100644
--- a/meshview/templates/packet.html
+++ b/meshview/templates/packet.html
@@ -469,7 +469,6 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
${s.node_id}
HW: ${node?.hw_model ?? "—"}
Channel: ${s.channel ?? "—"}
- Relay Node: ${s.relay_node ?? "—"}
${
distanceKm !== null
? `Distance:
diff --git a/meshview/templates/stats.html b/meshview/templates/stats.html
index 74c7c0f..2182065 100644
--- a/meshview/templates/stats.html
+++ b/meshview/templates/stats.html
@@ -119,12 +119,21 @@
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -256,9 +265,9 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
-async function fetchDailySnapshots(length=30){
+async function fetchDailySnapshots(){
try{
- const res=await fetch(`/api/snapshots/daily?length=${length}`);
+ const res=await fetch("/api/snapshots/daily");
if(!res.ok) return [];
const json=await res.json();
return json.data||[];
@@ -285,6 +294,18 @@ async function fetchChannels(){
}
}
+function buildPacketTypeBreakdownFromSeries(allData, perPortSeries){
+ const totalAll = (allData || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
+ const results = perPortSeries.map(({portnum, data}) => ({
+ portnum,
+ count: (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0),
+ }));
+ const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
+ const other = Math.max(totalAll - trackedTotal,0);
+ if(other>0) results.push({portnum:"other", count:other});
+ return results;
+}
+
function processCountField(nodes,field){
const counts={};
nodes.forEach(n=>{
@@ -383,19 +404,50 @@ function renderPieChart(elId,data,name){
return chart;
}
-function renderDailySnapshotChart(domId,data){
+function makeTelemetryLine(name, color, seriesData) {
+ return {
+ name,
+ type: "line",
+ smooth: true,
+ connectNulls: true,
+ showSymbol: true,
+ symbol: "circle",
+ symbolSize: 7,
+ lineStyle: {
+ width: 2,
+ color,
+ shadowColor: color.replace("1)", "0.35)"),
+ shadowBlur: 8,
+ shadowOffsetY: 3
+ },
+ itemStyle: {
+ color,
+ borderColor: "#000",
+ borderWidth: 1
+ },
+ areaStyle: {
+ color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
+ { offset: 0, color: color.replace("1)", "0.5)") },
+ { offset: 0.6, color: color.replace("1)", "0.2)") },
+ { offset: 1, color: "rgba(0,0,0,0)" }
+ ])
+ },
+ data: seriesData
+ };
+}
+
+function renderDailySnapshotPacketsChart(domId,data){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.init(el);
const labels=data.map(d=>d.date||"");
- const nodeCounts=data.map(d=>d.node_count??0);
const packetCounts=data.map(d=>d.packet_count??0);
- const gatewayCounts=data.map(d=>d.gateway_count??0);
+
chart.setOption({
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
legend:{
- data:['Nodes','Packets','Gateways'],
+ data:['Packets'],
textStyle:{color:'#ccc'}
},
grid:{left:'6%', right:'6%', bottom:'18%'},
@@ -411,9 +463,42 @@ function renderDailySnapshotChart(domId,data){
axisLabel:{color:'#ccc'}
},
series:[
- {name:'Nodes', type:'bar', data:nodeCounts, itemStyle:{color:'#5e81ac'}},
- {name:'Packets', type:'bar', data:packetCounts, itemStyle:{color:'#88c0d0'}},
- {name:'Gateways', type:'bar', data:gatewayCounts, itemStyle:{color:'#a3be8c'}}
+ makeTelemetryLine("Packets", "rgba(255,214,82,1)", packetCounts)
+ ]
+ });
+ return chart;
+}
+
+function renderDailySnapshotNodesGatewaysChart(domId,data){
+ const el=document.getElementById(domId);
+ if(!el) return;
+ const chart=echarts.init(el);
+ const labels=data.map(d=>d.date||"");
+ const nodeCounts=data.map(d=>d.node_count??0);
+ const gatewayCounts=data.map(d=>d.gateway_count??0);
+
+ chart.setOption({
+ backgroundColor:'#272b2f',
+ tooltip:{trigger:'axis'},
+ legend:{
+ data:['Nodes','Gateways'],
+ textStyle:{color:'#ccc'}
+ },
+ grid:{left:'6%', right:'6%', bottom:'18%'},
+ xAxis:{
+ type:'category',
+ data:labels,
+ axisLine:{lineStyle:{color:'#aaa'}},
+ axisLabel:{rotate:45,color:'#ccc'}
+ },
+ yAxis:{
+ type:'value',
+ axisLine:{lineStyle:{color:'#aaa'}},
+ axisLabel:{color:'#ccc'}
+ },
+ series:[
+ makeTelemetryLine("Nodes", "rgba(79,155,255,1)", nodeCounts),
+ makeTelemetryLine("Gateways", "rgba(138,255,108,1)", gatewayCounts)
]
});
return chart;
@@ -442,10 +527,11 @@ async function fetchPacketTypeBreakdown(channel=null) {
// --- Init ---
let chartHourlyAll, chartPortnum1, chartPortnum3, chartPortnum4, chartPortnum67, chartPortnum70, chartPortnum71;
let chartDailyAll, chartDailyPortnum1;
-let chartDailySnapshot;
+let chartDailySnapshotPackets, chartDailySnapshotNodesGateways;
let chartHwModel, chartRole, chartChannel;
let chartGatewayChannel, chartGatewayRole, chartGatewayFirmware;
let chartPacketTypes;
+let defaultPacketTypesData = [];
async function init(){
// Channel selector
@@ -459,8 +545,12 @@ async function init(){
});
// Daily snapshot histogram
- const snapshots = await fetchDailySnapshots(30);
- chartDailySnapshot = renderDailySnapshotChart("chart_daily_snapshot", snapshots);
+ const snapshots = await fetchDailySnapshots();
+ chartDailySnapshotPackets = renderDailySnapshotPacketsChart("chart_daily_snapshot_packets", snapshots);
+ chartDailySnapshotNodesGateways = renderDailySnapshotNodesGatewaysChart(
+ "chart_daily_snapshot_nodes_gateways",
+ snapshots
+ );
// Daily all ports
const dailyAllData=await fetchStats('day',14);
@@ -522,7 +612,9 @@ async function init(){
}
// Packet types pie
- const packetTypesData = await fetchPacketTypeBreakdown();
+ const packetSeries = portnums.map((pn, i) => ({ portnum: pn, data: allData[i] }));
+ const packetTypesData = buildPacketTypeBreakdownFromSeries(hourlyAllData, packetSeries);
+ defaultPacketTypesData = packetTypesData;
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
@@ -561,7 +653,8 @@ window.addEventListener('resize',()=>{
chartPortnum67,
chartPortnum70,
chartPortnum71,
- chartDailySnapshot,
+ chartDailySnapshotPackets,
+ chartDailySnapshotNodesGateways,
chartDailyAll,
chartDailyPortnum1,
chartHwModel,
@@ -646,7 +739,7 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
- const packetTypesData = await fetchPacketTypeBreakdown(channel);
+ const packetTypesData = channel ? await fetchPacketTypeBreakdown(channel) : defaultPacketTypesData;
const formatted = packetTypesData
.filter(d=>d.count>0)
.map(d=>({
@@ -666,10 +759,14 @@ init();
async function loadConfigAndTranslations() {
let langCode = "en";
try {
- const resConfig = await fetch("/api/config");
- const cfg = await resConfig.json();
- window.site_config = cfg;
- langCode = cfg?.site?.language || "en";
+ if (window.site_config?.site) {
+ langCode = window.site_config.site.language || "en";
+ } else {
+ const resConfig = await fetch("/api/config");
+ const cfg = await resConfig.json();
+ window.site_config = cfg;
+ langCode = cfg?.site?.language || "en";
+ }
} catch(err) {
console.error("Failed to load /api/config:", err);
window.site_config = { site: { language: "en" } };
diff --git a/meshview/web_api/api.py b/meshview/web_api/api.py
index afa22b3..6c14d2d 100644
--- a/meshview/web_api/api.py
+++ b/meshview/web_api/api.py
@@ -454,30 +454,41 @@ async def api_stats_count(request):
@routes.get("/api/snapshots/daily")
async def api_daily_snapshots(request):
- length_raw = request.query.get("length", "30")
- try:
- length = int(length_raw)
- except ValueError:
- return web.json_response({"error": "length must be an integer"}, status=400)
-
- # Keep query bounded.
- length = max(1, min(length, 3650))
+ length_raw = request.query.get("length")
+ length = None
+ if length_raw is not None:
+ try:
+ length = int(length_raw)
+ except ValueError:
+ return web.json_response({"error": "length must be an integer"}, status=400)
+ length = max(1, min(length, 3650))
try:
async with database.async_session() as session:
- rows = (
- (
- await session.execute(
- select(DailySnapshot)
- .order_by(DailySnapshot.snapshot_date.desc())
- .limit(length)
+ if length is not None:
+ rows = (
+ (
+ await session.execute(
+ select(DailySnapshot)
+ .order_by(DailySnapshot.snapshot_date.desc())
+ .limit(length)
+ )
)
+ .scalars()
+ .all()
+ )
+ rows = list(reversed(rows))
+ else:
+ rows = (
+ (
+ await session.execute(
+ select(DailySnapshot).order_by(DailySnapshot.snapshot_date.asc())
+ )
+ )
+ .scalars()
+ .all()
)
- .scalars()
- .all()
- )
- rows = list(reversed(rows))
data = [
{
"date": row.snapshot_date.isoformat(),