From 9fa874762e54c39a37c7367045493a46b7a23566 Mon Sep 17 00:00:00 2001 From: Joel Krauska Date: Wed, 5 Nov 2025 16:48:29 -0800 Subject: [PATCH] Add us first/last timestamps to node table too --- .../2b5a61bb2b75_auto_generated_migration.py | 35 +++++++++++++++++++ meshview/models.py | 8 ++++- meshview/mqtt_store.py | 30 +++++++++++++--- 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/2b5a61bb2b75_auto_generated_migration.py diff --git a/alembic/versions/2b5a61bb2b75_auto_generated_migration.py b/alembic/versions/2b5a61bb2b75_auto_generated_migration.py new file mode 100644 index 0000000..5b1b538 --- /dev/null +++ b/alembic/versions/2b5a61bb2b75_auto_generated_migration.py @@ -0,0 +1,35 @@ +"""Add first_seen_us and last_seen_us to node table + +Revision ID: 2b5a61bb2b75 +Revises: ac311b3782a1 +Create Date: 2025-11-05 15:19:13.446724 + +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = '2b5a61bb2b75' +down_revision: str | None = 'ac311b3782a1' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Add microsecond epoch timestamp columns for first and last seen times + op.add_column('node', sa.Column('first_seen_us', sa.BigInteger(), nullable=True)) + op.add_column('node', sa.Column('last_seen_us', sa.BigInteger(), nullable=True)) + op.create_index('idx_node_first_seen_us', 'node', ['first_seen_us'], unique=False) + op.create_index('idx_node_last_seen_us', 'node', ['last_seen_us'], unique=False) + + +def downgrade() -> None: + # Remove the microsecond epoch timestamp columns and their indexes + op.drop_index('idx_node_last_seen_us', table_name='node') + op.drop_index('idx_node_first_seen_us', table_name='node') + op.drop_column('node', 'last_seen_us') + op.drop_column('node', 'first_seen_us') diff --git a/meshview/models.py b/meshview/models.py index 2825dba..981ec09 100644 --- a/meshview/models.py +++ b/meshview/models.py @@ -23,8 +23,14 @@ class Node(Base): last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) channel: Mapped[str] = mapped_column(nullable=True) last_update: Mapped[datetime] = mapped_column(nullable=True) + first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) + last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) - __table_args__ = (Index("idx_node_node_id", "node_id"),) + __table_args__ = ( + Index("idx_node_node_id", "node_id"), + Index("idx_node_first_seen_us", "first_seen_us"), + Index("idx_node_last_seen_us", "last_seen_us"), + ) def to_dict(self): return { diff --git a/meshview/mqtt_store.py b/meshview/mqtt_store.py index baca633..949ec8c 100644 --- a/meshview/mqtt_store.py +++ b/meshview/mqtt_store.py @@ -37,6 +37,9 @@ async def process_envelope(topic, env): await session.execute(select(Node).where(Node.node_id == node_id)) ).scalar_one_or_none() + now = datetime.datetime.now(datetime.UTC) + now_us = int(now.timestamp() * 1_000_000) + if node: node.node_id = node_id node.long_name = map_report.long_name @@ -47,7 +50,10 @@ async def process_envelope(topic, env): node.last_lat = map_report.latitude_i node.last_long = map_report.longitude_i node.firmware = map_report.firmware_version - node.last_update = datetime.datetime.now() + node.last_update = now + node.last_seen_us = now_us + if node.first_seen_us is None: + node.first_seen_us = now_us else: node = Node( id=user_id, @@ -60,7 +66,9 @@ async def process_envelope(topic, env): firmware=map_report.firmware_version, last_lat=map_report.latitude_i, last_long=map_report.longitude_i, - last_update=datetime.datetime.now(), + last_update=now, + first_seen_us=now_us, + last_seen_us=now_us, ) session.add(node) except Exception as e: @@ -159,6 +167,9 @@ async def process_envelope(topic, env): await session.execute(select(Node).where(Node.id == user.id)) ).scalar_one_or_none() + now = datetime.datetime.now(datetime.UTC) + now_us = int(now.timestamp() * 1_000_000) + if node: node.node_id = node_id node.long_name = user.long_name @@ -166,7 +177,10 @@ async def process_envelope(topic, env): node.hw_model = hw_model node.role = role node.channel = env.channel_id - node.last_update = datetime.datetime.now() + node.last_update = now + node.last_seen_us = now_us + if node.first_seen_us is None: + node.first_seen_us = now_us else: node = Node( id=user.id, @@ -176,7 +190,9 @@ async def process_envelope(topic, env): hw_model=hw_model, role=role, channel=env.channel_id, - last_update=datetime.datetime.now(), + last_update=now, + first_seen_us=now_us, + last_seen_us=now_us, ) session.add(node) except Exception as e: @@ -193,8 +209,14 @@ async def process_envelope(topic, env): await session.execute(select(Node).where(Node.node_id == from_node_id)) ).scalar_one_or_none() if node: + now = datetime.datetime.now(datetime.UTC) + now_us = int(now.timestamp() * 1_000_000) node.last_lat = position.latitude_i node.last_long = position.longitude_i + node.last_update = now + node.last_seen_us = now_us + if node.first_seen_us is None: + node.first_seen_us = now_us session.add(node) # --- TRACEROUTE_APP (no conflict handling, normal insert)