diff --git a/backend/routes/analytics.py b/backend/routes/analytics.py index 4591b483..0119816b 100644 --- a/backend/routes/analytics.py +++ b/backend/routes/analytics.py @@ -13,6 +13,7 @@ from core.auth import get_user_nodes from core.users import ANONYMOUS_USER, AUTH_BYPASS, read_user_from_token from services import node_bias +from services.node_ref import public_node_ref from services.public_location import public_node_summary from services.publication import is_private, private_node_ids @@ -82,7 +83,10 @@ async def radar_analytics(request: Request, real_only: bool = False): # rather than tell the owner anything. if summary.keys() == {"node_id"}: continue - nodes[nid] = public_node_summary(nid, summary) + # Built fresh like the per-node route below, so it carries the same + # public handle the cached listing does — or the owner's own node is + # the one node on their map without a name. + nodes[nid] = {**public_node_summary(nid, summary), "node_ref": public_node_ref(nid)} return Response( content=orjson.dumps(payload, option=orjson.OPT_SERIALIZE_NUMPY), media_type="application/json", @@ -109,6 +113,10 @@ async def radar_node_analytics(node_id: str, request: Request): # the same receiver-geometry rewrite has to happen here too, or this route # is the hole the cached one closed. See services/public_location.py. summary = public_node_summary(node_id, summary) + # The same public handle the cached listing carries, for the same reason: + # this route is built fresh, so anything the listing adds has to be added + # here too or the two surfaces disagree. See services/node_ref.py. + summary = {**summary, "node_ref": public_node_ref(node_id)} # Backend-computed bias estimate from claim residuals — same conditional # shape as the manager's own blocks: present only once the node has # residual history. The trust block above already blends backend-fed diff --git a/backend/services/node_ref.py b/backend/services/node_ref.py new file mode 100644 index 00000000..d313da4b --- /dev/null +++ b/backend/services/node_ref.py @@ -0,0 +1,182 @@ +"""The public handle for a node: `node_ref`, never the raw `node_id`. + +A `node_id` comes off the board and is what Mender, the TCP handler and every +config file know the node as. It is also, on this deployment, a name its owner +chose — a name like `-` names a machine, and a run of them names a fleet's +naming convention — so printing it on a public map hands a stranger a +correlation key the node's owner never agreed to publish. `core.nodes.Node` +already carries the answer: `node_ref`, minted at registration +(`services.node_auth.mint_node_ref`) as `"nde" + 12` base36 characters, +deliberately rotatable without reflashing the board. + +The gap this module closes is that most live nodes have no row. The `nodes` +table only holds nodes that registered through `/v1/nodes`; the blah2 bridge's +receivers and anything speaking the plain TCP protocol never do, and on the +test deployment (2026-09-06) that is all seven of them. So a ref has to exist +for an unregistered node too, and it has to be indistinguishable from a minted +one — a map where some nodes show `ndeXXXXXXXXXXXX` and the rest show their +operator-chosen names publishes exactly the ids it was trying not to. + +For those, the ref is derived: `HMAC-SHA256(fuzz salt, "node_ref|" + node_id)` +rendered in the same base36 alphabet, truncated to the same 12 characters. + +- The salt is `services.public_location._salt()` — the configured + `NODE_FUZZ_SALT`, else the persisted runtime salt. It is already this + deployment's anonymity key, it is secret, and it survives restarts, so a + derived ref is stable for the life of the deployment without a table to + store it in. A deployment that rotates the salt re-anonymises its nodes, + which is the same thing rotating it already does to their positions. +- The `node_ref|` domain prefix separates this HMAC frame from the location + one (`public_location._frame_message`, which hashes a bare identity or an + identity plus a bounds label). Two frames sharing a key must not be able to + spell each other's messages, or a published ref would be a published sample + of the offset key. +- There is no fallback to the raw id. A node whose ref cannot be looked up is + derived; derivation needs only the salt, and the salt always resolves (see + `_persisted_salt`). "Publish the id when something goes wrong" would make + the leak conditional on a database being down, which is precisely when + nobody is watching. + +Registered nodes still win: their stored `node_ref` is the handle the rest of +the node API and the dashboard already use, and deriving a second one for them +would publish two names for one node. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +import threading +import time + +logger = logging.getLogger(__name__) + +# How long a snapshot of the nodes table is reused. Refs change only when a +# node registers or is rotated, both human-speed events, and the alternative is +# a database round trip per published node per refresh cycle. Mirrors +# services/node_sites.py, which caches the same kind of thing for the same +# reason. +_TTL_S = 30.0 + +# After a failed refresh, retry sooner than the full TTL — the snapshot is +# older than it should be, not wrong. +_ERROR_RETRY_S = 5.0 + +_PREFIX = "nde" +_REF_CHARS = 12 # same shape as mint_node_ref, so the two are not tellable apart + +# Domain separator for the derivation HMAC. "|" cannot start a node id, and +# the location frame's messages never begin with this literal, so no node id +# can spell a message in the other frame. +_DOMAIN = "node_ref|" + +_lock = threading.Lock() +# node_id -> stored node_ref, from the last successful read of the nodes table. +_db_refs: dict[str, str] = {} +_expires_at: float = 0.0 +# (salt, node_id) -> derived ref. Keyed on the salt so a re-salted deployment +# (or a test that monkeypatches it) cannot read a stale ref back out — the same +# reason public_location keys its offset cache on the salt. Bounded by the +# number of nodes this process has ever published. +_derived: dict[tuple[str, str], str] = {} +# Whether the "no node table" case has been logged. It is the normal state of +# a deployment whose nodes all predate /v1/nodes, so it is worth saying once +# and worth never saying again. +_db_unavailable_logged = False + + +def _reset_for_tests() -> None: + """Drop the snapshot and the derived refs. Tests only.""" + global _expires_at, _db_unavailable_logged + with _lock: + _db_refs.clear() + _derived.clear() + _expires_at = 0.0 + _db_unavailable_logged = False + + +def _refs_from_db() -> dict[str, str]: + """{node_id: node_ref} for every registered node. + + Imported inside the function and driven by the synchronous-engine pattern + services/publication.py documents, for the reasons services/node_sites.py + gives: the callers are executor threads and route handlers, neither of + which can await, and this module must import cleanly in a process with no + database at all. + """ + from sqlalchemy import select + + from core.nodes import Node + from services.publication import _sync_engine + + with _sync_engine().connect() as conn: + rows = conn.execute(select(Node.node_id, Node.node_ref)) + return {node_id: ref for node_id, ref in rows if node_id and ref} + + +def _snapshot() -> dict[str, str]: + global _expires_at, _db_unavailable_logged + now = time.monotonic() + if now < _expires_at: + return _db_refs + with _lock: + if time.monotonic() < _expires_at: + return _db_refs + try: + # Fetch first, swap second: a failed read leaves the previous + # snapshot in place rather than an empty one, so a transient + # database blip cannot flip a registered node to a derived name + # and back (services/node_sites.py keeps its snapshot the same + # way). The stale answer is the last good answer, not a wrong one. + fresh = _refs_from_db() + _db_refs.clear() + _db_refs.update(fresh) + _expires_at = time.monotonic() + _TTL_S + except Exception: + # No database, no table, or a transient failure. With no snapshot + # yet, every node derives its ref, which is the answer for an + # unregistered node anyway — so this degrades to "nothing is + # registered", never to publishing an id. + if not _db_unavailable_logged: + _db_unavailable_logged = True + logger.exception("node_ref: no node registry available, deriving every public ref") + _expires_at = time.monotonic() + _ERROR_RETRY_S + return _db_refs + + +def _derived_ref(node_id: str) -> str: + """The HMAC-derived ref for a node with no registry row.""" + from services.node_auth import _ALPHABET + from services.public_location import _salt + + salt = _salt() + key = (salt, node_id) + cached = _derived.get(key) + if cached is not None: + return cached + + digest = hmac.new(salt.encode("utf-8"), (_DOMAIN + node_id).encode("utf-8"), hashlib.sha256).digest() + # Base36 over the digest as one big integer, least-significant digit first. + # Twelve characters is ~62 bits of the 256 available, the same width — and + # the same alphabet — mint_node_ref draws at random. + value = int.from_bytes(digest, "big") + base = len(_ALPHABET) + chars = [] + for _ in range(_REF_CHARS): + value, remainder = divmod(value, base) + chars.append(_ALPHABET[remainder]) + ref = _PREFIX + "".join(chars) + _derived[key] = ref + return ref + + +def public_node_ref(node_id: str) -> str: + """The public handle for a node: its registered ref, else a derived one. + + Never the node id. A missing id derives from the empty string rather than + passing through, for the reason public_offset_km hashes it: a missing id is + a config fault, and the safe reading of a config fault is not to publish + whatever was there. + """ + return _snapshot().get(node_id) or _derived_ref(node_id or "") diff --git a/backend/services/tasks/analytics_refresh.py b/backend/services/tasks/analytics_refresh.py index c4d73948..a504c713 100644 --- a/backend/services/tasks/analytics_refresh.py +++ b/backend/services/tasks/analytics_refresh.py @@ -25,6 +25,7 @@ from services.geo import valid_latlon as _valid_latlon from services.id_utils import multinode_hex_from_key from services.node_config import position_status +from services.node_ref import public_node_ref from services.node_sites import log_colocation_audit from services.public_geometry import without_receiver_geometry from services.public_location import ( @@ -333,8 +334,18 @@ def _refresh_analytics_and_nodes(): # public_summaries drops it before public_node_summaries rewrites what is # left. Two separate promises, applied in the order they compose — there # is nothing to translate for a node that is not being published. + # The handle the map is allowed to print. Added once, before the real-only + # split, so both variants carry it: a node id names a machine its owner + # chose the name of, and every surface that shows a node has to have + # something else to show. See services/node_ref.py. New dicts, not an + # in-place key: with the fuzz off, public_node_summaries hands back the + # manager's own cached summaries, and those are not ours to grow. + public_nodes = { + nid: {**summary, "node_ref": public_node_ref(nid)} + for nid, summary in public_node_summaries(public_summaries(state.node_analytics.get_all_summaries())).items() + } analytics_data = { - "nodes": public_node_summaries(public_summaries(state.node_analytics.get_all_summaries())), + "nodes": public_nodes, "cross_node": public_cross_node(state.node_analytics.get_cross_node_analysis()), } state.latest_analytics_bytes = orjson.dumps(analytics_data, option=orjson.OPT_SERIALIZE_NUMPY) @@ -371,6 +382,7 @@ def _refresh_analytics_and_nodes(): "nodes": { nid: { "status": info.get("status"), + "node_ref": public_node_ref(nid), "name": info.get("config", {}).get("name", nid), "config_hash": info.get("config_hash"), "last_heartbeat": info.get("last_heartbeat"), diff --git a/backend/tests/test_analytics_refresh.py b/backend/tests/test_analytics_refresh.py index ac168e1e..8c84a56e 100644 --- a/backend/tests/test_analytics_refresh.py +++ b/backend/tests/test_analytics_refresh.py @@ -413,3 +413,55 @@ def test_a_stale_claim_still_reads_as_missed(self): assert entry["detected"] == 0 assert entry["missed"] == 1 assert entry["miss_rate"] == 1.0 + + +# ── Public node identifiers ────────────────────────────────────────────────── + + +class TestNodeRefInPublicPayloads: + """Every payload that names a node carries its public handle. + + The map has to print something for a node, and the node id is the name its + owner gave the machine — see services/node_ref.py. Added before the + real-only split, so both analytics variants and /api/radar/nodes agree. + """ + + NODE = "test-noderef-1" + + @pytest.fixture(autouse=True) + def _a_connected_node(self): + from core import state + + state.node_analytics.register_node(self.NODE, {"rx_lat": 33.45, "rx_lon": -112.07, "max_range_km": 50}) + state.connected_nodes[self.NODE] = { + "status": "connected", + "config": {"name": "noderef-test", "rx_lat": 33.45, "rx_lon": -112.07}, + "is_synthetic": False, + } + yield + state.connected_nodes.pop(self.NODE, None) + state.node_analytics.retire_node(self.NODE) + + def _refresh(self): + from services.tasks.analytics_refresh import _refresh_analytics_and_nodes + + _refresh_analytics_and_nodes() + + def test_both_analytics_variants_carry_it(self): + from core import state + from services.node_ref import public_node_ref + + self._refresh() + expected = public_node_ref(self.NODE) + for raw in (state.latest_analytics_bytes, state.latest_analytics_real_bytes): + node = orjson.loads(raw)["nodes"][self.NODE] + assert node["node_ref"] == expected + assert node["node_ref"] != self.NODE + + def test_the_nodes_payload_carries_it(self): + from core import state + from services.node_ref import public_node_ref + + self._refresh() + node = orjson.loads(state.latest_nodes_bytes)["nodes"][self.NODE] + assert node["node_ref"] == public_node_ref(self.NODE) diff --git a/backend/tests/test_node_ref.py b/backend/tests/test_node_ref.py new file mode 100644 index 00000000..3ec0dd97 --- /dev/null +++ b/backend/tests/test_node_ref.py @@ -0,0 +1,152 @@ +"""Tests for services/node_ref.py — the public handle for every node. + +The property under test is that a public payload never carries a node id, for +registered and unregistered nodes alike. A ref that is derived for one node +and looked up for another must be indistinguishable in form, or the shape of +the string says which nodes are on the registry. +""" + +import asyncio +import os +import re + +import pytest + +os.environ.setdefault("RETINA_ENV", "test") + +from core.nodes import Node # noqa: E402 +from core.users import async_session_maker # noqa: E402 +from services import node_ref, public_location # noqa: E402 +from services.node_ref import public_node_ref # noqa: E402 + +_SALT = "test-salt-for-node-ref" +_ID = "alpha-site-node" + +# What mint_node_ref produces: "nde" and twelve base36 characters. +_REF_RE = re.compile(r"^nde[0-9a-z]{12}$") + + +@pytest.fixture(autouse=True) +def _fixed_salt(monkeypatch): + """A salt fixed here, so a failure never depends on the runtime salt file.""" + monkeypatch.setenv("NODE_FUZZ_SALT", _SALT) + public_location._reset_for_tests() + node_ref._reset_for_tests() + yield + public_location._reset_for_tests() + node_ref._reset_for_tests() + + +@pytest.fixture() +def seed_node(): + """seed_node(node_id, node_ref) — write a registry row and drop the cache.""" + + def _seed(node_id: str, ref: str) -> None: + async def _go(): + async with async_session_maker() as session: + session.add(Node(node_id=node_id, node_ref=ref)) + await session.commit() + + asyncio.run(_go()) + # asyncio.run() clears the loop on exit (3.12); conftest's _clean_db + # restores one for the same reason. + asyncio.set_event_loop(asyncio.new_event_loop()) + node_ref._reset_for_tests() + + return _seed + + +class TestDerivedRef: + """The unregistered case, which on this deployment is every live node.""" + + def test_it_has_the_shape_of_a_minted_ref(self): + assert _REF_RE.match(public_node_ref(_ID)) + + def test_it_is_stable_across_calls(self): + first = public_node_ref(_ID) + node_ref._reset_for_tests() # not merely reading the memo back + assert public_node_ref(_ID) == first + + def test_different_nodes_get_different_refs(self): + assert public_node_ref(_ID) != public_node_ref("bravo-site-node") + + def test_the_salt_moves_every_ref(self, monkeypatch): + """Rotating the fuzz salt re-anonymises nodes, as it does positions.""" + before = public_node_ref(_ID) + monkeypatch.setattr(public_location, "_salt", lambda: "a-different-salt") + node_ref._reset_for_tests() + assert public_node_ref(_ID) != before + + def test_it_is_never_the_node_id(self): + for node_id in (_ID, "ret1a2b3c4d", "", "nde000000000000"): + assert public_node_ref(node_id) != node_id + + def test_a_missing_id_does_not_pass_through(self): + """A config fault must not publish whatever was in the field.""" + assert _REF_RE.match(public_node_ref("")) + + def test_the_domain_prefix_separates_it_from_the_location_hmac(self): + """Both frames hash a node id under the fuzz salt. + + Without the "node_ref|" domain the two messages would be the same + string, and a published ref would be a published sample of the digest + the location offset is drawn from. + """ + import hashlib + import hmac + + undomained = hmac.new(_SALT.encode(), _ID.encode(), hashlib.sha256).digest() + assert hmac.new(_SALT.encode(), f"node_ref|{_ID}".encode(), hashlib.sha256).digest() != undomained + # And no node id can spell another frame's message: the derivation is + # keyed on a prefix a node id cannot start with. + assert public_node_ref(_ID) != public_node_ref(f"node_ref|{_ID}") + + +class TestRegisteredRef: + def test_the_stored_ref_wins_over_derivation(self, seed_node): + derived = public_node_ref(_ID) + seed_node(_ID, "nde0123456789ab") + assert public_node_ref(_ID) == "nde0123456789ab" + assert public_node_ref(_ID) != derived + + def test_an_unregistered_node_still_derives(self, seed_node): + seed_node(_ID, "nde0123456789ab") + other = public_node_ref("ret1a2b3c4d") + assert _REF_RE.match(other) + assert other != "nde0123456789ab" + + def test_a_failed_refresh_keeps_the_last_snapshot(self, seed_node, monkeypatch): + """A database blip must not rename a registered node to a derived ref.""" + seed_node(_ID, "nde0123456789ab") + assert public_node_ref(_ID) == "nde0123456789ab" + + def _boom(): + raise RuntimeError("database unavailable") + + monkeypatch.setattr(node_ref, "_refs_from_db", _boom) + # Expire the snapshot so the next lookup has to refresh — and fail. + monkeypatch.setattr(node_ref, "_expires_at", 0.0) + assert public_node_ref(_ID) == "nde0123456789ab" + + +class TestPerNodeAnalyticsRoute: + """GET /api/radar/analytics/{node_id} is built fresh, not from the cache. + + The cached listing's coverage is in test_analytics_refresh.py; this is the + other surface, which has to be wired separately or the two disagree. + """ + + def test_the_route_carries_the_ref(self): + from fastapi.testclient import TestClient + + from core import state + from main import app + + state.node_analytics.register_node(_ID, {"rx_lat": 34.85, "rx_lon": -82.40, "max_range_km": 50}) + try: + with TestClient(app, raise_server_exceptions=False) as client: + body = client.get(f"/api/radar/analytics/{_ID}").json() + assert body["node_ref"] == public_node_ref(_ID) + assert body["node_ref"] != _ID + finally: + state.node_analytics.retire_node(_ID) diff --git a/backend/tests/test_public_location.py b/backend/tests/test_public_location.py index 0063d8a8..bccf40db 100644 --- a/backend/tests/test_public_location.py +++ b/backend/tests/test_public_location.py @@ -430,9 +430,20 @@ def registered_node(): state.node_analytics.register_node(_NODE_ID, dict(_NODE_CFG)) area = state.node_analytics.detection_areas[_NODE_ID] coverage = state.node_analytics.empirical_coverages[_NODE_ID] - # MIN_POINTS (20) calibration points before to_polygon() emits anything. - for i in range(30): - coverage.add_point(_TRUE_RX_LAT + 0.05 + i * 0.002, _TRUE_RX_LON + 0.06 + i * 0.002) + # MIN_POINTS (20) calibration points before to_polygon() emits anything, + # and the published polygon is evidence-only (FOV off), so a bin is drawn + # only once it holds FOV_OPEN_MIN_POINTS detections of its OWN. Thirty + # points spread over six adjacent bearings is a six-bin lobe with the rest + # of the compass closed — a polygon that has both a measured arc and the + # RX apex these assertions read. A single line of points, which this was, + # opens one bin and no longer forms an area at all. + for step in range(6): + rad = math.radians(30.0 + 5.0 * step) + for i in range(5): + range_km = 10.0 + i + lat = _TRUE_RX_LAT + range_km * math.cos(rad) / 111.19 + lon = _TRUE_RX_LON + range_km * math.sin(rad) / (111.19 * math.cos(math.radians(_TRUE_RX_LAT))) + coverage.add_point(lat, lon) for i in range(5): area.record_verified_detection(_TRUE_RX_LAT + 0.1 + i * 0.01, _TRUE_RX_LON + 0.1, f"abc{i:03d}") yield _NODE_ID diff --git a/backend/tests/test_publication.py b/backend/tests/test_publication.py index 3d5f025d..021ab71c 100644 --- a/backend/tests/test_publication.py +++ b/backend/tests/test_publication.py @@ -568,6 +568,22 @@ def test_an_owner_gets_their_private_nodes_summary_back(self, client, seed_nodes # The rest of the cached payload rides along untouched. assert body["nodes"]["other-node"] == {"node_id": "other-node"} + def test_the_owners_private_node_carries_a_node_ref_too(self, client, seed_nodes, analytics_node): + """Built fresh, like the per-node route — so it has to be given the + public handle the cached listing already carries, or the owner's own + node is the one node on their map without a name.""" + from core.users import ANONYMOUS_USER + from services.node_ref import public_node_ref + + seed_nodes(**{_PRIV: "private"}) + self._own(_PRIV, ANONYMOUS_USER["id"]) + try: + body = client.get("/api/radar/analytics").json() + finally: + self._own(_PRIV, None) + assert body["nodes"][_PRIV]["node_ref"] == public_node_ref(_PRIV) + assert body["nodes"][_PRIV]["node_ref"] != _PRIV + def test_the_owners_copy_is_the_same_fuzzed_frame_the_public_would_get(self, client, seed_nodes, analytics_node): """An owner is not an admin. They already know where their own receiver is, so serving the truth here buys them nothing and makes this route a @@ -587,7 +603,13 @@ def test_the_owners_copy_is_the_same_fuzzed_frame_the_public_would_get(self, cli option=orjson.OPT_SERIALIZE_NUMPY, ) ) - assert body["nodes"][_PRIV] == expected + # The public handle rides on top of the fuzzed frame; everything under + # it must be exactly what the public would get. uptime_s is the one + # field that legitimately differs between two summaries taken a few + # milliseconds apart (rounded to 0.1 s), so it is compared separately. + got = {k: v for k, v in body["nodes"][_PRIV].items() if k != "node_ref"} + assert abs(got["metrics"].pop("uptime_s") - expected["metrics"].pop("uptime_s")) < 5.0 + assert got == expected rx = body["nodes"][_PRIV]["detection_area"]["rx"] assert rx["lat"] != self.RX["rx_lat"] assert "location_uncertainty_km" in rx diff --git a/docs/arc-display.md b/docs/arc-display.md index 8afb87cd..fe31a544 100644 --- a/docs/arc-display.md +++ b/docs/arc-display.md @@ -95,5 +95,7 @@ pins their position to the arc midpoint, so a long glide walks the reference off the measured locus. Selecting an arc track (from the list panel or by clicking the arc) highlights -its arcs in amber, draws the detecting node's beam wedge, and centers the map -on the arc midpoint. +its arcs in amber, draws the detecting node's measured coverage polygon (the +empirical detection area — nothing at all for a node that has none yet; the +theoretical beam wedge is never drawn), and centers the map on the arc +midpoint. diff --git a/docs/architecture.md b/docs/architecture.md index 56e0dc0b..4a97d6a3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -64,6 +64,11 @@ are in [`arc-display.md`](arc-display.md). geometry (`beam_azimuth_deg`, `beam_width_deg`, `max_range_km`, `max_bistatic_range_km`) flows from node registration into the per-node pipelines, the arc builder, and inter-node association — one contract. +- **`services/node_ref.py`** — the public handle for a node. Every payload a + stranger can fetch names a node by `node_ref`, never by `node_id`: the + registry's ref when the node registered through `/v1/nodes`, an + HMAC-derived ref of the same shape (fuzz salt, `node_ref|` domain) when it + did not. See [`pipeline.md`](pipeline.md) §7. - **`services/tasks/`** — background async tasks: `aircraft_flush` (broadcast), `feed_gc` (stale-store GC on its own 5 s timer, deliberately not tied to the feed build), `solver` workers, `analytics_refresh`, archive lifecycle, diff --git a/docs/pipeline.md b/docs/pipeline.md index 20334405..21b05b55 100644 --- a/docs/pipeline.md +++ b/docs/pipeline.md @@ -290,7 +290,33 @@ evidence independent of both, and only from *detections*: active FOV gate it once formed a ghost → positive → wider-gate feedback loop. -`CALIBRATION_SCHEMA` (currently 5) versions what a stored positive *means*; +**What is published as the detection area is evidence only.** Under +`FOV_MODE=off` — the default, and what production and test run — +`empirical_coverage.polygon` in `/api/radar/analytics` is built by +`EmpiricalCoverageState.to_polygon(evidence_only=True)`: a bin is drawn only +when its own count reaches `FOV_OPEN_MIN_POINTS`, at its own clamped P85; +holes of at most `EVIDENCE_GAP_MAX_BINS` (2 bins, 10°) inside a lobe are +bridged; every other unobserved bearing collapses to the receiver apex. There +is no theoretical clip. It used to be clipped to the node's declared +`beam_azimuth_deg`/`beam_width_deg`, which are configuration — most nodes' +aim was never surveyed — so measured bins outside the declared wedge were +zeroed (radar3, 2026-09-06: evidence in all 72 bins reaching 17–65 km, +published as a 120° pie slice). The theoretical beam is never published as a +detection area, and the map draws nothing for a node with no polygon rather +than a sector nobody measured. Under `FOV_MODE=shadow|active` the published +polygon is the learned wedge instead, which is itself evidence-derived. + +**Every public node payload carries a `node_ref`.** `/api/radar/analytics` +(both variants), `/api/radar/analytics/{node_id}` and `/api/radar/nodes` each +carry one per node: the registry's `Node.node_ref` for a node registered +through `/v1/nodes`, and otherwise an HMAC-derived ref of the same +`nde` + 12 base36 shape, keyed on the node fuzz salt under a `node_ref|` +domain (`backend/services/node_ref.py`). Nodes on the blah2 bridge or the +plain TCP protocol have no registry row, so without the derivation half the +fleet would have no public name at all. The map shows only `node_ref`; the +`node_id` remains the join key on the wire and in the client. + +`CALIBRATION_SCHEMA` (currently 6) versions what a stored positive *means*; persisted state with an older schema is discarded and relearned at node registration, on every deployment, with no operator action (the ledger of past bumps is in `empirical_coverage.py`). diff --git a/frontend/src/components/LiveAircraftMap.css b/frontend/src/components/LiveAircraftMap.css index 12d493b2..458ed572 100644 --- a/frontend/src/components/LiveAircraftMap.css +++ b/frontend/src/components/LiveAircraftMap.css @@ -1158,6 +1158,46 @@ border: none !important; } +/* Count badge on a receive site shared by more than one node. Sits on the + glyph's shoulder rather than beside it so the marker's anchor — and + therefore the point it claims to be at — does not move when a site gains a + second receiver. */ +.node-badge { + position: absolute; + top: -3px; + right: -5px; + min-width: 12px; + height: 12px; + padding: 0 2px; + border-radius: 6px; + background: #facc15; + color: #0a162a; + font-size: 9px; + font-weight: 700; + line-height: 12px; + text-align: center; + box-shadow: 0 0 4px rgba(250, 204, 21, 0.75); +} + +/* Per-node "Show"/"Hide" control in a shared site's popup. A site with two + receivers has no single node for a marker click to select, so the choice + moves into the popup. */ +.node-select { + margin-top: 3px; + padding: 1px 7px; + border: 1px solid rgba(250, 204, 21, 0.5); + border-radius: 3px; + background: rgba(250, 204, 21, 0.12); + color: #facc15; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; +} + +.node-select:hover { + background: rgba(250, 204, 21, 0.24); +} + /* ── Bottom playback bar ─────────────────────────────────────── */ .playback-bar { diff --git a/frontend/src/components/LiveAircraftMap.tsx b/frontend/src/components/LiveAircraftMap.tsx index b3d581a1..6b9f6555 100644 --- a/frontend/src/components/LiveAircraftMap.tsx +++ b/frontend/src/components/LiveAircraftMap.tsx @@ -37,10 +37,12 @@ import { getAircraftColor, solveDiscCenter, solveUncertaintyRadiusM, + nodeSiteIcon, isRingOnlyRadius, - nodeIcon, - yagiSectorPositions, uncertaintyDiscRadiusM, + nodeLabel, + groupNodesBySite, + polygonMaxReachKm, FitBounds, ViewportTracker, MapClickClear, @@ -834,22 +836,38 @@ const BasemapLayer = memo(function BasemapLayer({ url }) { ); }); -/* ── NodeMarkersLayer: SVG CircleMarkers for synthetic nodes + divIcon for the - real radar node. - Background reason: 914 DOM divs with drop-shadow filters caused severe - pan/zoom jank, so the bulk synthetic fleet stays on cheap SVG circles in - a single overlay. But the real node is the one the user is actually - tracking, and a 5 px disc was getting lost under nearby aircraft icons — - so it gets the larger glowing divIcon (a handful of DOM nodes is fine). ── */ -const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelectNode }) { +/* ── NodeMarkersLayer: one marker per receive SITE, not per node. + Co-located receivers are published at exactly equal coordinates on + purpose — they share one fuzz offset so the pair leaks one sample of its + position instead of two (backend/services/node_sites.py). A marker per + node therefore stacked two identical glyphs and two identical + uncertainty discs on one point: the lower node could not be clicked at + all, and the doubled fill made a shared site look MORE precisely located + than a lone one. Sites carry a count badge instead, and the popup lists + each node at the site. + + Background reason for the two glyph kinds: 914 DOM divs with drop-shadow + filters caused severe pan/zoom jank, so the bulk synthetic fleet stays on + cheap SVG circles in a single overlay. But a real node is the one the + user is actually tracking, and a 5 px disc was getting lost under nearby + aircraft icons — so it gets the larger glowing divIcon (a handful of DOM + nodes is fine). A site counts as synthetic only when every node at it + is: one real receiver there means the site is real. ── */ +const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelectNode, selectedNodeId }) { const { NODE } = usePalette(); - return visibleNodes.map((n) => { - const isSynth = n.node_id?.startsWith("synth-"); + // Grouped once per node refresh, not per selection: selectedNodeId only + // flips a Show/Hide label inside a popup, and it changes on every click, so + // without this the whole fleet would be re-grouped for each one. + const sites = useMemo(() => groupNodesBySite(visibleNodes), [visibleNodes]); + return sites.map((site) => { + const multi = site.nodes.length > 1; // Every published rx coordinate is displaced by the backend; the disc is - // how the map admits it, at the radius the feed itself declares. Not - // special-cased by node kind — a synthetic node that ever carries the + // how the map admits it, at the radius the feed itself declares. One per + // site — the members share the coordinate and therefore the disc, and two + // stacked fills would read as a tighter answer than either node gave. + // Not special-cased by node kind — a synthetic node that ever carries the // field gets one too, because the disclosure follows the data. - const discRadiusM = uncertaintyDiscRadiusM(n.location_uncertainty_km); + const discRadiusM = uncertaintyDiscRadiusM(site.location_uncertainty_km); // A soft blob, not a ring: a crisp edge would read as a surveyed // boundary, and the true receiver is no likelier just inside the rim than // at the centre. The blur is a CSS filter (screen-space, so the edge @@ -858,7 +876,7 @@ const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelect // which is also what keeps the node reachable at far zoom. const disc = discRadiusM > 0 ? ( ) : null; - const uncertaintyLine = discRadiusM > 0 - ? <>Location approximate: ±{n.location_uncertainty_km} km
- : null; - if (isSynth) { + const popup = ( + + {multi && <>{site.nodes.length} nodes at this site
} + {discRadiusM > 0 && <>Location approximate: ±{site.location_uncertainty_km} km
} + {site.nodes.map((n, i) => ( + + {i > 0 &&
} + {nodeLabel(n)}
+ {/* Only measured coverage is quoted. The declared beam azimuth, + width and range used to be printed here; they are + configuration, most nodes' aim was never surveyed, and beside + a calibration-point count they read as measurements. */} + {n.empirical_polygon && n.empirical_polygon.length >= 3 + ? <>Coverage: measured from {n.empirical_n_points} calibration pts, + reach ≤ {polygonMaxReachKm(n.rx_lat, n.rx_lon, n.empirical_polygon)} km + : <>Coverage: not yet measured ({n.empirical_n_points || 0} calibration pts)} + {multi && ( + <> +
+ {/* At a shared site the marker click can no longer stand for + "select this node" — there are two — so each block carries + its own control and the click just opens the popup. */} + + + )} +
+ ))} +
+ ); + // A single-node site keeps the old behaviour: clicking the marker toggles + // that node's overlay and opens the popup. + const clickHandlers = multi ? undefined : { click: () => onSelectNode(site.nodes[0].node_id) }; + if (site.isSynth) { return ( - + {disc} onSelectNode(n.node_id) }} + eventHandlers={clickHandlers} > - - {n.node_id}
- {uncertaintyLine} - Beam: {n.beam_azimuth_deg}° / {n.beam_width_deg}°
- {n.max_bistatic_range_km != null - ? <>Bistatic range: {n.max_bistatic_range_km} km
- : <>Range: {n.max_range_km} km
} - {n.empirical_polygon && n.empirical_polygon.length >= 3 - ? <>Coverage: empirical, {n.empirical_n_points} calibration pts - : <>Coverage: theoretical ({n.empirical_n_points || 0} calibration pts)} -
+ {popup}
); } return ( - + {disc} onSelectNode(n.node_id) }} + eventHandlers={clickHandlers} > - - {n.node_id}
- {uncertaintyLine} - Beam: {n.beam_azimuth_deg}° / {n.beam_width_deg}°
- {n.max_bistatic_range_km != null - ? <>Bistatic range: {n.max_bistatic_range_km} km
- : <>Range: {n.max_range_km} km
} - {n.empirical_polygon && n.empirical_polygon.length >= 3 - ? <>Coverage: empirical, {n.empirical_n_points} calibration pts - : <>Coverage: theoretical ({n.empirical_n_points || 0} calibration pts)} -
+ {popup}
); @@ -927,52 +956,38 @@ const NodeMarkersLayer = memo(function NodeMarkersLayer({ visibleNodes, onSelect /* ── CoverageLayer: memoized — only re-renders when nodes or showCoverage changes ── */ const CoverageLayer = memo(function CoverageLayer({ visibleNodes, showCoverage }) { - const { COVERAGE, NODE } = usePalette(); + const { COVERAGE } = usePalette(); if (!showCoverage) return null; return visibleNodes.map((n) => { - if (n.empirical_polygon && n.empirical_polygon.length >= 3) { - // Blurred edge as presentational honesty, not as the privacy mechanism: - // the coordinates underneath are already fuzzed server-side, and the - // polygon is approximate by construction anyway (a rigid translation of - // the calibrated shape onto a fuzzed anchor). A 1.5 px stroke under the - // blur reads as a glow, i.e. as a boundary the data never established — - // so the outline goes and the fill alone carries the region. - return ( - - ); - } - // The theoretical fallback stays sharp on purpose: it is a declared model - // sector, already dashed to say so, and blurring it would conflate "we - // measured this, roughly" with "we never measured this at all". + // Measured coverage or nothing. A node with no polygon used to get a + // dashed theoretical Yagi sector drawn from its declared azimuth and + // width — numbers nobody surveyed — which put a confident wedge on the map + // for a node that had never been shown to detect anything. "We have not + // measured this yet" is drawn as empty space, which is what it is. + if (!n.empirical_polygon || n.empirical_polygon.length < 3) return null; + // Blurred edge as presentational honesty, not as the privacy mechanism: + // the coordinates underneath are already fuzzed server-side, and the + // polygon is approximate by construction anyway (a rigid translation of + // the calibrated shape onto a fuzzed anchor). A 1.5 px stroke under the + // blur reads as a glow, i.e. as a boundary the data never established — + // so the outline goes and the fill alone carries the region. return ( ); @@ -993,7 +1008,8 @@ const IlluminatorsLayer = memo(function IlluminatorsLayer({ visibleNodes, showIl if (Math.abs(n.tx_lat) < 1e-6 && Math.abs(n.tx_lon) < 1e-6) continue; const key = `${n.tx_lat.toFixed(4)},${n.tx_lon.toFixed(4)}`; if (!byTx.has(key)) byTx.set(key, { lat: n.tx_lat, lon: n.tx_lon, nodes: [] }); - byTx.get(key).nodes.push(n.node_id); + // Labels, not ids: this popup is as public as the node popups are. + byTx.get(key).nodes.push(nodeLabel(n)); } return [...byTx.entries()].map(([key, tx]) => ( { + const m = {}; + for (const n of nodes) m[n.node_id] = n; + return m; + }, [nodes]); + const nodeLabelFor = useCallback((nodeId) => nodeLabel(nodesById[nodeId]), [nodesById]); /* ── Local UI state ─────────────────────────────────────────── */ // URL-hash deep-link state — parsed once at mount. Anything we find here @@ -1168,10 +1193,10 @@ export default function LiveAircraftMap() { const [showFilters, setShowFilters] = useState(false); const [showStats, setShowStats] = usePersistedState("tf.layer.stats", initialLayers?.stats ?? true); const [showRangeRings, setShowRangeRings] = usePersistedState("tf.layer.rangeRings", initialLayers?.rangeRings ?? false); - // Beam-gap diagnostic defaults OFF: it draws one line per (aircraft, node) - // pair, so a metro-scoped fleet whose nodes all cover the same airspace turns - // the map into a thicket. Still available from the "Beam gaps" toolbar button - // and the `b` URL-hash layer. + // Coverage-gap diagnostic defaults OFF: it draws one line per (aircraft, + // node) pair, so a metro-scoped fleet whose nodes all cover the same airspace + // turns the map into a thicket. Still available from the "Coverage gaps" + // toolbar button and the `b` URL-hash layer. // Storage key is versioned (.v2) so the new default reaches anyone who // already has the old `tf.layer.inBeamDiag: true` persisted in localStorage — // without it, every existing user keeps seeing the lines. @@ -2001,10 +2026,13 @@ export default function LiveAircraftMap() { {/* Coverage zones — memoized, only re-renders on nodes/showCoverage change */} - {/* Node markers — uses full `nodes` list (not viewport-culled) so it only - re-renders every 30s when node data refreshes, not on every pan/zoom. + {/* Node markers — uses full `nodes` list (not viewport-culled) so the + site grouping is redone only every 30s when node data refreshes, + never on pan/zoom. A selection change re-renders the layer (the + popup's Show/Hide label reads it) but reuses the memoised + grouping, and react-leaflet leaves unchanged markers alone. SVG circles all share one composited layer — no per-element pan cost. */} - + {/* Illuminators (TX towers our nodes use) — off by default, deduped per transmitter */} @@ -2014,14 +2042,6 @@ export default function LiveAircraftMap() { const sn = visibleNodes.find((n) => n.node_id === selectedNodeId) || nodes.find((n) => n.node_id === selectedNodeId); if (!sn) return null; const hasEmpirical = Array.isArray(sn.empirical_polygon) && sn.empirical_polygon.length >= 3; - const conePositions = yagiSectorPositions( - sn.rx_lat, sn.rx_lon, - sn.tx_lat, sn.tx_lon, - sn.beam_azimuth_deg, - sn.beam_width_deg ?? 42, - sn.max_range_km ?? 50, - sn.max_bistatic_range_km, - ); // Find aircraft detected by this node (those whose node_id matches) const nodeAircraft = radarAircraft.filter((ac) => ac.node_id === selectedNodeId); return ( @@ -2048,21 +2068,12 @@ export default function LiveAircraftMap() { interactive={false} /> )} - {/* Theoretical Yagi cone — stays sharp and dashed like the - always-on fallback: a declared model sector must not borrow - the blurred edge that means "measured, roughly". Faint - reference behind empirical; full highlight when no empirical data */} - + {/* No theoretical cone here either: selecting a node used to + draw its declared Yagi sector, faint behind the measured + area and at full strength when there was none — so the + node with the LEAST evidence got the boldest wedge. A + node with nothing measured now shows its marker, its + transmitter and its detections, and no area at all. */} {/* TX tower marker */} {sn.tx_lat && sn.tx_lon && ( = 3; return ( - {/* Coverage area — the empirical polygon carries the same soft, + {/* Coverage area — the measured polygon, with the same soft, strokeless edge as the always-on CoverageLayer (see the note - there); the Yagi fallback below stays sharp and dashed because - it is a declared model, not a measurement. Fill raised to 0.18 - to replace the prominence of the dropped 1.5 px stroke. */} - {hasEmpirical ? ( + there). Fill raised to 0.18 to replace the prominence of the + dropped 1.5 px stroke. A contributing node with nothing + measured contributes no area: it keeps the ring and the line + to the aircraft, which is what says it contributed. */} + {hasEmpirical && ( - ) : ( - )} {/* Prominent node marker ring */} )} diff --git a/frontend/src/components/map/AircraftDetailPanel.tsx b/frontend/src/components/map/AircraftDetailPanel.tsx index 9a9a8221..0dace1fb 100644 --- a/frontend/src/components/map/AircraftDetailPanel.tsx +++ b/frontend/src/components/map/AircraftDetailPanel.tsx @@ -8,7 +8,13 @@ import { M_PER_FT, KNOTS_PER_MS, MS_PER_KNOT } from "./units"; import { solveUncertaintyRadiusM, solveUncertaintyRadius95M } from "./uncertainty"; import { usePalette } from "./useMapTheme"; -export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, computeError, detectingNodes = [], solveHistory = null }) { +/** + * `nodeLabelFor` maps a node id to the handle the map is allowed to print + * (map/nodeSites.ts). Node ids stay the join key everywhere — `ac.node_id`, + * `detectingNodes` — and only the text changes; the default keeps the panel + * usable on its own (in a test, say) without ever falling back to the id. + */ +export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, computeError, detectingNodes = [], solveHistory = null, nodeLabelFor = (_nodeId) => "unlisted node" }) { const { ANOMALY, DRONE, GOOD, INK_MUTED, INK_SUBTLE, LANE_MN_ADSB } = usePalette(); if (!ac) return null; @@ -205,7 +211,7 @@ export default function AircraftDetailPanel({ ac, onClose, groundTruth, trails, {isAdsbSingleNode && (
Claimed detection
- + - {detectingNodes.join(", ")} + {detectingNodes.map(nodeLabelFor).join(", ")} ({detectingNodes.length}) ) diff --git a/frontend/src/components/map/InBeamDiagnostic.tsx b/frontend/src/components/map/InBeamDiagnostic.tsx index 5442469b..a97246fb 100644 --- a/frontend/src/components/map/InBeamDiagnostic.tsx +++ b/frontend/src/components/map/InBeamDiagnostic.tsx @@ -2,23 +2,28 @@ import { memo, useEffect, useRef } from "react"; import { useMap } from "react-leaflet"; import L from "leaflet"; -import { isInBeam } from "./geo"; +import { pointInPolygon, haversineDistanceKm } from "./geo"; import { groundTruthKey } from "./constants"; import { usePalette } from "./useMapTheme"; -/* ── InBeamDiagnostic: flags ADS-B aircraft inside a node's beam that - have no recent confirmed detection from that node. Renders a - dashed red polyline from the node's RX position to the aircraft — - one per (aircraft, node) pair — so the "missing link" is +/* ── InBeamDiagnostic: flags ADS-B aircraft inside a node's MEASURED + coverage that have no recent confirmed detection from that node. + Renders a dashed red polyline from the node's RX position to the + aircraft — one per (aircraft, node) pair — so the "missing link" is geometrically visible. Reads detectionsRef as the recently-detected oracle — a "hex|node_id" → ts map covering every detection shape (single-node and multinode), TTL-pruned so its grace period matches - the spec (don't flag a detection that only just expired). Thresholds - are tightened to 0.9 × beam width and 0.95 × max range to avoid - flagging aircraft that are only momentarily clipping the edges. ── */ - -const BEAM_WIDTH_FACTOR = 0.9; -const MAX_RANGE_FACTOR = 0.95; + the spec (don't flag a detection that only just expired). + + Membership used to be a declared-beam test (azimuth, width, range, + tightened by fudge factors to stop edge-clipping aircraft flagging). + Those numbers are configuration, so the layer was reporting gaps + against a wedge the node had never been shown to cover: a mis-declared + azimuth painted the map red, and a node whose real lobe sat outside its + declared one had its true gaps hidden. The test is now the node's own + empirical polygon, which is what "this node can see here" means, and a + node with no measured coverage yet is skipped entirely — there is + nothing to be missing from. ── */ const InBeamDiagnostic = memo(function InBeamDiagnostic({ detectionsRef, groundTruthRef, nodesByIdRef, smoothRef }) { const { BEAM_GAP } = usePalette(); @@ -42,23 +47,30 @@ const InBeamDiagnostic = memo(function InBeamDiagnostic({ detectionsRef, groundT // Pre-resolve the node list once per tick, so the O(truth × nodes) loop // below rejects distant pairs with two comparisons instead of a - // haversine + bearing — enabling this layer on a dense testmap used to - // hang the tab. + // point-in-polygon walk — enabling this layer on a dense testmap used + // to hang the tab. The bounding box comes from the polygon itself (its + // furthest vertex), so the prefilter can never exclude a point the + // polygon test would have accepted. // // rx_lat/rx_lon are the server-published coordinates, displaced from the // operator's true position by the backend; no true receiver position - // reaches the browser. The backend derives its own published beam - // geometry from the same anchor, so in-beam rays drawn from it agree - // with the served arcs by construction. + // reaches the browser. The polygon is translated rigidly onto that same + // anchor, so a ray drawn from it agrees with the served coverage by + // construction. const nodeList = []; for (const [nodeId, node] of Object.entries(nodes)) { const rxLat = node.rx_lat; const rxLon = node.rx_lon; - const { beam_azimuth_deg: azimuth, beam_width_deg: beamWidth, max_range_km: maxRange } = node; - if (rxLat == null || rxLon == null || azimuth == null || beamWidth == null || maxRange == null) continue; - const reachKm = maxRange * MAX_RANGE_FACTOR; + const polygon = node.empirical_polygon; + if (rxLat == null || rxLon == null) continue; + if (!Array.isArray(polygon) || polygon.length < 3) continue; + let reachKm = 0; + for (const [vLat, vLon] of polygon) { + const d = haversineDistanceKm(rxLat, rxLon, vLat, vLon); + if (d > reachKm) reachKm = d; + } nodeList.push({ - nodeId, rxLat, rxLon, azimuth, beamWidth, reachKm, + nodeId, rxLat, rxLon, polygon, reachKm, latPadDeg: reachKm / 111 + 0.01, }); } @@ -67,29 +79,29 @@ const InBeamDiagnostic = memo(function InBeamDiagnostic({ detectionsRef, groundT if (!Array.isArray(trail) || trail.length === 0) continue; const last = trail[trail.length - 1]; if (!last) continue; - // Beam membership is tested against the raw ground-truth sample — + // Coverage membership is tested against the raw ground-truth sample — // the freshest real fix. A dead-reckoned position extrapolates the // last velocity and, for a stalled/lost track, can glide across a - // beam edge and fabricate (or hide) a gap, so it must NOT drive the - // in/out decision. - const beamLat = last[0]; - const beamLon = last[1]; - if (beamLat == null || beamLon == null) continue; + // coverage edge and fabricate (or hide) a gap, so it must NOT drive + // the in/out decision. + const fixLat = last[0]; + const fixLon = last[1]; + if (fixLat == null || fixLon == null) continue; // The drawn endpoint, by contrast, prefers the dead-reckoned position // (same source that draws the aircraft dot) so the line's far end // lands on the icon rather than lagging behind it by one update. const s = smooth[groundTruthKey(hex)]; - const acLat = s ? s.lat : beamLat; - const acLon = s ? s.lon : beamLon; - const kmPerDegLon = 111 * Math.cos(beamLat * (Math.PI / 180)); + const acLat = s ? s.lat : fixLat; + const acLon = s ? s.lon : fixLon; + const kmPerDegLon = 111 * Math.cos(fixLat * (Math.PI / 180)); for (const n of nodeList) { // Cheap box reject before any trig. - if (Math.abs(beamLat - n.rxLat) > n.latPadDeg) continue; - if (Math.abs(beamLon - n.rxLon) * kmPerDegLon > n.reachKm + 1) continue; + if (Math.abs(fixLat - n.rxLat) > n.latPadDeg) continue; + if (Math.abs(fixLon - n.rxLon) * kmPerDegLon > n.reachKm + 1) continue; if (recentDetections[`${hex}|${n.nodeId}`] != null) continue; - if (!isInBeam(n.rxLat, n.rxLon, n.azimuth, n.beamWidth * BEAM_WIDTH_FACTOR, n.reachKm, beamLat, beamLon)) continue; + if (!pointInPolygon(fixLat, fixLon, n.polygon)) continue; const pairKey = `${hex}|${n.nodeId}`; seen.add(pairKey); diff --git a/frontend/src/components/map/Toolbar.tsx b/frontend/src/components/map/Toolbar.tsx index e1a50e31..e2757915 100644 --- a/frontend/src/components/map/Toolbar.tsx +++ b/frontend/src/components/map/Toolbar.tsx @@ -223,9 +223,9 @@ export default function Toolbar({