diff --git a/LICENSES.md b/LICENSES.md index db8924e..8cdc53b 100644 --- a/LICENSES.md +++ b/LICENSES.md @@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -### meshcore (2.3.1) — MIT +### meshcore (2.3.2) — MIT
Full license text diff --git a/app/models.py b/app/models.py index e2774d8..214d884 100644 --- a/app/models.py +++ b/app/models.py @@ -833,4 +833,5 @@ class StatisticsResponse(BaseModel): total_outgoing: int contacts_heard: ContactActivityCounts repeaters_heard: ContactActivityCounts + known_channels_active: ContactActivityCounts path_hash_width_24h: PathHashWidthStats diff --git a/app/repository/settings.py b/app/repository/settings.py index 5c94f95..78ac729 100644 --- a/app/repository/settings.py +++ b/app/repository/settings.py @@ -270,6 +270,30 @@ class StatisticsRepository: "last_week": row["last_week"] or 0, } + @staticmethod + async def _known_channels_active() -> dict[str, int]: + """Count distinct known channel keys with channel traffic in each time window.""" + now = int(time.time()) + cursor = await db.conn.execute( + """ + SELECT + COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour, + COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours, + COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week + FROM messages m + INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key) + WHERE m.type = 'CHAN' + """, + (now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D), + ) + row = await cursor.fetchone() + assert row is not None + return { + "last_hour": row["last_hour"] or 0, + "last_24_hours": row["last_24_hours"] or 0, + "last_week": row["last_week"] or 0, + } + @staticmethod async def _path_hash_width_24h() -> dict[str, int | float]: """Count parsed raw packets from the last 24h by hop hash width.""" @@ -396,6 +420,7 @@ class StatisticsRepository: # Activity windows contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True) repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2) + known_channels_active = await StatisticsRepository._known_channels_active() path_hash_width_24h = await StatisticsRepository._path_hash_width_24h() return { @@ -411,5 +436,6 @@ class StatisticsRepository: "total_outgoing": total_outgoing, "contacts_heard": contacts_heard, "repeaters_heard": repeaters_heard, + "known_channels_active": known_channels_active, "path_hash_width_24h": path_hash_width_24h, } diff --git a/frontend/src/components/settings/SettingsStatisticsSection.tsx b/frontend/src/components/settings/SettingsStatisticsSection.tsx index 42dab34..626088a 100644 --- a/frontend/src/components/settings/SettingsStatisticsSection.tsx +++ b/frontend/src/components/settings/SettingsStatisticsSection.tsx @@ -164,6 +164,12 @@ export function SettingsStatisticsSection({ className }: { className?: string }) {stats.repeaters_heard.last_24_hours} {stats.repeaters_heard.last_week} + + Known-channels active + {stats.known_channels_active.last_hour} + {stats.known_channels_active.last_24_hours} + {stats.known_channels_active.last_week} + diff --git a/frontend/src/test/settingsModal.test.tsx b/frontend/src/test/settingsModal.test.tsx index d7b9c8a..7f6c1a1 100644 --- a/frontend/src/test/settingsModal.test.tsx +++ b/frontend/src/test/settingsModal.test.tsx @@ -584,6 +584,7 @@ describe('SettingsModal', () => { total_outgoing: 30, contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, + known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 }, path_hash_width_24h: { total_packets: 120, single_byte: 60, @@ -630,6 +631,7 @@ describe('SettingsModal', () => { expect(screen.getByText('24 (20.0%)')).toBeInTheDocument(); expect(screen.getByText('Contacts heard')).toBeInTheDocument(); expect(screen.getByText('Repeaters heard')).toBeInTheDocument(); + expect(screen.getByText('Known-channels active')).toBeInTheDocument(); // Busiest channels expect(screen.getByText('general')).toBeInTheDocument(); @@ -650,6 +652,7 @@ describe('SettingsModal', () => { total_outgoing: 30, contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 }, repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 }, + known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 }, path_hash_width_24h: { total_packets: 120, single_byte: 60, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0b26866..062170d 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -514,6 +514,7 @@ export interface StatisticsResponse { total_outgoing: number; contacts_heard: ContactActivityCounts; repeaters_heard: ContactActivityCounts; + known_channels_active: ContactActivityCounts; path_hash_width_24h: { total_packets: number; single_byte: number; diff --git a/tests/test_statistics.py b/tests/test_statistics.py index a6d5539..66b8748 100644 --- a/tests/test_statistics.py +++ b/tests/test_statistics.py @@ -29,6 +29,9 @@ class TestStatisticsEmpty: assert result["repeaters_heard"]["last_hour"] == 0 assert result["repeaters_heard"]["last_24_hours"] == 0 assert result["repeaters_heard"]["last_week"] == 0 + assert result["known_channels_active"]["last_hour"] == 0 + assert result["known_channels_active"]["last_24_hours"] == 0 + assert result["known_channels_active"]["last_week"] == 0 assert result["path_hash_width_24h"] == { "total_packets": 0, "single_byte": 0, @@ -256,6 +259,51 @@ class TestActivityWindows: assert result["repeaters_heard"]["last_24_hours"] == 1 assert result["repeaters_heard"]["last_week"] == 1 + @pytest.mark.asyncio + async def test_known_channels_active_windows(self, test_db): + """Known channels are counted by distinct active keys in each time window.""" + now = int(time.time()) + conn = test_db.conn + + known_1h = "AA" * 16 + known_24h = "BB" * 16 + known_7d = "CC" * 16 + unknown_key = "DD" * 16 + + await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_1h, "chan-1h")) + await conn.execute( + "INSERT INTO channels (key, name) VALUES (?, ?)", (known_24h, "chan-24h") + ) + await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_7d, "chan-7d")) + + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", known_1h, "recent-1", now - 1200), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", known_1h, "recent-2", now - 600), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", known_24h, "day-old", now - 43200), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", known_7d, "week-old", now - 259200), + ) + await conn.execute( + "INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)", + ("CHAN", unknown_key, "unknown", now - 600), + ) + await conn.commit() + + result = await StatisticsRepository.get_all() + + assert result["known_channels_active"]["last_hour"] == 1 + assert result["known_channels_active"]["last_24_hours"] == 2 + assert result["known_channels_active"]["last_week"] == 3 + class TestPathHashWidthStats: @pytest.mark.asyncio