fixed the db and added more reporting

This commit is contained in:
pablorevilla-meshtastic
2026-03-05 11:36:26 -08:00
parent 2490284435
commit 973dcbfe51
9 changed files with 154 additions and 110 deletions
-24
View File
@@ -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())
@@ -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")
+2 -1
View File
@@ -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",
+2 -1
View File
@@ -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",
-1
View File
@@ -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)
-1
View File
@@ -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,
}
-1
View File
@@ -469,7 +469,6 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
<a href="/node/${s.node_id}">${s.node_id}</a><br>
HW: ${node?.hw_model ?? "—"}<br>
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
Relay Node: ${s.relay_node ?? "—"}<br>
${
distanceKm !== null
? `<span data-translate-lang="distance">Distance</span>:
+121 -24
View File
@@ -119,12 +119,21 @@
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="daily_snapshot_histogram">
Daily Network Snapshot Histogram (Last 30 Days)
<p class="section-header" data-translate-lang="daily_snapshot_packets_history">
Daily Packet Snapshot History (All Available Days)
</p>
<button class="expand-btn" data-chart="chart_daily_snapshot" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot" class="chart"></div>
<button class="expand-btn" data-chart="chart_daily_snapshot_packets" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot_packets" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot_packets" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="daily_snapshot_nodes_gateways_history">
Daily Node/Gateway Snapshot History (All Available Days)
</p>
<button class="expand-btn" data-chart="chart_daily_snapshot_nodes_gateways" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_snapshot_nodes_gateways" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_snapshot_nodes_gateways" class="chart"></div>
</div>
<!-- Daily Charts -->
@@ -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" } };
+29 -18
View File
@@ -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(),