Files
agessaman 75fda424c2 feat(web-viewer): implement per-payload-type multibyte share tracking
- Added a new feature to track and display the multibyte share of packets by payload type in the dashboard.
- Introduced a new database migration to store per-payload-type multibyte encoding data in the daily rollup.
- Updated the dashboard to visualize the multibyte share as a stacked bar chart, reflecting the share of each day's packets that took a multibyte path.
- Enhanced the API to provide raw counts for each payload type, ensuring accurate representation in the dashboard.
- Adjusted the frontend to maintain consistent color coding for payload types and improve the overall user experience.
- Updated tests to validate the new multibyte share functionality and ensure data integrity.
2026-07-31 21:23:51 -07:00

1629 lines
68 KiB
Python

#!/usr/bin/env python3
"""Dashboard statistics: daily rollups, the current-state snapshot, and readers.
The web viewer's landing page used to recompute ~50 aggregate queries on every
request, five times per page load. This module moves that work off the request
path into a background refresher that writes two tables:
``daily_rollup``
One row per local date. Holds the metrics whose raw sources are pruned
long before the dashboard's 30-day window — message/command/path stats at 7
days, packet_stream at 3 — so trends outlive retention. Signal metrics are
stored as sums and counts, never means, so any window re-aggregates
correctly.
``dashboard_snapshot``
A single row of JSON describing current state, regenerated whole each tick.
Every method takes a ``sqlite3.Connection`` rather than owning one, so the
service unit-tests without Flask and the caller controls transaction scope.
Two conventions matter throughout:
* **NULL is not zero.** A NULL column means "no source data for this day" and
must render as a gap. Writing 0 instead produces a fake cliff at the
retention boundary that reads as an outage.
* **Local dates, clamped timestamps.** Rollup dates are local to match
``daily_stats`` (the bot writes ``date('now','localtime')``), while the stats
tables store UTC epochs. Those tables also contain future-dated rows
(observed up to year 2103), so every query clamps to now — otherwise the
rollup grows a row for 2103 and the x-axis spans 77 years.
"""
from __future__ import annotations
import json
import os
import socket
import sqlite3
import time
from contextlib import suppress
from datetime import datetime, timedelta
from typing import Any, Callable
SCHEMA_REV = 1
LEASE_KEY = "dashboard.snapshot_lease"
# Source-presence bitmask stored on each daily_rollup row. A missing table is
# genuinely possible: message_stats/command_stats/path_stats are created by
# stats_command, not by migrations, so `collect_stats = false` installs lack them.
SOURCE_MESSAGE_STATS = 1 << 0
SOURCE_COMMAND_STATS = 1 << 1
SOURCE_PATH_STATS = 1 << 2
SOURCE_PACKET_STREAM = 1 << 3
SOURCE_DAILY_STATS = 1 << 4
SOURCE_CONTACT_TRACKING = 1 << 5
SOURCE_OBSERVED_PATHS = 1 << 6
SOURCE_NAMES = {
SOURCE_MESSAGE_STATS: "message_stats",
SOURCE_COMMAND_STATS: "command_stats",
SOURCE_PATH_STATS: "path_stats",
SOURCE_PACKET_STREAM: "packet_stream",
SOURCE_DAILY_STATS: "daily_stats",
SOURCE_CONTACT_TRACKING: "complete_contact_tracking",
SOURCE_OBSERVED_PATHS: "observed_paths",
}
# Payload types charted on the packet encoding trend, in the fixed order the
# chart assigns its categorical colours in. A colour belongs to a type and not
# to its current rank, so a quiet day for one type must never repaint the other
# seven lines — which means this order is part of the contract with the client,
# not a display detail, and new types are appended rather than inserted.
PACKET_ENCODING_TYPES = (
"GRP_TXT", "RESPONSE", "REQ", "PATH", "TXT_MSG", "ANON_REQ", "GRP_DATA", "ADVERT",
)
# Everything else the firmware emits — ACK, TRACE, MULTIPART, unmapped ordinals
# like 'Type11' — folded into one bucket rather than dropped. On the live mesh
# that is 0.8% of traffic, and dropping it would leave the chart's bar heights a
# share of the charted types instead of a share of the day, disagreeing with the
# multibyte doughnut sitting on the same card. Charting each separately instead
# would spend the palette's remaining separation on traffic nobody watches.
OTHER_PAYLOAD_TYPE = "OTHER"
# Storage/chart order. The named types keep their colour slots and the residual
# bucket sits last, drawn in a neutral so it does not read as a ninth category.
PACKET_ENCODING_BUCKETS = (*PACKET_ENCODING_TYPES, OTHER_PAYLOAD_TYPE)
def packet_share_metric(payload_type: str) -> str:
"""Series-metric name carrying one payload type's multibyte share."""
return f"multibyte_share_{payload_type.lower()}"
def _packet_share_sql(payload_type: str) -> str:
"""Percent of that type's classified packets that used 2- or 3-byte hops.
Reads the JSON written by ``_packet_encoding_by_type``. A day with no
stored split, or none for this type, yields NULL — a gap, not a zero.
"""
multibyte = f"""json_extract(packet_type_encoding, '$."{payload_type}".mb')"""
total = f"""json_extract(packet_type_encoding, '$."{payload_type}".total')"""
return (
f"CASE WHEN COALESCE({total}, 0) > 0 "
f"THEN ROUND(COALESCE({multibyte}, 0) * 100.0 / {total}, 1) END"
)
# Series exposed by /api/dashboard/series and folded into the summary payload.
# The multibyte shares are ratios the UI plots on a 0-100 axis; the rest are
# counts.
SERIES_METRICS: dict[str, str] = {
"messages": "messages_total",
"commands": "commands_total",
"adverts": "adverts_total",
"nodes": "nodes_active",
"new_nodes": "nodes_new",
"packets": "packets_total",
"multibyte_share": (
"CASE WHEN COALESCE(adverts_from_multibyte, 0) + COALESCE(adverts_from_singlebyte, 0) > 0 "
"THEN ROUND(adverts_from_multibyte * 100.0 / "
"(adverts_from_multibyte + adverts_from_singlebyte), 1) END"
),
**{
packet_share_metric(payload_type): _packet_share_sql(payload_type)
for payload_type in PACKET_ENCODING_TYPES
},
}
PACKET_SHARE_METRICS = frozenset(
packet_share_metric(payload_type) for payload_type in PACKET_ENCODING_TYPES
)
# Metrics folded into the summary payload's sparkline block. The per-payload-
# type shares stay out of it: the dashboard chart stacks raw counts from
# ``packet_encoding`` instead, and eight independent percentages cannot be
# restacked into a composition — the shared denominator is gone. They remain on
# /api/dashboard/series for anyone plotting one type on its own.
SUMMARY_METRICS = tuple(
name for name in SERIES_METRICS if name not in PACKET_SHARE_METRICS
)
SUMMARY_SERIES_POINTS = 30
# Categories to show in a role/payload mix before the tail is rolled into "Other".
MIX_ROWS = 8
# MeshCore carries up to a 64-byte path, so a route can be 64 hops long at one
# byte per hop (and proportionally fewer with 2- or 3-byte hashes: 32 and 21).
# Beyond that the path field could not have held it, so the value is corrupt.
#
# Not a display convenience. The earlier limit of 32 was inherited from the old
# dashboard's `BETWEEN 0 AND 32` filters and silently discarded 5,654 flood
# packets on the live database — genuine traffic arriving from as far as 63
# hops. It also applies after the per-node MIN(), so any node whose *closest*
# path exceeded 32 hops vanished from the chart entirely rather than appearing
# at the far end; one such node exists in the live history.
MAX_PLOTTED_HOPS = 64
# Flood hop buckets carrying less than this share of the series are not drawn.
# The tail decays for ~20 hops in bars under a pixel tall, which reads as noise;
# the amount withheld is reported alongside the chart rather than dropped
# quietly.
FLOOD_MIN_SHARE_PCT = 0.1
# Metrics that are already a ratio: a period "total" has to be the mean of the
# daily values, not their sum — adding percentages together means nothing.
RATIO_METRICS = frozenset(
{
"multibyte_share",
*(packet_share_metric(payload_type) for payload_type in PACKET_ENCODING_TYPES),
}
)
TOP_KINDS = ("users", "commands", "channels", "paths", "repeaters", "neighbors")
# Neighbour windows are capped well below observed_paths' 90-day retention: a
# link last exercised a month ago says nothing about whether it works today.
NEIGHBOR_WINDOWS = ("24h", "7d")
# A path is one hop when its byte length equals the per-hop encoding width.
# path_length is measured in BYTES, and with 2- or 3-byte hop encoding a 3-hop
# path is 6 or 9 bytes long — reading the raw value as a hop count inflates
# every multibyte path by 2-3x.
ONE_HOP_PATH = "op.bytes_per_hop > 0 AND op.path_length = op.bytes_per_hop"
# Roles the firmware reports as an unmapped enum ordinal. They are real
# contacts, so they belong in the mix — just not as sixteen singleton slices.
_UNKNOWN_ROLE_PREFIX = "type"
def local_date_str(when: float | None = None) -> str:
"""Local YYYY-MM-DD for *when* (default now), matching date('now','localtime')."""
return datetime.fromtimestamp(time.time() if when is None else when).strftime("%Y-%m-%d")
def day_bounds(date_str: str) -> tuple[float, float]:
"""Local-midnight epoch bounds [start, end) for *date_str*.
Computed in Python rather than with date(timestamp,'unixepoch','localtime')
so the comparison stays sargable against the timestamp indexes, and so DST
transitions produce a genuine 23- or 25-hour day.
"""
start = datetime.strptime(date_str, "%Y-%m-%d")
return start.timestamp(), (start + timedelta(days=1)).timestamp()
def _table_exists(conn: sqlite3.Connection, table: str) -> bool:
row = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)
).fetchone()
return row is not None
def _top_n_with_other(entries: list[list[Any]], limit: int = MIX_ROWS) -> list[list[Any]]:
"""Keep the largest *limit* categories and roll the tail into "Other".
Truncating the list instead would silently drop counts, leaving a chart whose
bars no longer add up to the total printed beside it.
"""
ranked = sorted(entries, key=lambda entry: -(entry[1] or 0))
if len(ranked) <= limit:
return ranked
tail = sum(entry[1] or 0 for entry in ranked[limit:])
return [*ranked[:limit], ["Other", tail]]
def _change_pct(current: float | None, previous: float | None) -> float | None:
"""Percent change, or None when the baseline cannot support one.
Computed server-side precisely so the client never has to decide what
dividing by zero means.
"""
if current is None or previous is None:
return None
if previous == 0:
return None
return round(((current - previous) / previous) * 100, 1)
def normalize_role(role: str | None) -> str:
"""Map firmware role values to display buckets, folding unmapped ordinals.
A handful of contacts report role as the raw enum ordinal ('type0'…'type15')
rather than a name. Charting each as its own slice buries the real roles.
"""
text = (role or "").strip()
if not text:
return "Unknown"
lowered = text.lower()
if lowered.startswith(_UNKNOWN_ROLE_PREFIX) and lowered[len(_UNKNOWN_ROLE_PREFIX):].isdigit():
return "Unknown"
return lowered
def humanize_span(seconds: float | None) -> str:
"""Render a covered-window length as 'last 2d 6h' / 'last 45m'."""
if seconds is None or seconds <= 0:
return "no data"
days, remainder = divmod(int(seconds), 86400)
hours, remainder = divmod(remainder, 3600)
minutes = remainder // 60
if days:
return f"last {days}d {hours}h" if hours else f"last {days}d"
if hours:
return f"last {hours}h {minutes}m" if minutes else f"last {hours}h"
return f"last {max(1, minutes)}m"
class DashboardStatsService:
"""Computes and reads the dashboard's rollup and snapshot tables.
``multibyte_contacts_fn`` is injected by the app so the service can reuse
the viewer's cached hop-prefix evidence without importing Flask. It takes a
cursor and returns ``(multibyte_count, total_count)`` for contacts heard in
the last 7 days, or None if it cannot be computed.
"""
def __init__(
self,
logger,
*,
history_days: int = 400,
packet_backfill_rows: int = 2000,
interval_seconds: int = 60,
stats_retention_days: int = 7,
packet_retention_days: int = 3,
adverts_retention_days: int = 90,
multibyte_contacts_fn: Callable[[sqlite3.Cursor], tuple[int, int] | None] | None = None,
) -> None:
self.logger = logger
self.history_days = max(7, int(history_days))
self.packet_backfill_rows = max(0, int(packet_backfill_rows))
self.interval_seconds = max(5, int(interval_seconds))
self.stats_retention_days = max(1, int(stats_retention_days))
self.packet_retention_days = max(1, int(packet_retention_days))
self.adverts_retention_days = max(1, int(adverts_retention_days))
self.multibyte_contacts_fn = multibyte_contacts_fn
# Recompute today plus a trailing window, bounded by each source's own
# retention: recomputing a day whose raw rows were just pruned would
# overwrite a real value with a zero.
self.trailing_days = 3
# -- source availability -------------------------------------------------
def detect_sources(self, conn: sqlite3.Connection) -> int:
"""Bitmask of the source tables actually present in this database."""
present = 0
for bit, name in SOURCE_NAMES.items():
if _table_exists(conn, name):
present |= bit
return present
def _retention_for(self, bit: int) -> int:
if bit == SOURCE_PACKET_STREAM:
return self.packet_retention_days
if bit in (SOURCE_MESSAGE_STATS, SOURCE_COMMAND_STATS, SOURCE_PATH_STATS):
return self.stats_retention_days
return self.adverts_retention_days
def _recompute_dates(self, today: str) -> list[str]:
"""Dates to recompute this tick: today plus the trailing window."""
base = datetime.strptime(today, "%Y-%m-%d")
return [
(base - timedelta(days=offset)).strftime("%Y-%m-%d")
for offset in range(self.trailing_days + 1)
]
# -- packet dimension backfill ------------------------------------------
def backfill_packet_dims(self, conn: sqlite3.Connection) -> int:
"""Populate denormalized packet columns for a bounded batch of old rows.
Rows written before migration 0019 carry their dimensions only inside
the JSON blob. Widening all of them at once rewrites the whole table
into the WAL, so this walks backwards a batch per tick instead; with
3-day retention the backlog clears long before it matters.
route_type_name doubles as the "already dimensioned" marker, so a row
whose JSON has no route type is stamped UNKNOWN_ROUTE rather than left
NULL — otherwise the same batch would be reselected on every tick and
the backlog would never advance.
Returns the number of rows updated.
"""
if not self.packet_backfill_rows or not _table_exists(conn, "packet_stream"):
return 0
try:
cursor = conn.execute(
"""
UPDATE packet_stream SET
route_type_name = COALESCE(json_extract(data, '$.route_type_name'),
'UNKNOWN_ROUTE'),
payload_type_name = json_extract(data, '$.payload_type_name'),
path_len = CAST(json_extract(data, '$.path_len') AS INTEGER),
bytes_per_hop = CAST(json_extract(data, '$.bytes_per_hop') AS INTEGER)
WHERE id IN (
SELECT id FROM packet_stream
WHERE type = 'packet' AND route_type_name IS NULL
ORDER BY id DESC LIMIT ?
)
""",
(self.packet_backfill_rows,),
)
return cursor.rowcount or 0
except sqlite3.OperationalError as exc:
# Older SQLite builds without the JSON1 extension, or a viewer DB
# that predates the migration. Packet tiles degrade to "unknown".
self.logger.debug(f"packet_stream dimension backfill unavailable: {exc}")
return 0
def packet_coverage(self, conn: sqlite3.Connection) -> dict[str, Any]:
"""Actual time span covered by packet rows carrying denormalized dims.
The old dashboard labelled this window "last 7 days" while packet_stream
retention is 3 — reporting the measured span instead of a config value
is the whole point.
"""
coverage: dict[str, Any] = {
"packets_from": None,
"packets_to": None,
"packets_window_label": "no data",
"packets_with_dims": 0,
}
if not _table_exists(conn, "packet_stream"):
return coverage
row = conn.execute(
"""
SELECT MIN(timestamp), MAX(timestamp), COUNT(*)
FROM packet_stream
WHERE type = 'packet' AND route_type_name IS NOT NULL
"""
).fetchone()
if not row or not row[2]:
return coverage
coverage["packets_from"] = row[0]
coverage["packets_to"] = row[1]
coverage["packets_with_dims"] = row[2]
coverage["packets_window_label"] = humanize_span(time.time() - row[0])
return coverage
# -- daily rollup --------------------------------------------------------
def _first_advert_dates(self, conn: sqlite3.Connection) -> dict[str, str]:
"""public_key -> earliest daily_stats date, for new-node attribution.
Computed once per refresh: the alternative is a HAVING MIN(date) scan
per day, which the backfill would run ninety times.
"""
if not _table_exists(conn, "daily_stats"):
return {}
return {
row[0]: row[1]
for row in conn.execute(
"SELECT public_key, MIN(date) FROM daily_stats GROUP BY public_key"
)
}
def _message_metrics(
self, conn: sqlite3.Connection, start: float, end: float
) -> dict[str, Any]:
row = conn.execute(
"""
SELECT COUNT(*),
SUM(CASE WHEN is_dm THEN 1 ELSE 0 END),
SUM(CASE WHEN channel IS NOT NULL AND channel != '' THEN 1 ELSE 0 END),
COUNT(DISTINCT sender_id),
COUNT(DISTINCT channel),
SUM(snr), SUM(CASE WHEN snr IS NOT NULL THEN 1 ELSE 0 END),
SUM(rssi), SUM(CASE WHEN rssi IS NOT NULL THEN 1 ELSE 0 END),
SUM(hops), SUM(CASE WHEN hops IS NOT NULL THEN 1 ELSE 0 END)
FROM message_stats
WHERE timestamp >= ? AND timestamp < ?
""",
(start, end),
).fetchone()
return {
"messages_total": row[0] or 0,
"messages_dm": row[1] or 0,
"messages_channel": row[2] or 0,
"unique_senders": row[3] or 0,
"unique_channels": row[4] or 0,
"snr_sum": row[5],
"snr_count": row[6] or 0,
"rssi_sum": row[7],
"rssi_count": row[8] or 0,
"hops_sum": row[9],
"hops_count": row[10] or 0,
}
def _command_metrics(
self, conn: sqlite3.Connection, start: float, end: float
) -> dict[str, Any]:
row = conn.execute(
"""
SELECT COUNT(*),
SUM(CASE WHEN response_sent THEN 1 ELSE 0 END),
COUNT(DISTINCT sender_id)
FROM command_stats
WHERE timestamp >= ? AND timestamp < ?
""",
(start, end),
).fetchone()
return {
"commands_total": row[0] or 0,
"commands_replied": row[1] or 0,
"unique_command_users": row[2] or 0,
}
def _path_metrics(
self, conn: sqlite3.Connection, start: float, end: float
) -> dict[str, Any]:
row = conn.execute(
"""
SELECT COUNT(*), MAX(path_length), SUM(path_length)
FROM path_stats
WHERE timestamp >= ? AND timestamp < ?
""",
(start, end),
).fetchone()
return {
"path_obs_total": row[0] or 0,
"path_len_max": row[1],
"path_len_sum": row[2],
}
def _packet_metrics(
self, conn: sqlite3.Connection, start: float, end: float
) -> dict[str, Any]:
row = conn.execute(
"""
SELECT COUNT(*),
SUM(CASE WHEN route_type_name LIKE '%FLOOD' THEN 1 ELSE 0 END),
SUM(CASE WHEN route_type_name LIKE '%DIRECT' THEN 1 ELSE 0 END),
SUM(CASE WHEN bytes_per_hop IN (2, 3) THEN 1 ELSE 0 END)
FROM packet_stream
WHERE type = 'packet' AND timestamp >= ? AND timestamp < ?
""",
(start, end),
).fetchone()
return {
"packets_total": row[0] or 0,
"packets_flood": row[1] or 0,
"packets_direct": row[2] or 0,
"packets_multibyte": row[3] or 0,
"packet_type_encoding": self._packet_encoding_by_type(conn, start, end),
}
def _packet_encoding_by_type(
self, conn: sqlite3.Connection, start: float, end: float
) -> str | None:
"""One day's multibyte split per payload type, as JSON.
Written now because it cannot be recovered later: packet_stream is
pruned at three days while the chart shows thirty.
Covers *every* payload type, with the uncharted tail summed into
``OTHER``, because the totals here are the chart's denominator: bar
heights are each type's multibyte packets over the day's whole traffic,
so leaving a type out of the file would quietly inflate every bar.
Packets whose dimensions the refresher has not backfilled yet are
excluded from both sides rather than counted as single-byte. On a
count that would merely undershoot; on a ratio it invents a dip in
whichever type the backfill has not reached, which reads as a real
change in the mesh.
"""
counts: dict[str, dict[str, int]] = {}
for name, total, multibyte in conn.execute(
"""
SELECT payload_type_name, COUNT(*),
SUM(CASE WHEN bytes_per_hop IN (2, 3) THEN 1 ELSE 0 END)
FROM packet_stream
WHERE type = 'packet' AND timestamp >= ? AND timestamp < ?
AND bytes_per_hop IS NOT NULL
GROUP BY payload_type_name
""",
(start, end),
):
# A NULL type is dimensioned but unnamed, which is still a packet
# the day's traffic contains — it belongs in the residual bucket,
# not thrown away.
key = name if name in PACKET_ENCODING_TYPES else OTHER_PAYLOAD_TYPE
bucket = counts.setdefault(key, {"mb": 0, "total": 0})
bucket["mb"] += multibyte or 0
bucket["total"] += total or 0
# Nothing found means "cannot say", not "the mesh was silent": a day
# whose rows were pruned ahead of the retention window would otherwise
# overwrite the split recorded for it while the rows still existed.
return json.dumps(counts, separators=(",", ":")) if counts else None
def _advert_metrics(
self,
conn: sqlite3.Connection,
date_str: str,
first_seen: dict[str, str],
multibyte_keys: set[str] | None,
) -> dict[str, Any]:
row = conn.execute(
"SELECT SUM(advert_count), COUNT(DISTINCT public_key) FROM daily_stats WHERE date = ?",
(date_str,),
).fetchone()
metrics: dict[str, Any] = {
"adverts_total": row[0] or 0,
"nodes_active": row[1] or 0,
"nodes_new": sum(1 for first in first_seen.values() if first == date_str),
"adverts_from_multibyte": None,
"adverts_from_singlebyte": None,
}
if multibyte_keys is None:
return metrics
# Classification is only knowable as of now, so this split is written
# for the recompute window and then frozen — see the tooltip copy.
multibyte = single = 0
for key, count in conn.execute(
"SELECT public_key, advert_count FROM daily_stats WHERE date = ?", (date_str,)
):
if key in multibyte_keys:
multibyte += count or 0
else:
single += count or 0
metrics["adverts_from_multibyte"] = multibyte
metrics["adverts_from_singlebyte"] = single
return metrics
def _multibyte_advert_keys(self, conn: sqlite3.Connection) -> set[str] | None:
"""Public keys currently classified as advertising over multibyte paths."""
if not _table_exists(conn, "observed_paths"):
return None
try:
return {
row[0]
for row in conn.execute(
"""
SELECT DISTINCT public_key FROM observed_paths
WHERE packet_type = 'advert' AND public_key IS NOT NULL
AND bytes_per_hop IN (2, 3)
"""
)
if row[0]
}
except sqlite3.Error as exc:
self.logger.debug(f"Could not load multibyte advert keys: {exc}")
return None
def compute_day(
self,
conn: sqlite3.Connection,
date_str: str,
*,
sources: int,
today: str,
first_seen: dict[str, str],
multibyte_keys: set[str] | None,
backfill: bool = False,
) -> dict[str, Any]:
"""Recompute every rollup column for one local date.
Families whose raw rows have already aged out contribute NULL rather
than 0, so an upsert preserves whatever history is already stored.
"""
start, end = day_bounds(date_str)
now = time.time()
# Future-dated rows exist in the wild (max observed: 2103-08-15); an
# unclamped upper bound would mint a rollup row for that year.
end = min(end, now)
values: dict[str, Any] = {"date": date_str}
present = 0
if end <= start:
# Entirely in the future — nothing observable belongs to this date.
return {"values": values, "sources": 0}
age_days = (datetime.strptime(today, "%Y-%m-%d") - datetime.strptime(date_str, "%Y-%m-%d")).days
def _in_retention(bit: int) -> bool:
return age_days < self._retention_for(bit)
for bit, compute in (
(SOURCE_MESSAGE_STATS, self._message_metrics),
(SOURCE_COMMAND_STATS, self._command_metrics),
(SOURCE_PATH_STATS, self._path_metrics),
(SOURCE_PACKET_STREAM, self._packet_metrics),
):
if not (sources & bit) or not _in_retention(bit):
continue
try:
values.update(compute(conn, start, end))
present |= bit
except sqlite3.Error as exc:
self.logger.debug(f"Rollup {SOURCE_NAMES[bit]} for {date_str}: {exc}")
if sources & SOURCE_DAILY_STATS:
try:
values.update(
self._advert_metrics(conn, date_str, first_seen, multibyte_keys)
)
present |= SOURCE_DAILY_STATS
except sqlite3.Error as exc:
self.logger.debug(f"Rollup daily_stats for {date_str}: {exc}")
# Contact totals are a point-in-time reading with no historical form, so
# only today's row gets them; older rows keep whatever was stored then.
if (sources & SOURCE_CONTACT_TRACKING) and date_str == today and not backfill:
try:
row = conn.execute(
"""
SELECT COUNT(*), SUM(CASE WHEN is_currently_tracked = 1 THEN 1 ELSE 0 END)
FROM complete_contact_tracking
"""
).fetchone()
values["contacts_known"] = row[0] or 0
values["contacts_tracked"] = row[1] or 0
present |= SOURCE_CONTACT_TRACKING
except sqlite3.Error as exc:
self.logger.debug(f"Rollup contact totals for {date_str}: {exc}")
return {"values": values, "sources": present}
ROLLUP_COLUMNS = (
"messages_total", "messages_dm", "messages_channel",
"unique_senders", "unique_channels",
"commands_total", "commands_replied", "unique_command_users",
"path_obs_total", "path_len_max", "path_len_sum",
"snr_sum", "snr_count", "rssi_sum", "rssi_count",
"hops_sum", "hops_count",
"packets_total", "packets_flood", "packets_direct", "packets_multibyte",
"packet_type_encoding",
"adverts_total", "nodes_active", "nodes_new",
"adverts_from_multibyte", "adverts_from_singlebyte",
"contacts_known", "contacts_tracked",
)
def upsert_day(
self,
conn: sqlite3.Connection,
computed: dict[str, Any],
*,
is_backfilled: bool = False,
is_final: bool = False,
) -> None:
"""Write one recomputed date.
Idempotent: every value is a full recomputation from raw rows, not an
increment. Columns arrive as NULL when their source is absent or aged
out, and COALESCE keeps the stored value rather than erasing history.
"""
values = computed["values"]
columns = ("date", *self.ROLLUP_COLUMNS, "sources_present", "is_backfilled", "is_final", "computed_at")
params = [values["date"]]
params.extend(values.get(column) for column in self.ROLLUP_COLUMNS)
params.extend([
computed["sources"],
1 if is_backfilled else 0,
1 if is_final else 0,
datetime.now().isoformat(timespec="seconds"),
])
assignments = ", ".join(
f"{column} = COALESCE(excluded.{column}, daily_rollup.{column})"
for column in self.ROLLUP_COLUMNS
)
conn.execute(
f"""
INSERT INTO daily_rollup ({', '.join(columns)})
VALUES ({', '.join('?' * len(columns))})
ON CONFLICT(date) DO UPDATE SET
{assignments},
sources_present = daily_rollup.sources_present | excluded.sources_present,
is_backfilled = CASE WHEN excluded.is_backfilled = 0 THEN 0
ELSE daily_rollup.is_backfilled END,
is_final = MAX(daily_rollup.is_final, excluded.is_final),
computed_at = excluded.computed_at
""",
params,
)
def plan_backfill(self, conn: sqlite3.Connection) -> list[dict[str, Any]]:
"""Compute (read-only) one rollup row per date present in daily_stats.
Message, command, path and packet columns stay NULL: those raw rows were
pruned days ago and cannot be recovered. The UI renders them as gaps.
Separated from the write so the ~90-day pass never holds a write lock
while it reads.
"""
if not _table_exists(conn, "daily_stats"):
return []
today = local_date_str()
horizon = (
datetime.strptime(today, "%Y-%m-%d") - timedelta(days=self.history_days)
).strftime("%Y-%m-%d")
dates = [
row[0]
for row in conn.execute(
"SELECT DISTINCT date FROM daily_stats WHERE date >= ? AND date <= ? ORDER BY date",
(horizon, today),
)
if row[0]
]
if not dates:
return []
first_seen = self._first_advert_dates(conn)
sources = self.detect_sources(conn) & SOURCE_DAILY_STATS
return [
self.compute_day(
conn,
date_str,
sources=sources,
today=today,
first_seen=first_seen,
multibyte_keys=None,
backfill=True,
)
for date_str in dates
]
def write_backfill(self, conn: sqlite3.Connection, planned: list[dict[str, Any]]) -> int:
"""Persist a planned backfill. Caller owns the transaction."""
today = local_date_str()
for computed in planned:
self.upsert_day(
conn,
computed,
is_backfilled=True,
is_final=computed["values"]["date"] < today,
)
return len(planned)
def has_history(self, conn: sqlite3.Connection) -> bool:
"""True once daily_rollup holds anything — backfill runs only when empty."""
row = conn.execute("SELECT 1 FROM daily_rollup LIMIT 1").fetchone()
return row is not None
def prune_history(self, conn: sqlite3.Connection) -> int:
cutoff = (datetime.now() - timedelta(days=self.history_days)).strftime("%Y-%m-%d")
cursor = conn.execute("DELETE FROM daily_rollup WHERE date < ?", (cutoff,))
return cursor.rowcount or 0
# -- snapshot ------------------------------------------------------------
def _mesh_snapshot(self, conn: sqlite3.Connection, sources: int) -> dict[str, Any]:
mesh: dict[str, Any] = {}
today = local_date_str()
if sources & SOURCE_DAILY_STATS:
row = conn.execute(
"SELECT SUM(advert_count), COUNT(DISTINCT public_key) FROM daily_stats WHERE date = ?",
(today,),
).fetchone()
mesh["adverts_24h"] = row[0] or 0
mesh["nodes_24h"] = row[1] or 0
week_ago = (datetime.now() - timedelta(days=6)).strftime("%Y-%m-%d")
row = conn.execute(
"""
SELECT SUM(CASE WHEN first_date = ? THEN 1 ELSE 0 END),
SUM(CASE WHEN first_date >= ? THEN 1 ELSE 0 END)
FROM (SELECT MIN(date) AS first_date FROM daily_stats GROUP BY public_key)
""",
(today, week_ago),
).fetchone()
mesh["new_nodes_24h"] = row[0] or 0
mesh["new_nodes_7d"] = row[1] or 0
if sources & SOURCE_CONTACT_TRACKING:
row = conn.execute(
"""
SELECT COUNT(*),
SUM(CASE WHEN is_currently_tracked = 1 THEN 1 ELSE 0 END),
SUM(CASE WHEN last_heard < datetime('now','localtime','-7 days')
AND last_heard >= datetime('now','localtime','-14 days')
THEN 1 ELSE 0 END)
FROM complete_contact_tracking
"""
).fetchone()
mesh["contacts_known"] = row[0] or 0
mesh["contacts_tracked"] = row[1] or 0
# "Heard last week, silent this week" — a churn signal, not a total.
mesh["gone_quiet_7d"] = row[2] or 0
# Each column carries its own emptiness test rather than sharing one
# in the WHERE clause: filtering the row set on country would drop
# contacts that have a state or city but no country, undercounting
# both. COUNT(DISTINCT ...) already ignores the NULLs.
row = conn.execute(
"""
SELECT COUNT(DISTINCT CASE
WHEN country IS NULL OR country = '' THEN NULL
WHEN country IN ('United States','United States of America','US','USA')
THEN 'United States' ELSE country END),
COUNT(DISTINCT CASE WHEN state IS NOT NULL AND state != '' THEN state END),
COUNT(DISTINCT CASE WHEN city IS NOT NULL AND city != '' THEN city END)
FROM complete_contact_tracking
WHERE last_heard > datetime('now','localtime','-30 days')
AND is_currently_tracked = 1
"""
).fetchone()
mesh["countries"] = row[0] or 0
mesh["states"] = row[1] or 0
mesh["cities"] = row[2] or 0
mesh["role_mix"] = self._mix(conn, "role")
mesh["hops"] = self._hops_distribution(conn, sources)
if sources & SOURCE_OBSERVED_PATHS:
mesh["neighbors"] = {
window: self._count_one_hop_nodes(conn, window)
for window in NEIGHBOR_WINDOWS
}
if sources & SOURCE_PACKET_STREAM:
row = conn.execute(
"""
SELECT SUM(CASE WHEN route_type_name LIKE '%FLOOD' THEN 1 ELSE 0 END),
SUM(CASE WHEN route_type_name LIKE '%DIRECT' THEN 1 ELSE 0 END),
SUM(CASE WHEN bytes_per_hop IN (2, 3) THEN 1 ELSE 0 END),
COUNT(*)
FROM packet_stream
WHERE type = 'packet' AND route_type_name IS NOT NULL
"""
).fetchone()
mesh["route_mix"] = {"flood": row[0] or 0, "direct": row[1] or 0}
mesh.setdefault("encoding", {})["packets"] = {
"multibyte": row[2] or 0,
"total": row[3] or 0,
}
# Same population as route_mix — what the traffic is, beside how it
# is routed.
mesh["payload_mix"] = _top_n_with_other(
[
[name or "Unknown", count]
for name, count in conn.execute(
"""
SELECT payload_type_name, COUNT(*) FROM packet_stream
WHERE type = 'packet' AND route_type_name IS NOT NULL
GROUP BY payload_type_name ORDER BY COUNT(*) DESC
"""
)
]
)
if self.multibyte_contacts_fn is not None:
try:
result = self.multibyte_contacts_fn(conn.cursor())
except Exception as exc: # noqa: BLE001 - injected callback
self.logger.debug(f"Multibyte contact evidence unavailable: {exc}")
result = None
if result is not None:
multibyte, total = result
mesh.setdefault("encoding", {})["contacts_7d"] = {
"multibyte": multibyte,
"total": total,
}
return mesh
def _mix(self, conn: sqlite3.Connection, column: str) -> list[list[Any]]:
"""Contact counts per role, unmapped ordinals folded into Unknown."""
buckets: dict[str, int] = {}
for value, count in conn.execute(
f"SELECT {column}, COUNT(*) FROM complete_contact_tracking GROUP BY {column}" # noqa: S608 - fixed identifiers
):
key = normalize_role(value) if column == "role" else (value or "Unknown")
buckets[key] = buckets.get(key, 0) + (count or 0)
return _top_n_with_other([[name, count] for name, count in buckets.items()])
def _hops_distribution(self, conn: sqlite3.Connection, sources: int) -> dict[str, Any]:
"""Two views of distance: where nodes are, and where flood traffic comes from.
Beware that the two tables count paths in different units, and the
conversion is not symmetric:
* ``observed_paths.path_length`` is a BYTE count, so hops are
``path_length / bytes_per_hop`` — a 3-hop multibyte path is 6 or 9.
* ``packet_stream.path_len`` is already a HOP count, with the byte
length carried separately as ``path_byte_length``.
Dividing the second by bytes_per_hop, or failing to divide the first,
silently rescales a whole axis. Verified against live rows in
tests/test_dashboard_stats.py::TestHopConventions.
The two series also cover different spans — adverts over 7 days,
packets over whatever packet_stream retains (typically 3) — so each is
labelled with its own window rather than being presented as one period.
"""
distribution: dict[str, Any] = {"nodes": [], "flood_packets": []}
if sources & SOURCE_OBSERVED_PATHS:
distribution["nodes"] = self._bucket_hops(
conn.execute(
"""
SELECT MIN(path_length / bytes_per_hop) AS hops, COUNT(*) AS n
FROM observed_paths
WHERE packet_type = 'advert' AND bytes_per_hop > 0
AND last_seen >= datetime('now','localtime','-7 days')
GROUP BY public_key
"""
),
per_row=True,
)
if sources & SOURCE_PACKET_STREAM:
# path_len is already hops here — do NOT divide by bytes_per_hop.
distribution["flood_packets"] = self._bucket_hops(
conn.execute(
"""
SELECT path_len AS hops, COUNT(*) AS n FROM packet_stream
WHERE type = 'packet' AND route_type_name LIKE '%FLOOD'
AND path_len IS NOT NULL
GROUP BY path_len
"""
)
)
totals = {key: sum(count for _, count in series) for key, series in distribution.items()}
# The flood tail is a long thin decay — on the live mesh it runs to 63
# hops in bars under a pixel tall. Hide the buckets carrying less than
# FLOOD_MIN_SHARE_PCT of the series, but count what was hidden: an
# unannounced cut is the same lie as a truncated category list.
flood_total = totals["flood_packets"]
hidden_packets = 0
hidden_buckets = 0
if flood_total:
kept = []
for hop, count in distribution["flood_packets"]:
if count and (count / flood_total) * 100 < FLOOD_MIN_SHARE_PCT:
hidden_packets += count
hidden_buckets += 1
kept.append([hop, None])
else:
kept.append([hop, count])
distribution["flood_packets"] = kept
# Pad both onto one contiguous axis so the bars line up, bounded by the
# hops that actually show something: an axis that runs on past the last
# drawn bar spends its width on nothing.
populated = [
hop
for series in distribution.values()
for hop, count in series
if count
]
if populated:
low, high = min(populated), max(populated)
for key, series in distribution.items():
counts = dict(series)
# Gaps pad with 0 ("no packets at this hop"), which is a
# different claim from the None used above for "withheld".
distribution[key] = [
[hop, counts.get(hop, 0)] for hop in range(low, high + 1)
]
distribution["totals"] = totals
distribution["flood_hidden"] = {
"packets": hidden_packets,
"buckets": hidden_buckets,
"share_pct": round(hidden_packets / flood_total * 100, 2) if flood_total else 0,
"threshold_pct": FLOOD_MIN_SHARE_PCT,
}
return distribution
@staticmethod
def _bucket_hops(rows, per_row: bool = False) -> list[list[int]]:
"""Fold (hops, n) rows into a hop -> count mapping, dropping absurd hops."""
counts: dict[int, int] = {}
for row in rows:
hops = row["hops"]
if hops is None or not 0 <= hops <= MAX_PLOTTED_HOPS:
continue
counts[int(hops)] = counts.get(int(hops), 0) + (1 if per_row else (row["n"] or 0))
return [[hop, count] for hop, count in sorted(counts.items())]
def _count_one_hop_nodes(self, conn: sqlite3.Connection, window: str) -> int:
return conn.execute(
f"""
SELECT COUNT(DISTINCT op.public_key) FROM observed_paths op
WHERE op.packet_type = 'advert' AND {ONE_HOP_PATH}
AND op.last_seen > datetime('now','localtime', ?)
""", # noqa: S608 - ONE_HOP_PATH is a module constant, not input
(self._window_offset(window),),
).fetchone()[0] or 0
@staticmethod
def _window_offset(window: str) -> str:
return {"24h": "-1 days", "7d": "-7 days", "30d": "-30 days"}.get(window, "-7 days")
def _one_hop_rows(self, conn: sqlite3.Connection, window: str, limit: int) -> list[dict]:
"""Nodes whose advert reached this radio in a single hop, weakest first.
Membership comes from path evidence, not from
``complete_contact_tracking.hop_count``. That column claims 800 zero-hop
contacts, but only 68 of them have any one-hop path to corroborate it,
their stored SNR piles up in a 1.5 dB band (655 of 800 between 11.25 and
12.75), and their RSSI clusters around -45 dBm — the signature of one
strong local link being recorded against every node whose traffic came
through it, not of hundreds of separate radios.
Signal is therefore reported only where the path evidence and the stored
hop_count agree; everything else lists as unknown rather than being given
a number that belongs to somebody else's link.
"""
rows = conn.execute(
f"""
SELECT op.public_key,
MAX(op.last_seen) AS last_seen,
c.name, c.role, c.snr, c.signal_strength, c.hop_count
FROM observed_paths op
LEFT JOIN complete_contact_tracking c ON c.public_key = op.public_key
WHERE op.packet_type = 'advert' AND {ONE_HOP_PATH}
AND op.last_seen > datetime('now','localtime', ?)
GROUP BY op.public_key
""", # noqa: S608 - ONE_HOP_PATH is a module constant, not input
(self._window_offset(window),),
).fetchall()
measured: list[dict[str, Any]] = []
unmeasured: list[dict[str, Any]] = []
for row in rows:
corroborated = row["hop_count"] == 0 and row["snr"] is not None
item = {
"name": row["name"] or (row["public_key"] or "")[:12],
"public_key": row["public_key"],
"role": normalize_role(row["role"]),
"snr": round(float(row["snr"]), 1) if corroborated else None,
"rssi": (
round(float(row["signal_strength"]))
if corroborated and row["signal_strength"] is not None
else None
),
"signal_corroborated": corroborated,
"last_seen": row["last_seen"],
}
(measured if corroborated else unmeasured).append(item)
# Weakest measured links first — those are the ones worth acting on.
measured.sort(key=lambda item: item["snr"])
unmeasured.sort(key=lambda item: item["last_seen"] or "", reverse=True)
return (measured + unmeasured)[:limit]
def _bot_snapshot(self, conn: sqlite3.Connection, sources: int) -> dict[str, Any]:
"""Rolling 24-hour bot activity (distinct from the calendar-day rollup)."""
bot: dict[str, Any] = {}
now = time.time()
cutoff = now - 86400
if sources & SOURCE_MESSAGE_STATS:
row = conn.execute(
"""
SELECT COUNT(*), COUNT(DISTINCT sender_id), COUNT(DISTINCT channel)
FROM message_stats WHERE timestamp >= ? AND timestamp <= ?
""",
(cutoff, now),
).fetchone()
bot["messages_24h"] = row[0] or 0
bot["unique_users_24h"] = row[1] or 0
bot["unique_channels_24h"] = row[2] or 0
if sources & SOURCE_COMMAND_STATS:
row = conn.execute(
"""
SELECT COUNT(*), SUM(CASE WHEN response_sent THEN 1 ELSE 0 END)
FROM command_stats WHERE timestamp >= ? AND timestamp <= ?
""",
(cutoff, now),
).fetchone()
total = row[0] or 0
bot["commands_24h"] = total
bot["reply_rate_24h"] = round(((row[1] or 0) / total) * 100, 1) if total else None
return bot
def _series_from_rollup(
self, conn: sqlite3.Connection, points: int = SUMMARY_SERIES_POINTS
) -> dict[str, list[dict[str, Any]]]:
expressions = ", ".join(f"{SERIES_METRICS[name]} AS {name}" for name in SUMMARY_METRICS)
rows = conn.execute(
f"""
SELECT date, is_final, {expressions}
FROM daily_rollup ORDER BY date DESC LIMIT ?
""",
(points,),
).fetchall()
series: dict[str, list[dict[str, Any]]] = {name: [] for name in SUMMARY_METRICS}
for row in reversed(rows):
for name in SUMMARY_METRICS:
series[name].append(
{
"date": row["date"],
"value": row[name],
"complete": bool(row["is_final"]),
}
)
return series
def _deltas_from_rollup(self, conn: sqlite3.Connection) -> dict[str, float | None]:
"""Percent change of the last complete day against the day before it.
Explicitly calendar days, not a rolling 24 hours: quoting one against
the other is the classic dashboard lie, so the UI says which it is.
"""
expressions = ", ".join(f"{SERIES_METRICS[name]} AS {name}" for name in SUMMARY_METRICS)
rows = conn.execute(
f"""
SELECT date, {expressions} FROM daily_rollup
WHERE is_final = 1 ORDER BY date DESC LIMIT 2
"""
).fetchall()
if len(rows) < 2:
return {}
current, previous = rows[0], rows[1]
return {name: _change_pct(current[name], previous[name]) for name in SUMMARY_METRICS}
def _packet_encoding_from_rollup(
self, conn: sqlite3.Connection, points: int = SUMMARY_SERIES_POINTS
) -> list[dict[str, Any]]:
"""Per-day multibyte/total counts per payload type, oldest first.
The chart stacks raw counts rather than the ratio series because a
composition needs one shared denominator — each day's multibyte total —
and that cannot be rebuilt from eight percentages taken over eight
different denominators. Sending the counts also lets the tooltip quote
both readings: a type's slice of the day's multibyte traffic, and how
much of that type's own traffic was multibyte.
"""
rows = conn.execute(
"SELECT date, is_final, packet_type_encoding FROM daily_rollup "
"ORDER BY date DESC LIMIT ?",
(points,),
).fetchall()
series: list[dict[str, Any]] = []
for row in reversed(rows):
try:
stored = json.loads(row["packet_type_encoding"] or "{}")
except (TypeError, ValueError):
self.logger.debug(f"Unreadable packet encoding split for {row['date']}")
stored = {}
series.append(
{
"date": row["date"],
"complete": bool(row["is_final"]),
# Keyed by name: Flask sorts JSON object keys, so the client
# cannot read a colour slot off position and looks each
# bucket up instead. OTHER is included — it is part of the
# denominator the client divides by.
"types": {
name: stored[name]
for name in PACKET_ENCODING_BUCKETS
if isinstance(stored.get(name), dict)
},
}
)
return series
def build_snapshot(self, conn: sqlite3.Connection) -> dict[str, Any]:
"""Assemble the whole current-state payload. Read-only."""
sources = self.detect_sources(conn)
payload: dict[str, Any] = {
"coverage": {
**self.packet_coverage(conn),
"stats_retention_days": self.stats_retention_days,
"packet_retention_days": self.packet_retention_days,
"adverts_retention_days": self.adverts_retention_days,
"sources_present": [
name for bit, name in SOURCE_NAMES.items() if sources & bit
],
},
"mesh": self._mesh_snapshot(conn, sources),
"bot": self._bot_snapshot(conn, sources),
}
payload["series"] = self._series_from_rollup(conn)
payload["deltas"] = self._deltas_from_rollup(conn)
payload["packet_encoding"] = self._packet_encoding_from_rollup(conn)
return payload
# -- refresh orchestration ----------------------------------------------
def refresh(self, conn: sqlite3.Connection, *, backfill: bool | None = None) -> dict[str, Any]:
"""Recompute rollups and the snapshot.
The whole read phase runs outside any explicit transaction and every
write goes in one short BEGIN IMMEDIATE at the end, so a refresh never
holds a write lock while it reads and cannot stall the bot's inserts.
``backfill`` defaults to "only if daily_rollup is empty".
"""
started = time.perf_counter()
today = local_date_str()
# --- write phase 0: dimension a batch of older packet rows -----------
# First, not last: every packet figure below reads the denormalized
# columns, so backfilling afterwards would make each tick describe the
# previous tick's coverage and leave the counts drifting upward until
# the backlog cleared.
conn.execute("BEGIN IMMEDIATE")
try:
backfilled_packets = self.backfill_packet_dims(conn)
conn.commit()
except Exception:
conn.rollback()
raise
# --- read phase 1: everything the rollup rows need -------------------
sources = self.detect_sources(conn)
first_seen = self._first_advert_dates(conn)
multibyte_keys = self._multibyte_advert_keys(conn)
if backfill is None:
backfill = not self.has_history(conn)
planned_backfill = self.plan_backfill(conn) if backfill else []
computed_days = [
self.compute_day(
conn,
date_str,
sources=sources,
today=today,
first_seen=first_seen,
multibyte_keys=multibyte_keys,
)
for date_str in self._recompute_dates(today)
]
oldest_recomputed = min(day["values"]["date"] for day in computed_days)
# --- write phase 1: rollups and the packet dimension backfill --------
conn.execute("BEGIN IMMEDIATE")
try:
if planned_backfill:
self.write_backfill(conn, planned_backfill)
for day in computed_days:
self.upsert_day(conn, day, is_final=day["values"]["date"] < today)
# A day that has left the recompute window will never change again.
conn.execute(
"UPDATE daily_rollup SET is_final = 1 WHERE date < ? AND is_final = 0",
(oldest_recomputed,),
)
self.prune_history(conn)
conn.commit()
except Exception:
conn.rollback()
raise
# --- read phase 2: the snapshot, which reads what we just wrote ------
# Series and deltas come from daily_rollup and the packet tiles from the
# freshly dimensioned rows, so this has to follow the write, not precede
# it — otherwise every snapshot describes the previous tick.
payload = self.build_snapshot(conn)
generated_at = time.time()
duration_ms = int((time.perf_counter() - started) * 1000)
# --- write phase 2: the snapshot row ---------------------------------
conn.execute("BEGIN IMMEDIATE")
try:
conn.execute(
"""
INSERT INTO dashboard_snapshot (id, generated_at, duration_ms, schema_rev, payload)
VALUES (1, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
generated_at = excluded.generated_at,
duration_ms = excluded.duration_ms,
schema_rev = excluded.schema_rev,
payload = excluded.payload
""",
(generated_at, duration_ms, SCHEMA_REV, json.dumps(payload)),
)
conn.commit()
except Exception:
conn.rollback()
raise
return {
"generated_at": generated_at,
"duration_ms": duration_ms,
"days": len(computed_days),
"backfilled_days": len(planned_backfill),
"backfilled_packet_rows": backfilled_packets,
}
# -- lease ---------------------------------------------------------------
def try_claim_lease(self, conn: sqlite3.Connection) -> bool:
"""Best-effort exclusion between viewers sharing one database.
Refreshes are idempotent full recomputations, so a double refresh wastes
CPU and corrupts nothing. This exists to skip that waste, not to
guarantee correctness — treat a lost race as "someone else has it".
"""
if not _table_exists(conn, "bot_metadata"):
return True
now = time.time()
owner = f"{os.getpid()}:{socket.gethostname()}:{now + self.interval_seconds * 2:.0f}"
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT value FROM bot_metadata WHERE key = ?", (LEASE_KEY,)
).fetchone()
if row and row[0]:
parts = str(row[0]).rsplit(":", 1)
try:
expires = float(parts[-1])
except (TypeError, ValueError):
expires = 0.0
held_by_other = parts[0] != f"{os.getpid()}:{socket.gethostname()}"
if expires > now and held_by_other:
conn.rollback()
return False
conn.execute(
"""
INSERT INTO bot_metadata (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
""",
(LEASE_KEY, owner),
)
conn.commit()
return True
except sqlite3.Error as exc:
# Losing the race to another viewer surfaces here as a lock error;
# treat any failure as "someone else has it" and skip the tick.
with suppress(sqlite3.Error):
conn.rollback()
self.logger.debug(f"Snapshot lease not claimed: {exc}")
return False
# -- readers -------------------------------------------------------------
def read_summary(self, conn: sqlite3.Connection) -> dict[str, Any] | None:
"""Return the stored snapshot with freshness metadata, or None if unset."""
if not _table_exists(conn, "dashboard_snapshot"):
return None
row = conn.execute(
"SELECT generated_at, duration_ms, schema_rev, payload FROM dashboard_snapshot WHERE id = 1"
).fetchone()
if not row:
return None
try:
payload = json.loads(row["payload"])
except (TypeError, ValueError) as exc:
self.logger.warning(f"Discarding unreadable dashboard snapshot: {exc}")
return None
age = max(0.0, time.time() - row["generated_at"])
payload.update(
{
"generated_at": row["generated_at"],
"duration_ms": row["duration_ms"],
"schema_rev": row["schema_rev"],
"snapshot_age_seconds": round(age, 1),
"stale": age > self.interval_seconds * 3,
}
)
return payload
def read_series(self, conn: sqlite3.Connection, metric: str, days: int) -> dict[str, Any]:
"""Points for one metric plus period-over-period totals."""
if metric not in SERIES_METRICS:
raise ValueError(f"Unknown metric: {metric}")
days = max(1, min(int(days), self.history_days))
expression = SERIES_METRICS[metric]
start = (datetime.now() - timedelta(days=days - 1)).strftime("%Y-%m-%d")
previous_start = (datetime.now() - timedelta(days=days * 2 - 1)).strftime("%Y-%m-%d")
rows = conn.execute(
f"SELECT date, is_final, {expression} AS value, computed_at "
"FROM daily_rollup WHERE date >= ? ORDER BY date",
(start,),
).fetchall()
points = [
{"date": row["date"], "value": row["value"], "complete": bool(row["is_final"])}
for row in rows
]
previous_rows = conn.execute(
f"SELECT {expression} AS value FROM daily_rollup WHERE date >= ? AND date < ?",
(previous_start, start),
).fetchall()
def aggregate(source) -> float | None:
values = [row["value"] for row in source if row["value"] is not None]
if not values:
return None
if metric in RATIO_METRICS:
return round(sum(values) / len(values), 1)
return sum(values)
current_total = aggregate(rows)
previous_total = aggregate(previous_rows)
etag_row = conn.execute(
"SELECT MAX(computed_at) FROM daily_rollup WHERE date >= ?", (previous_start,)
).fetchone()
return {
"metric": metric,
"days": days,
"is_ratio": metric in RATIO_METRICS,
"points": points,
"current_period_total": current_total,
"previous_period_total": previous_total,
"change_pct": _change_pct(current_total, previous_total),
"etag_source": etag_row[0] if etag_row else None,
}
def _window_cutoff(self, window: str) -> float | None:
"""Epoch lower bound for a window token, or None for 'all'."""
return {
"24h": time.time() - 86400,
"7d": time.time() - 7 * 86400,
"30d": time.time() - 30 * 86400,
"90d": time.time() - 90 * 86400,
}.get(window)
def read_top(
self, conn: sqlite3.Connection, kind: str, window: str, limit: int
) -> dict[str, Any]:
"""One narrow leaderboard query, instead of the whole stats payload."""
if kind not in TOP_KINDS:
raise ValueError(f"Unknown top kind: {kind}")
limit = max(1, min(int(limit), 50))
cutoff = self._window_cutoff(window)
now = time.time()
retention = self.stats_retention_days
items: list[dict[str, Any]] = []
total: int | None = None
if kind == "neighbors":
if window not in NEIGHBOR_WINDOWS:
window = NEIGHBOR_WINDOWS[0]
retention = self.adverts_retention_days
if _table_exists(conn, "observed_paths"):
items = self._one_hop_rows(conn, window, limit)
total = self._count_one_hop_nodes(conn, window)
elif kind == "repeaters":
retention = self.adverts_retention_days
days = {"24h": 1, "7d": 7, "30d": 30, "90d": 90}.get(window, retention)
since = (datetime.now() - timedelta(days=days - 1)).strftime("%Y-%m-%d")
if _table_exists(conn, "daily_stats") and _table_exists(conn, "complete_contact_tracking"):
items = [
{
"name": row["name"] or row["public_key"][:12],
"public_key": row["public_key"],
"role": normalize_role(row["role"]),
"count": row["adverts"] or 0,
}
for row in conn.execute(
"""
SELECT ds.public_key, c.name, c.role, SUM(ds.advert_count) AS adverts
FROM daily_stats ds
JOIN complete_contact_tracking c ON c.public_key = ds.public_key
WHERE ds.date >= ? AND c.role IN ('repeater', 'roomserver')
GROUP BY ds.public_key ORDER BY adverts DESC LIMIT ?
""",
(since, limit),
)
]
elif kind == "users" and _table_exists(conn, "message_stats"):
items = [
{"name": row[0], "count": row[1]}
for row in conn.execute(
"""
SELECT sender_id, COUNT(*) AS n FROM message_stats
WHERE timestamp <= ? AND timestamp >= ?
GROUP BY sender_id ORDER BY n DESC LIMIT ?
""",
(now, cutoff if cutoff is not None else 0, limit),
)
]
elif kind == "commands" and _table_exists(conn, "command_stats"):
items = [
{"name": row[0], "count": row[1]}
for row in conn.execute(
"""
SELECT command_name, COUNT(*) AS n FROM command_stats
WHERE timestamp <= ? AND timestamp >= ?
GROUP BY command_name ORDER BY n DESC LIMIT ?
""",
(now, cutoff if cutoff is not None else 0, limit),
)
]
elif kind == "channels" and _table_exists(conn, "message_stats"):
items = [
{"name": row[0], "count": row[1], "users": row[2]}
for row in conn.execute(
"""
SELECT channel, COUNT(*) AS n, COUNT(DISTINCT sender_id) AS users
FROM message_stats
WHERE channel IS NOT NULL AND channel != ''
AND timestamp <= ? AND timestamp >= ?
GROUP BY channel ORDER BY n DESC LIMIT ?
""",
(now, cutoff if cutoff is not None else 0, limit),
)
]
elif kind == "paths" and _table_exists(conn, "path_stats"):
items = [
{
"name": row[0],
"path_length": row[1],
"path_string": row[2],
"timestamp": row[3],
}
for row in conn.execute(
"""
SELECT sender_id, path_length, path_string, timestamp FROM path_stats
WHERE timestamp <= ? AND timestamp >= ?
ORDER BY path_length DESC, timestamp DESC LIMIT ?
""",
(now, cutoff if cutoff is not None else 0, limit),
)
]
requested_days = {"24h": 1, "7d": 7, "30d": 30, "90d": 90}.get(window)
return {
"kind": kind,
"window": window,
"window_label": self._window_label(window, retention),
"retention_days": retention,
"truncated_by_retention": bool(
requested_days is None or requested_days > retention
),
"total": total if total is not None else len(items),
"items": items,
}
@staticmethod
def _window_label(window: str, retention_days: int) -> str:
return {
"24h": "Last 24 hours",
"7d": "Last 7 days",
"30d": "Last 30 days",
"90d": "Last 90 days",
}.get(window, f"All retained ({retention_days}d)")
def derive_windows(self, conn: sqlite3.Connection | None = None) -> dict[str, Any]:
"""Build the selector options from retention, so labels cannot overclaim.
The old UI offered "30d" and "All" against tables pruned at 7 days: every
one of those options returned the same 7-day number under a wrong label.
"""
retention = {
"stats_days": self.stats_retention_days,
"packet_stream_days": self.packet_retention_days,
"daily_stats_days": self.adverts_retention_days,
"observed_paths_days": self.adverts_retention_days,
}
def options(retention_days: int) -> list[dict[str, str]]:
candidates = [("24h", 1), ("7d", 7), ("30d", 30), ("90d", 90)]
out = [
{"value": value, "label": self._window_label(value, retention_days)}
for value, days in candidates
if days <= retention_days or value == "24h"
]
out.append({"value": "all", "label": f"All retained ({retention_days}d)"})
return out
stats_options = options(self.stats_retention_days)
return {
"retention": retention,
"windows": {
"users": stats_options,
"commands": stats_options,
"channels": stats_options,
"paths": stats_options,
"repeaters": options(self.adverts_retention_days),
# Deliberately not retention-derived: observed_paths keeps 90
# days, but a link last used a month ago tells you nothing about
# whether it works now.
"neighbors": [
{"value": value, "label": self._window_label(value, self.adverts_retention_days)}
for value in NEIGHBOR_WINDOWS
],
},
}