Two rounds of interactive-polish fixes across the dashboard, Time Machine, chat, and the menu popups. Dashboard - Feed name lane names what a row actually is: a channel message's parsed sender, or a relayed packet's payload class (channel text / trace / …), instead of a bare "?" or a redundant "ch N". The class is persisted so history-seeded rows carry it too (observations.payload_typename, schema v10). - PgUp/PgDn walk the feed selection so the highlight travels with the view. Packet viewer - Takes a live source and re-locates the viewed packet by identity, so packets arriving after it opens become reachable; the n/total title stays current. - Decrypts overheard channel text straight from stored history (crypto fields persisted: chan_hash/cipher_mac/crypted, schema v9). Time Machine - Day charts label their bars with column ticks: each date sits under its own bar with a ┬ notch, shortens (month only on the first tick and on rollover), and thins to fit. axis_chart grows an optional ticks path for columnar charts. - Node history charts render through the shared axis_chart widget; the mesh Rhythm is 96 fifteen-minute slices at 4x width, "now/newest" hints dropped. Chat - The byte counter pins to the right edge of the input's last line, so it stays bottom-right when the compose wraps instead of trailing the cursor. Menu popups - The per-node Rules popover gets an explicit Close row. - The Send advert popup drops the emoji from its floating title (a terminal that paints it narrow left the content-sized dialog's border short, bleeding the backdrop through); the menu row keeps its icon. 625 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
213 lines
9.4 KiB
Python
213 lines
9.4 KiB
Python
"""SQLite database bootstrap and schema.
|
|
|
|
The schema is intentionally generic: a central ``runs`` table records every tool
|
|
execution (with arguments and a JSON summary), and measurement tables reference it so
|
|
new tools can persist data without schema churn.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
SCHEMA_VERSION = 10
|
|
|
|
_SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
-- Small key/value store for UI state that should survive across sessions (e.g. the last
|
|
-- map viewport), independent of any tool run. New keys need no schema change.
|
|
CREATE TABLE IF NOT EXISTS app_state (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL
|
|
);
|
|
|
|
-- One row per tool execution; the spine that measurements hang off of.
|
|
CREATE TABLE IF NOT EXISTS runs (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
tool TEXT NOT NULL,
|
|
profile TEXT,
|
|
args_json TEXT NOT NULL DEFAULT '{}',
|
|
status TEXT NOT NULL DEFAULT 'running', -- running | ok | error
|
|
summary_json TEXT,
|
|
started_at TEXT NOT NULL,
|
|
finished_at TEXT
|
|
);
|
|
|
|
-- Aggregated outcome of a single path trace, linked to its run.
|
|
CREATE TABLE IF NOT EXISTS traces (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
target TEXT NOT NULL,
|
|
success INTEGER NOT NULL,
|
|
hop_count INTEGER NOT NULL DEFAULT 0,
|
|
min_snr REAL,
|
|
round_trip_ms REAL,
|
|
tx_power INTEGER,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
-- Per-hop SNR for a trace.
|
|
CREATE TABLE IF NOT EXISTS trace_hops (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
trace_id INTEGER NOT NULL REFERENCES traces(id) ON DELETE CASCADE,
|
|
hop_index INTEGER NOT NULL,
|
|
node TEXT,
|
|
snr REAL NOT NULL
|
|
);
|
|
|
|
-- One robust sample per (tx_power) tried during TX-power optimization.
|
|
CREATE TABLE IF NOT EXISTS tx_samples (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
target TEXT NOT NULL,
|
|
tx_power INTEGER NOT NULL,
|
|
median_min_snr REAL,
|
|
success_rate REAL,
|
|
samples INTEGER NOT NULL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
-- A candidate path evaluated during path optimization.
|
|
CREATE TABLE IF NOT EXISTS path_candidates (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
target TEXT NOT NULL,
|
|
path_json TEXT NOT NULL,
|
|
bottleneck_snr REAL,
|
|
success_rate REAL,
|
|
median_rtt_ms REAL,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
-- One entry of a remote repeater's neighbour table, fetched over the mesh (v8). Each row
|
|
-- is a link the repeater reported hearing directly, with SNR measured at the repeater;
|
|
-- refetching appends a new snapshot and readers take the latest row per pair.
|
|
CREATE TABLE IF NOT EXISTS neighbour_reports (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
repeater TEXT NOT NULL, -- canonical id of the repeater that was asked
|
|
neighbour TEXT NOT NULL, -- reported neighbour's key-prefix hex, as replied
|
|
snr REAL, -- dB, measured at the repeater
|
|
heard_at TEXT, -- when the repeater last heard the neighbour
|
|
fetched_at TEXT NOT NULL
|
|
);
|
|
|
|
-- One packet overheard while passively monitoring the mesh (advert/telemetry/...).
|
|
CREATE TABLE IF NOT EXISTS observations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
|
|
node TEXT,
|
|
name TEXT,
|
|
kind TEXT NOT NULL DEFAULT 'advert',
|
|
node_type INTEGER, -- advert type (repeater/chat/room/...), when carried
|
|
snr REAL,
|
|
rssi REAL,
|
|
lat REAL,
|
|
lon REAL,
|
|
path TEXT, -- 'packet' rows: comma-separated relay-hop hex hashes
|
|
observed_at TEXT NOT NULL,
|
|
chan_hash TEXT, -- overheard GRP_TXT frames: channel-hash fingerprint (hex)
|
|
cipher_mac TEXT, -- …its 2-byte MAC (hex)
|
|
crypted TEXT, -- …its ciphertext (hex), so a stored channel text still decrypts
|
|
payload_typename TEXT -- 'packet' rows: the frame's payload class (GRP_TXT/TRACE/…)
|
|
);
|
|
|
|
-- One chat message, sent or received, on a channel or with a contact. Unlike the other
|
|
-- measurement tables these are not tied to a single tool run (they arrive unsolicited via
|
|
-- the event hub), so run_id is nullable and detaches rather than cascades.
|
|
CREATE TABLE IF NOT EXISTS messages (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
run_id INTEGER REFERENCES runs(id) ON DELETE SET NULL,
|
|
outbound INTEGER NOT NULL DEFAULT 0,
|
|
is_channel INTEGER NOT NULL DEFAULT 0,
|
|
channel_id TEXT, -- a channel's slot-independent identity (its history key)
|
|
channel_idx INTEGER, -- the slot it went out on / arrived on (reference only)
|
|
peer TEXT,
|
|
peer_name TEXT,
|
|
text TEXT NOT NULL,
|
|
snr REAL,
|
|
acked INTEGER,
|
|
created_at TEXT NOT NULL
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_traces_run ON traces(run_id);
|
|
CREATE INDEX IF NOT EXISTS idx_trace_hops_trace ON trace_hops(trace_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tx_samples_run ON tx_samples(run_id);
|
|
CREATE INDEX IF NOT EXISTS idx_neighbour_reports_pair ON neighbour_reports(repeater, neighbour);
|
|
CREATE INDEX IF NOT EXISTS idx_observations_run ON observations(run_id);
|
|
CREATE INDEX IF NOT EXISTS idx_observations_node ON observations(node);
|
|
CREATE INDEX IF NOT EXISTS idx_messages_peer ON messages(peer);
|
|
"""
|
|
|
|
|
|
def connect(db_path: Path) -> sqlite3.Connection:
|
|
"""Open (creating if needed) the SQLite database and ensure the schema exists.
|
|
|
|
Args:
|
|
db_path: Filesystem location of the database. Parent directories are created.
|
|
|
|
Returns:
|
|
An open connection with ``Row`` factory and foreign keys enabled.
|
|
"""
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA foreign_keys = ON;")
|
|
conn.executescript(_SCHEMA)
|
|
_migrate(conn)
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('version', ?)",
|
|
(str(SCHEMA_VERSION),),
|
|
)
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _migrate(conn: sqlite3.Connection) -> None:
|
|
"""Bring an existing database's tables up to the current schema.
|
|
|
|
``executescript(_SCHEMA)`` only *creates* missing tables (``IF NOT EXISTS``); it cannot
|
|
add a column to a table an older version already created. Column additions are applied
|
|
here, guarded so the migration is idempotent and safe to run on every open. Indexes on
|
|
added columns are created here too (not in ``_SCHEMA``), so ``executescript`` never
|
|
references a column an older database hasn't grown yet.
|
|
"""
|
|
message_cols = {row["name"] for row in conn.execute("PRAGMA table_info(messages)")}
|
|
if "channel_id" not in message_cols:
|
|
# v3 -> v4: channel history moved from being keyed by slot index to a
|
|
# slot-independent channel identity. Existing rows are backfilled lazily at runtime
|
|
# (see Repository.backfill_channel_ids) once the device's channels can be read.
|
|
conn.execute("ALTER TABLE messages ADD COLUMN channel_id TEXT")
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_messages_channel_id ON messages(channel_id)"
|
|
)
|
|
|
|
observation_cols = {row["name"] for row in conn.execute("PRAGMA table_info(observations)")}
|
|
if "node_type" not in observation_cols:
|
|
# v4 -> v5: observations gained the transmitting node's advert type, so the map can
|
|
# tell repeaters from leaf nodes. Older rows simply carry NULL (type unknown).
|
|
conn.execute("ALTER TABLE observations ADD COLUMN node_type INTEGER")
|
|
if "path" not in observation_cols:
|
|
# v6 -> v7: observations gained the relay path of RX-logged packets, the passive
|
|
# evidence the mesh topology graph is built from. Older rows carry NULL (no path).
|
|
conn.execute("ALTER TABLE observations ADD COLUMN path TEXT")
|
|
if "chan_hash" not in observation_cols:
|
|
# v8 -> v9: overheard channel-text (GRP_TXT) frames keep the fields the packet
|
|
# viewer needs to decrypt them — the channel-hash fingerprint, the 2-byte MAC, and
|
|
# the ciphertext — so a channel we hold the key for stays readable even when the feed
|
|
# is seeded from stored history (a live frame carries them in its raw payload; a
|
|
# replayed one had nowhere to keep them). Older rows carry NULL (never decryptable).
|
|
conn.execute("ALTER TABLE observations ADD COLUMN chan_hash TEXT")
|
|
conn.execute("ALTER TABLE observations ADD COLUMN cipher_mac TEXT")
|
|
conn.execute("ALTER TABLE observations ADD COLUMN crypted TEXT")
|
|
if "payload_typename" not in observation_cols:
|
|
# v9 -> v10: 'packet' RX-log rows keep their payload class (GRP_TXT, TRACE, PATH,
|
|
# ACK, …), the one identifying thing a relayed flood carries when it names no
|
|
# origin node — so the dashboard feed can read "channel text" / "trace" instead of
|
|
# a bare "?" even when seeded from history. Older rows carry NULL (class unknown).
|
|
conn.execute("ALTER TABLE observations ADD COLUMN payload_typename TEXT")
|