diff --git a/backend/routes/archive.py b/backend/routes/archive.py index ade64efed..75d94a8bc 100644 --- a/backend/routes/archive.py +++ b/backend/routes/archive.py @@ -4,23 +4,33 @@ Doppler and SNR plus the node's rx/tx geometry, for one node at a time. That makes it the most literal reading of "publish this node's detections" the server has, so a node whose owner registered it private is not listed here and -its keys do not download. The keys are Hive-partitioned -(``year=/month=/day=/node_id=/part-*.parquet``), so the node id is in the key -itself and both checks read it from there — see ``_key_node_id``. +its keys do not download. + +The store is Hive-partitioned on the private id +(``year=/month=/day=/node_id=/part-*.parquet``), so the id is in every key on +disk. The route publishes each key with that segment rewritten to the node's +published identity (``node_ref=``), resolves it back on the way in, and renames +the downloaded body's ``node_id`` the same way: no key, parameter or body it +hands out names the private id. See ``_published_key`` and ``_stored_key``. """ import asyncio from fastapi import APIRouter, HTTPException, Query +from services.node_refs import id_for_identity, public_identity from services.publication import private_node_ids from services.storage import list_archived_files, read_archived_file router = APIRouter(tags=["archive"]) +# The node segment's Hive name on disk, and the one it is published under. +_STORED = "node_id=" +_PUBLISHED = "node_ref=" + def _key_node_id(key: str) -> str: - """The node id a key is partitioned under, or "" if it has none. + """The node id a stored key is partitioned under, or "" if it has none. Reads the second-to-last path segment, which is where both the Hive layout (``node_id=NNN``) and the legacy one (a bare directory name) put it, and @@ -35,20 +45,59 @@ def _key_node_id(key: str) -> str: return parts[-2].split("=", 1)[-1] +def _published_key(key: str) -> str | None: + """A stored key as the listing publishes it, or None if its node has no + published identity. A key with no node segment is published as it is.""" + parts = key.strip("/").split("/") + if len(parts) < 2: + return key + hive = parts[-2].startswith(_STORED) + ref = public_identity(parts[-2].removeprefix(_STORED)) + if ref is None: + return None + parts[-2] = f"{_PUBLISHED}{ref}" if hive else ref + return "/".join(parts) + + +def _stored_key(key: str) -> tuple[str, str] | None: + """The stored key behind a published one, and the node id it names ("" for + a key with no node segment); None if it names no published node. + + Only the published spellings resolve: a ``node_id=`` segment, or a bare + one holding a private id, is a key this route never handed out. + """ + parts = key.strip("/").split("/") + if len(parts) < 2: + return key, "" + hive = parts[-2].startswith(_PUBLISHED) + if "=" in parts[-2] and not hive: + return None + node_id = id_for_identity(parts[-2].removeprefix(_PUBLISHED)) + if node_id is None: + return None + parts[-2] = f"{_STORED}{node_id}" if hive else node_id + return "/".join(parts), node_id + + @router.get("/api/data/archive") async def list_archive( date: str = Query(None, description="Date prefix, e.g. 2025/06/21"), - node_id: str = Query(None, description="Filter by node ID"), + node_ref: str = Query(None, description="Filter by node_ref"), limit: int = Query(50, ge=1, le=500, description="Page size"), offset: int = Query(0, ge=0, description="Pagination offset"), ): + private = private_node_ids() + node_id = None + if node_ref: + # An unknown ref and a private node's answer the same, so the filter + # cannot confirm that a private node exists. + node_id = id_for_identity(node_ref) + if node_id is None or node_id in private: + return {"files": [], "count": 0, "total": 0} loop = asyncio.get_running_loop() result = await loop.run_in_executor( None, lambda: list_archived_files(date_prefix=date, node_id=node_id, limit=limit, offset=offset) ) - private = private_node_ids() - if not private: - return result # Filtered after paging rather than before it, which shortens a page # instead of resampling the store: list_archived_files walks the tree in # date order with an early exit, and pushing the predicate into it would @@ -56,18 +105,37 @@ async def list_archive( # page fills. `count` follows what is actually returned; `total` is left # as the scan reported, the same approximation it already is for the # truncated no-date path. - files = [f for f in result.get("files", []) if _key_node_id(f.get("key", "")) not in private] + files = [] + for f in result.get("files", []): + key = f.get("key", "") + if _key_node_id(key) in private: + continue + published = _published_key(key) + if published is not None: + files.append({**f, "key": published}) return {**result, "files": files, "count": len(files)} @router.get("/api/data/archive/{key:path}") async def download_archive_file(key: str): - # Before the read, not after: the point is not to avoid serving the bytes - # but to give a private node's key the same answer as a key that does not - # exist, so the endpoint cannot be used to enumerate the private fleet. - if _key_node_id(key) in private_node_ids(): + # The policy is read before the key is resolved, and both before the read: + # a private node's key gets the same answer as a key that does not exist, + # so the endpoint cannot be used to enumerate the private fleet. + private = private_node_ids() + stored = _stored_key(key) + if stored is None or stored[1] in private: raise HTTPException(status_code=404, detail="Archive file not found") - data = read_archived_file(key) + data = read_archived_file(stored[0]) if data is None: raise HTTPException(status_code=404, detail="Archive file not found") + if isinstance(data, dict) and "node_id" in data: + data = dict(data) + body_id = data.pop("node_id") + # Named from the key where it has a node, which is already resolved: a + # zero-row file's own node_id is "". A key with no node segment was + # never checked above, so the node its body names is checked here. + node_id = stored[1] or body_id + if node_id in private: + raise HTTPException(status_code=404, detail="Archive file not found") + data["node_ref"] = public_identity(node_id) return data diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 5775bfbbf..437ec7b19 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -43,7 +43,7 @@ release_node, ) from services.node_config import position_status -from services.node_refs import owner_identity +from services.node_refs import owner_identity, public_name from services.polled_radars import remove_polled_radar logger = logging.getLogger(__name__) @@ -417,11 +417,14 @@ async def my_nodes(request: Request): info = snapshot.get(nid) or {} cfg = info.get("config", {}) or {} private, source = privacy.get(nid, (False, publication.SOURCE_DEFAULT)) + ref = owner_identity(nid) out.append( { "node_id": nid, - "node_ref": owner_identity(nid), - "name": cfg.get("name", nid), + "node_ref": ref, + # The console names a node by its ref, so an unnamed node, or + # one whose name is a node id, is called by that instead. + "name": public_name(cfg.get("name"), ref, node_ids), "status": info.get("status", "never_connected"), "last_heartbeat": info.get("last_heartbeat"), "is_synthetic": info.get("is_synthetic", False), diff --git a/backend/services/node_refs.py b/backend/services/node_refs.py index c263a028c..39d10deeb 100644 --- a/backend/services/node_refs.py +++ b/backend/services/node_refs.py @@ -131,16 +131,17 @@ def id_for_identity(identity: str | None) -> str | None: """The node behind a published identity, or None if nothing publishes as it. The inverse of `public_identity`, and what every public path parameter - resolves through. It is `id_for_ref` plus the synthetic passthrough that + resolves through. It is `id_for_ref` plus the two fallbacks `public_identity` applies on the way out: a synthetic node is published - under its own id, so that id is its handle and must keep resolving. A real - node is reachable only by the ref its registry row carries. + under its own id, so that id is its handle and must keep resolving, and a + mirrored node under the ref its sending environment resolved. A real node + is reachable only by the ref it is published under. """ if not identity: return None if is_synthetic_node(identity): return identity - return id_for_ref(identity) + return id_for_ref(identity) or _mirrored_id(identity) def ref_to_id_map(node_ids: Iterable[str] = ()) -> dict[str, str]: @@ -177,7 +178,7 @@ def _names_a_node(value: str, known_ids: Iterable[str]) -> bool: return value in _forward or value in known_ids -def public_name(name, fallback: str, known_ids: Iterable[str] = ()) -> str: +def public_name(name, fallback: str | None, known_ids: Iterable[str] = ()) -> str | None: """A node-supplied display name, or `fallback` when it names a node. Nodes fill this field in themselves and nothing validates it, so a name @@ -207,6 +208,22 @@ def _mirrored_ref(node_id: str) -> str | None: return ref if isinstance(ref, str) and ref else None +def _mirrored_id(node_ref: str) -> str | None: + """The mirrored node published under `node_ref`, the inverse of `_mirrored_ref`. + + A scan of the connected nodes, which is where a mirrored ref lives; there + are tens of them. A candidate counts only if `owner_identity` names it by + this ref, so a registry row that outranks its mirrored ref on the way out + outranks it here too. + """ + with state.connected_nodes_lock: + snapshot = list(state.connected_nodes.items()) + for node_id, known in snapshot: + if known.get("node_ref") == node_ref and owner_identity(node_id) == node_ref: + return node_id + return None + + def owner_identity(node_id: str | None) -> str | None: """What a node publishes as, for a caller that already knows the node. diff --git a/backend/tests/test_auth_routes.py b/backend/tests/test_auth_routes.py index a2fdeade4..143ce1743 100644 --- a/backend/tests/test_auth_routes.py +++ b/backend/tests/test_auth_routes.py @@ -174,6 +174,43 @@ def test_an_owned_node_carries_the_ref_the_public_surfaces_key_on(self, client): finally: asyncio.run(set_node_owner(node_id, None)) + @pytest.mark.parametrize("configured", [None, "ref-name-node"]) + def test_a_node_without_a_name_of_its_own_is_called_by_its_ref(self, configured, client): + """An unnamed node, and one whose name is its own id, read as the ref: + the console never shows the node_id.""" + from core import state + from core.auth import set_node_owner + from core.users import ANONYMOUS_USER + + node_id = "ref-name-node" + ref = own(node_id, ANONYMOUS_USER["id"]) + with state.connected_nodes_lock: + state.connected_nodes[node_id] = {"status": "active", "config": {"name": configured}} + try: + node = next(n for n in client.get("/api/auth/me/nodes").json() if n["node_id"] == node_id) + assert node["name"] == ref + finally: + with state.connected_nodes_lock: + state.connected_nodes.pop(node_id, None) + asyncio.run(set_node_owner(node_id, None)) + + def test_a_node_keeps_the_name_it_was_given(self, client): + from core import state + from core.auth import set_node_owner + from core.users import ANONYMOUS_USER + + node_id = "named-node" + own(node_id, ANONYMOUS_USER["id"]) + with state.connected_nodes_lock: + state.connected_nodes[node_id] = {"status": "active", "config": {"name": "Roof dish"}} + try: + node = next(n for n in client.get("/api/auth/me/nodes").json() if n["node_id"] == node_id) + assert node["name"] == "Roof dish" + finally: + with state.connected_nodes_lock: + state.connected_nodes.pop(node_id, None) + asyncio.run(set_node_owner(node_id, None)) + def test_my_nodes_entry_carries_position_status_for_a_private_node(self, client): """A private node is filtered out of /api/radar/nodes entirely, so its owner has nowhere else to learn it needs a position.""" diff --git a/backend/tests/test_node_refs.py b/backend/tests/test_node_refs.py index b7a2f9383..d1c48cc0b 100644 --- a/backend/tests/test_node_refs.py +++ b/backend/tests/test_node_refs.py @@ -329,6 +329,12 @@ def _connected(self, **entry): with state.connected_nodes_lock: state.connected_nodes[_MIRRORED] = {"is_synthetic": False, "status": "active", **entry} + def teardown_method(self): + from core import state + + with state.connected_nodes_lock: + state.connected_nodes.pop(_MIRRORED, None) + def test_a_mirrored_ref_is_published_when_the_registry_has_none(self, seed): seed(ret1a2b3c4d="nde1a2b3c4d00") self._connected(node_ref="ndemirrored001") @@ -345,6 +351,18 @@ def test_the_registry_wins_over_a_mirrored_ref(self, seed): self._connected(node_ref="ndemirrored001") assert node_refs.public_identity(_MIRRORED) == "ndelocalrow001" + def test_a_mirrored_ref_resolves_back_to_its_node(self, seed): + """Public path parameters take the ref a node is published under.""" + seed(ret1a2b3c4d="nde1a2b3c4d00") + self._connected(node_ref="ndemirrored001") + assert node_refs.id_for_identity("ndemirrored001") == _MIRRORED + + def test_a_mirrored_ref_the_registry_outranks_resolves_to_nothing(self, seed): + seed(**{_MIRRORED: "ndelocalrow001"}) + self._connected(node_ref="ndemirrored001") + assert node_refs.id_for_identity("ndemirrored001") is None + assert node_refs.id_for_identity("ndelocalrow001") == _MIRRORED + def test_a_mirrored_node_reaches_the_published_feed(self, seed): seed(ret1a2b3c4d="nde1a2b3c4d00") self._connected(node_ref="ndemirrored001") diff --git a/backend/tests/test_publication.py b/backend/tests/test_publication.py index da217b2f5..b65546b61 100644 --- a/backend/tests/test_publication.py +++ b/backend/tests/test_publication.py @@ -1180,44 +1180,146 @@ def test_node_id_comes_out_of_the_key(self, key, expected): class TestArchiveRoutes: - KEY = f"year=2026/month=08/day=27/node_id={_PRIV}/part-120000.parquet" + """The store is keyed on node_id; everything the route hands out, node_ref.""" - def test_a_private_nodes_file_is_not_listed(self, client, seed_nodes, monkeypatch): + @staticmethod + def stored(node_id: str) -> str: + return f"year=2026/month=08/day=27/node_id={node_id}/part-120000.parquet" + + @staticmethod + def published(node_id: str) -> str: + return f"year=2026/month=08/day=27/node_ref={_seed_ref(node_id)}/part-120000.parquet" + + @staticmethod + def listing(monkeypatch, *keys: str) -> list[dict]: import routes.archive as ar - seed_nodes(**{_PRIV: "private"}) - pub_key = self.KEY.replace(_PRIV, _PUB) - monkeypatch.setattr( - ar, - "list_archived_files", - lambda **kw: { - "files": [{"key": self.KEY, "size_bytes": 1}, {"key": pub_key, "size_bytes": 1}], - "count": 2, - "total": 2, - }, - ) + calls = [] + + def _list(**kw): + calls.append(kw) + return {"files": [{"key": k, "size_bytes": 1} for k in keys], "count": len(keys), "total": len(keys)} + + monkeypatch.setattr(ar, "list_archived_files", _list) + return calls + + @staticmethod + def reads(monkeypatch, body: dict) -> list[str]: + import routes.archive as ar + + called = [] + monkeypatch.setattr(ar, "read_archived_file", lambda key: called.append(key) or body) + return called + + def test_a_public_nodes_file_is_listed_under_its_ref(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PUB: "public"}) + self.listing(monkeypatch, self.stored(_PUB)) + response = client.get("/api/data/archive") + assert [f["key"] for f in response.json()["files"]] == [self.published(_PUB)] + assert _PUB not in response.text + + def test_a_private_nodes_file_is_not_listed(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PRIV: "private", _PUB: "public"}) + self.listing(monkeypatch, self.stored(_PRIV), self.stored(_PUB)) body = client.get("/api/data/archive").json() - assert [f["key"] for f in body["files"]] == [pub_key] + assert [f["key"] for f in body["files"]] == [self.published(_PUB)] assert body["count"] == 1 + def test_a_node_with_no_ref_is_not_listed(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PUB: "public"}) + self.listing(monkeypatch, self.stored("unregistered01"), self.stored(_PUB)) + response = client.get("/api/data/archive") + assert [f["key"] for f in response.json()["files"]] == [self.published(_PUB)] + assert "unregistered01" not in response.text + + def test_the_legacy_layout_is_published_under_the_ref_too(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PUB: "public"}) + self.listing(monkeypatch, f"2025/06/21/{_PUB}/detections_120000.json") + keys = [f["key"] for f in client.get("/api/data/archive").json()["files"]] + assert keys == [f"2025/06/21/{_seed_ref(_PUB)}/detections_120000.json"] + + def test_the_filter_takes_a_ref_and_scans_the_node_behind_it(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PUB: "public"}) + calls = self.listing(monkeypatch, self.stored(_PUB)) + body = client.get("/api/data/archive", params={"node_ref": _seed_ref(_PUB)}).json() + assert calls[0]["node_id"] == _PUB + assert [f["key"] for f in body["files"]] == [self.published(_PUB)] + + @pytest.mark.parametrize("asked", ["private", "node id", "unknown"]) + def test_the_filter_answers_nothing_but_a_published_ref(self, asked, client, seed_nodes, monkeypatch): + """A private node's ref, a private id and a ref nobody holds read the + same, and none of them reaches the store.""" + seed_nodes(**{_PRIV: "private", _PUB: "public"}) + calls = self.listing(monkeypatch, self.stored(_PUB)) + value = {"private": _seed_ref(_PRIV), "node id": _PUB, "unknown": node_auth.mint_node_ref()}[asked] + body = client.get("/api/data/archive", params={"node_ref": value}).json() + assert body == {"files": [], "count": 0, "total": 0} + assert calls == [] + + def test_a_published_key_downloads_under_the_ref(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PUB: "public"}) + called = self.reads(monkeypatch, {"node_id": _PUB, "count": 0, "detections": []}) + response = client.get(f"/api/data/archive/{self.published(_PUB)}") + assert response.status_code == 200 + assert called == [self.stored(_PUB)] + assert response.json() == {"node_ref": _seed_ref(_PUB), "count": 0, "detections": []} + + def test_a_mirrored_nodes_file_lists_filters_and_downloads_under_its_ref(self, client, monkeypatch): + """A node mirrored from another environment has no row here; its ref + rides on its connected entry, in both directions.""" + mirrored, ref = "ret0badcafe", "ndemirrored001" + monkeypatch.setitem(state.connected_nodes, mirrored, {"status": "active", "node_ref": ref}) + node_refs._reset_for_tests() + stored = self.stored(mirrored) + published = stored.replace(f"node_id={mirrored}", f"node_ref={ref}") + self.listing(monkeypatch, stored) + called = self.reads(monkeypatch, {"node_id": mirrored, "detections": []}) + assert [f["key"] for f in client.get("/api/data/archive").json()["files"]] == [published] + assert client.get("/api/data/archive", params={"node_ref": ref}).json()["count"] == 1 + assert client.get(f"/api/data/archive/{published}").json() == {"node_ref": ref, "detections": []} + assert called == [stored] + + def test_a_file_with_no_node_in_its_key_is_withheld_if_its_body_names_a_private_node( + self, client, seed_nodes, monkeypatch + ): + seed_nodes(**{_PRIV: "private", _PUB: "public"}) + self.reads(monkeypatch, {"node_id": _PRIV, "detections": []}) + assert client.get("/api/data/archive/detections_120000.json").status_code == 404 + self.reads(monkeypatch, {"node_id": _PUB, "detections": []}) + response = client.get("/api/data/archive/detections_120000.json") + assert response.json() == {"node_ref": _seed_ref(_PUB), "detections": []} + + def test_an_empty_file_is_named_by_its_key(self, client, seed_nodes, monkeypatch): + """A zero-row Parquet reads back with node_id "", but its key names the node.""" + seed_nodes(**{_PUB: "public"}) + self.reads(monkeypatch, {"node_id": "", "count": 0, "detections": []}) + response = client.get(f"/api/data/archive/{self.published(_PUB)}") + assert response.json() == {"node_ref": _seed_ref(_PUB), "count": 0, "detections": []} + + def test_a_legacy_key_resolves_to_its_bare_directory(self, client, seed_nodes, monkeypatch): + seed_nodes(**{_PUB: "public"}) + called = self.reads(monkeypatch, {"frames": []}) + response = client.get(f"/api/data/archive/2025/06/21/{_seed_ref(_PUB)}/detections_120000.json") + assert response.status_code == 200 + assert called == [f"2025/06/21/{_PUB}/detections_120000.json"] + + def test_a_stored_key_does_not_download(self, client, seed_nodes, monkeypatch): + """The key on disk names the private id, so it is not an address this + route answers to, public node or not.""" + seed_nodes(**{_PUB: "public"}) + called = self.reads(monkeypatch, {"frames": []}) + assert client.get(f"/api/data/archive/{self.stored(_PUB)}").status_code == 404 + assert client.get(f"/api/data/archive/2025/06/21/{_PUB}/detections_120000.json").status_code == 404 + assert called == [] + def test_a_private_nodes_file_does_not_download(self, client, seed_nodes, monkeypatch): """Same answer as a key that does not exist, so it cannot enumerate.""" - import routes.archive as ar - seed_nodes(**{_PRIV: "private"}) - called = [] - monkeypatch.setattr(ar, "read_archived_file", lambda key: called.append(key) or {"frames": []}) - assert client.get(f"/api/data/archive/{self.KEY}").status_code == 404 + called = self.reads(monkeypatch, {"frames": []}) + assert client.get(f"/api/data/archive/{self.published(_PRIV)}").status_code == 404 # 404 decided before the read, not by discarding what came back. assert called == [] - def test_a_public_nodes_file_still_downloads(self, client, seed_nodes, monkeypatch): - import routes.archive as ar - - seed_nodes(**{_PRIV: "private"}) - monkeypatch.setattr(ar, "read_archived_file", lambda key: {"frames": []}) - assert client.get(f"/api/data/archive/{self.KEY.replace(_PRIV, _PUB)}").status_code == 200 - # ── The single-node documents ──────────────────────────────────────────────── diff --git a/backend/tests/test_routes.py b/backend/tests/test_routes.py index 7e3722a06..5abc7a67e 100644 --- a/backend/tests/test_routes.py +++ b/backend/tests/test_routes.py @@ -264,13 +264,15 @@ def test_existing_archive_file_returns_content(self, client, tmp_path, monkeypat import services.storage as _storage + # A synthetic node publishes under its own id, so its key on disk is + # also the one the route answers to. archive_dir = tmp_path / "archive" - node_dir = archive_dir / "2025" / "06" / "21" / "node-A" + node_dir = archive_dir / "2025" / "06" / "21" / "test-node-A" node_dir.mkdir(parents=True) payload = {"aircraft": [], "now": 1234567890} (node_dir / "detections_120000.json").write_text(json.dumps(payload)) monkeypatch.setattr(_storage, "_LOCAL_ARCHIVE_DIR", str(archive_dir)) - r = client.get("/api/data/archive/2025/06/21/node-A/detections_120000.json") + r = client.get("/api/data/archive/2025/06/21/test-node-A/detections_120000.json") assert r.status_code == 200 assert r.json()["now"] == 1234567890 diff --git a/dashboard/src/api/client.ts b/dashboard/src/api/client.ts index 157f29bd9..b4b532fc3 100644 --- a/dashboard/src/api/client.ts +++ b/dashboard/src/api/client.ts @@ -138,9 +138,9 @@ export const api = { anomalies: () => request("/api/radar/anomalies"), // Archive - archive: (limit = 50, offset = 0, nodeId = null) => { + archive: (limit = 50, offset = 0, nodeRef = null) => { const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); - if (nodeId) params.set("node_id", nodeId); + if (nodeRef) params.set("node_ref", nodeRef); return request(`/api/data/archive?${params}`); }, diff --git a/dashboard/src/pages/admin/StoragePage.tsx b/dashboard/src/pages/admin/StoragePage.tsx index 2cf662578..5854c5f83 100644 --- a/dashboard/src/pages/admin/StoragePage.tsx +++ b/dashboard/src/pages/admin/StoragePage.tsx @@ -5,6 +5,7 @@ import { FetchNotice, nothingLoaded } from "../../components/Notice"; import { Pager, clampPage } from "../../components/Pager"; import { StatCard } from "../../components/StatCard"; import { UsageBar } from "../../components/UsageBar"; +import { useNodeIds } from "../../hooks/useNodeIds"; import { useFetch } from "../../hooks/usePolling"; import { formatBytes } from "../../utils/format"; @@ -28,6 +29,9 @@ export default function StoragePage() { () => api.archive(PAGE_SIZE, page * PAGE_SIZE).then((listing) => ({ page, listing })), page, ); + // The listing is the public route's, which names each file by its node_ref; + // the scan above is keyed on node_id, so a file's node is shown as that too. + const nodeIds = useNodeIds(); const { pending: loading } = archiveFetch; const archive = archiveFetch.data?.listing; const pageFetch = archiveFetch.data?.page === page ? archiveFetch : { ...archiveFetch, updatedAt: null }; @@ -71,7 +75,10 @@ export default function StoragePage() { const key = typeof file === "string" ? file : (file.key || ""); const parts = key.split("/"); const name = parts[parts.length - 1] || key; - const node = parts.length >= 4 ? parts[3] : "—"; + // The segment before the filename, as the route and the Data Explorer read it. + const segment = parts.length >= 4 ? parts[parts.length - 2] : ""; + const ref = segment.slice(segment.indexOf("=") + 1); + const node = ref ? (nodeIds?.[ref] ?? ref) : "—"; const size = file.size_bytes != null ? formatBytes(file.size_bytes) : "—"; const date = file.modified ? new Date(file.modified).toLocaleString() : "—"; return ( diff --git a/dashboard/src/pages/user/OnboardingPage.tsx b/dashboard/src/pages/user/OnboardingPage.tsx index 208666a81..5a4919eaf 100644 --- a/dashboard/src/pages/user/OnboardingPage.tsx +++ b/dashboard/src/pages/user/OnboardingPage.tsx @@ -15,7 +15,7 @@ import type { LocationPrivacySource } from "../../types"; type OwnedNode = { node_id: string; node_ref: string | null; - name: string; + name: string | null; status: string; last_heartbeat: string | null; is_synthetic: boolean; @@ -81,12 +81,11 @@ export default function OnboardingPage() { ) : ( {nodes.map((n) => ( - {n.node_id} {n.node_ref ?? "—"}{" "} diff --git a/dashboard/src/pages/user/OverviewPage.tsx b/dashboard/src/pages/user/OverviewPage.tsx index da7665f95..4a7f089da 100644 --- a/dashboard/src/pages/user/OverviewPage.tsx +++ b/dashboard/src/pages/user/OverviewPage.tsx @@ -127,7 +127,7 @@ export default function OverviewPage() { onClick={ref ? () => navigate(`/nodes/${ref}`) : undefined} > - {node.name || ref || node.node_id || node.id} + {node.name || ref || "—"} @@ -189,7 +189,7 @@ export default function OverviewPage() {
- {node.name || id} + {node.name || ref || "—"}
Detections diff --git a/dashboard/src/pages/user/TunnelLinkPage.tsx b/dashboard/src/pages/user/TunnelLinkPage.tsx index 1d16792ae..472c21b6a 100644 --- a/dashboard/src/pages/user/TunnelLinkPage.tsx +++ b/dashboard/src/pages/user/TunnelLinkPage.tsx @@ -69,7 +69,7 @@ export default function TunnelLinkPage() {
{(() => { const filtered = search - ? nodes.filter((n) => ((n.name || n.node_id || "")).toLowerCase().includes(search.toLowerCase())) + ? nodes.filter((n) => `${n.name || ""} ${n.node_ref || ""}`.toLowerCase().includes(search.toLowerCase())) : nodes; const totalPages = Math.ceil(filtered.length / PAGE_SIZE); const paged = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); @@ -87,7 +87,7 @@ export default function TunnelLinkPage() { - {node.name || id} + {node.name || node.node_ref || "—"} {" "} diff --git a/dashboard/src/pages/user/dataExplorer/NodePicker.tsx b/dashboard/src/pages/user/dataExplorer/NodePicker.tsx index 977345512..a3eff6914 100644 --- a/dashboard/src/pages/user/dataExplorer/NodePicker.tsx +++ b/dashboard/src/pages/user/dataExplorer/NodePicker.tsx @@ -82,8 +82,8 @@ export function NodePicker({ filters, nodes, discovered, fileCountFor, onChange setSearch(e.target.value)} /> @@ -104,7 +104,7 @@ export function NodePicker({ filters, nodes, discovered, fileCountFor, onChange ))} {visible.length === 0 && (
  • - {ids.length ? "No node id matches that." : "No nodes yet."} + {ids.length ? "No node ref matches that." : "No nodes yet."}
  • )} diff --git a/dashboard/src/pages/user/dataExplorer/archive.ts b/dashboard/src/pages/user/dataExplorer/archive.ts index f5064291e..428a22dbd 100644 --- a/dashboard/src/pages/user/dataExplorer/archive.ts +++ b/dashboard/src/pages/user/dataExplorer/archive.ts @@ -21,19 +21,19 @@ export type DayStatus = "loading" | "done" | "error"; export interface DayEntry { day: string; /** The node this listing was scoped to, or null for the whole day. */ - nodeId: string | null; + nodeRef: string | null; status: DayStatus; files: ArchiveFile[]; error?: string; } -export function cacheKey(day: string, nodeId: string | null): string { - return `${day}|${nodeId || "*"}`; +export function cacheKey(day: string, nodeRef: string | null): string { + return `${day}|${nodeRef || "*"}`; } export async function fetchDayListing( day: string, - nodeId: string | null, + nodeRef: string | null, signal?: AbortSignal, ): Promise { const files: ArchiveFile[] = []; @@ -43,7 +43,7 @@ export async function fetchDayListing( limit: String(PAGE), offset: String(offset), }); - if (nodeId) params.set("node_id", nodeId); + if (nodeRef) params.set("node_ref", nodeRef); const page = await request<{ files?: unknown[]; total?: number }>( `/api/data/archive?${params}`, diff --git a/dashboard/src/pages/user/dataExplorer/keys.ts b/dashboard/src/pages/user/dataExplorer/keys.ts index 8d61c859b..f2aab7235 100644 --- a/dashboard/src/pages/user/dataExplorer/keys.ts +++ b/dashboard/src/pages/user/dataExplorer/keys.ts @@ -1,6 +1,6 @@ /** * Archive keys and what can be read off one. Keys are Hive-partitioned - * (`year=/month=/day=/node_id=/part-*.parquet`) with older ones carrying bare + * (`year=/month=/day=/node_ref=/part-*.parquet`) with older ones carrying bare * directory names, so every segment is read through `value()`. */ @@ -25,8 +25,8 @@ export interface ArchiveFile { endMs: number; } -/** A partition segment's value: `node_id=ret-abc` and `ret-abc` both give - * `ret-abc`. Everything after the first `=`, which is the derivation the +/** A partition segment's value: `node_ref=ndeabc` and `ndeabc` both give + * `ndeabc`. Everything after the first `=`, which is the derivation the * archive route itself uses, so the page and the route never disagree about * which node a key belongs to. */ const value = (segment: string): string => { diff --git a/dashboard/src/pages/user/dataExplorer/preview.ts b/dashboard/src/pages/user/dataExplorer/preview.ts index b16da3001..636014222 100644 --- a/dashboard/src/pages/user/dataExplorer/preview.ts +++ b/dashboard/src/pages/user/dataExplorer/preview.ts @@ -98,7 +98,7 @@ export function summarisePreview(data: unknown): PreviewSummary | null { const placed = frames.find((f) => num(f.rx_lat) !== null) ?? frames[0]; return { - reportedNode: typeof data.node_id === "string" && data.node_id ? data.node_id : null, + reportedNode: typeof data.node_ref === "string" && data.node_ref ? data.node_ref : null, spanStartMs, spanEndMs, frames: frames.length, diff --git a/dashboard/src/pages/user/dataExplorer/useArchiveScan.ts b/dashboard/src/pages/user/dataExplorer/useArchiveScan.ts index 10de6d224..256989a64 100644 --- a/dashboard/src/pages/user/dataExplorer/useArchiveScan.ts +++ b/dashboard/src/pages/user/dataExplorer/useArchiveScan.ts @@ -59,30 +59,30 @@ export function useArchiveScan(days: string[], nodeSel: Set | null): Sca }, []); const start = useCallback( - (day: string, nodeId: string | null) => { - const key = cacheKey(day, nodeId); + (day: string, nodeRef: string | null) => { + const key = cacheKey(day, nodeRef); // Written before the task runs, so the next pass over `days` sees this // day as claimed rather than queueing it again. - setEntries((prev) => new Map(prev).set(key, { day, nodeId, status: "loading", files: [] })); + setEntries((prev) => new Map(prev).set(key, { day, nodeRef, status: "loading", files: [] })); queue.current.push(async () => { const controller = new AbortController(); aborts.current.add(controller); try { - const files = await fetchDayListing(day, nodeId, controller.signal); + const files = await fetchDayListing(day, nodeRef, controller.signal); for (const f of files) filesRef.current.set(f.key, f); setNodeIds((prev) => { const next = new Set(prev); for (const f of files) next.add(f.node); return next.size === prev.size ? prev : next; }); - setEntries((prev) => new Map(prev).set(key, { day, nodeId, status: "done", files })); + setEntries((prev) => new Map(prev).set(key, { day, nodeRef, status: "done", files })); } catch (e) { if (controller.signal.aborted) return; setEntries((prev) => new Map(prev).set(key, { day, - nodeId, + nodeRef, status: "error", files: [], error: e instanceof Error ? e.message : String(e), diff --git a/dashboard/src/test/OnboardingPage.test.tsx b/dashboard/src/test/OnboardingPage.test.tsx index acfc8c997..368aac2b2 100644 --- a/dashboard/src/test/OnboardingPage.test.tsx +++ b/dashboard/src/test/OnboardingPage.test.tsx @@ -36,10 +36,11 @@ const NODE = { location_privacy_source: "registration", }; -// The Node ref cell of the row for `nodeId`, found by its header so the -// assertion follows the column if the table is reordered. -async function refCell(nodeId: string) { - const row = (await screen.findByText(nodeId)).closest("tr")!; +// The Node ref cell of the node's row, found by its header so the assertion +// follows the column if the table is reordered. The row is found by its +// frequency, the one cell every fixture here fills the same way. +async function refCell() { + const row = (await screen.findByText("195.000 MHz")).closest("tr")!; const column = screen.getAllByRole("columnheader").findIndex((h) => h.textContent === "Node ref"); return row.children[column]; } @@ -54,10 +55,19 @@ describe("OnboardingPage", () => { vi.mocked(api.myNodes).mockResolvedValue([NODE]); renderPage(); - expect(await screen.findByText("ret1a2b3c4d")).toBeInTheDocument(); + expect(await screen.findByText("nde0123456789")).toBeInTheDocument(); expect(screen.getByText("195.000 MHz")).toBeInTheDocument(); }); + it("never shows the node_id, which the route carries only for the owner's own calls", async () => { + vi.mocked(api.myNodes).mockResolvedValue([NODE]); + renderPage(); + + await screen.findByText("nde0123456789"); + expect(screen.queryByText("ret1a2b3c4d")).not.toBeInTheDocument(); + expect(screen.queryByRole("columnheader", { name: "Node ID" })).not.toBeInTheDocument(); + }); + it("shows each node under its node_ref, not its name", async () => { vi.mocked(api.myNodes).mockResolvedValue([NODE]); renderPage(); @@ -72,14 +82,14 @@ describe("OnboardingPage", () => { vi.mocked(api.myNodes).mockResolvedValue([{ ...NODE, node_ref: null }]); renderPage(); - expect(await refCell("ret1a2b3c4d")).toHaveTextContent(/^—/); + expect(await refCell()).toHaveTextContent(/^—/); }); it("keeps the location privacy badge beside the node_ref", async () => { vi.mocked(api.myNodes).mockResolvedValue([{ ...NODE, location_private: true }]); renderPage(); - expect(await refCell("ret1a2b3c4d")).toHaveTextContent("nde0123456789 Private"); + expect(await refCell()).toHaveTextContent("nde0123456789 Private"); }); it("tells an owner of nothing how a node joins their account", async () => { diff --git a/dashboard/src/test/OverviewPage.test.tsx b/dashboard/src/test/OverviewPage.test.tsx new file mode 100644 index 000000000..e9de7009d --- /dev/null +++ b/dashboard/src/test/OverviewPage.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { api } from "../api/client"; +import OverviewPage from "../pages/user/OverviewPage"; + +vi.mock("../api/client", () => ({ + api: { nodes: vi.fn(), analytics: vi.fn(), aircraft: vi.fn(), myNodes: vi.fn() }, +})); +vi.mock("recharts", async (importOriginal) => ({ + ...(await importOriginal()), + ResponsiveContainer: () => null, +})); + +// A private node is on no public listing, so the owner's own copy is the only +// one the page has, and it carries the node_id the page must not show. +const OWNED = { + node_id: "ret1a2b3c4d", + node_ref: "nde0123456789", + name: "nde0123456789", + status: "active", + position_status: "missing_rx", + location_private: true, +}; + +function renderWith(mine: object[]) { + vi.mocked(api.nodes).mockResolvedValue({ nodes: {} }); + vi.mocked(api.analytics).mockResolvedValue({ nodes: {} }); + vi.mocked(api.aircraft).mockResolvedValue({ aircraft: [] }); + vi.mocked(api.myNodes).mockResolvedValue(mine); + render( + + + , + ); +} + +describe("OverviewPage", () => { + beforeEach(() => vi.resetAllMocks()); + + it("names an owned node by its node_ref", async () => { + renderWith([OWNED]); + expect(await screen.findAllByText("nde0123456789")).toHaveLength(2); + expect(screen.queryByText("ret1a2b3c4d")).not.toBeInTheDocument(); + }); + + it("shows a dash, not the node_id, for a node with neither a name nor a ref", async () => { + renderWith([{ ...OWNED, node_ref: null, name: null }]); + await screen.findByText("My Nodes"); + expect(screen.queryByText(/ret1a2b3c4d/)).not.toBeInTheDocument(); + expect(screen.getByRole("table").querySelector("td")).toHaveTextContent(/^—$/); + expect(document.querySelector(".node-name")).toHaveTextContent(/—$/); + }); +}); diff --git a/dashboard/src/test/StoragePage.test.tsx b/dashboard/src/test/StoragePage.test.tsx index 1664d89f9..efaa99658 100644 --- a/dashboard/src/test/StoragePage.test.tsx +++ b/dashboard/src/test/StoragePage.test.tsx @@ -1,7 +1,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../api/client", () => ({ api: { adminStorage: vi.fn(), archive: vi.fn() } })); +vi.mock("../api/client", () => ({ api: { adminStorage: vi.fn(), archive: vi.fn(), adminNodeRefs: vi.fn() } })); import { api } from "../api/client"; import StoragePage from "../pages/admin/StoragePage"; @@ -19,6 +19,18 @@ describe("StoragePage", () => { archive_files: 120, archive_mb: 1, archive_bytes: 1048576, disk: { total_gb: 100, used_gb: 80, free_gb: 20, used_pct: 80 }, }); + vi.mocked(api.adminNodeRefs).mockResolvedValue({}); + }); + + it("names a listed file's node by the id the per-node table uses", async () => { + vi.mocked(api.adminNodeRefs).mockResolvedValue({ nde0123456789: "ret1a2b3c4d" }); + vi.mocked(api.archive).mockResolvedValue({ + files: [{ key: "archive/year=2026/month=09/day=24/node_ref=nde0123456789/part-0.parquet", size_bytes: 1, modified: null }], + total: 1, + }); + render(); + expect(await screen.findByText("ret1a2b3c4d")).toBeInTheDocument(); + expect(screen.queryByText(/node_ref=/)).not.toBeInTheDocument(); }); it("pages the archive listing with the shared pager", async () => { diff --git a/dashboard/src/test/TunnelLinkPage.test.tsx b/dashboard/src/test/TunnelLinkPage.test.tsx index b4ec03345..70d6f2c9d 100644 --- a/dashboard/src/test/TunnelLinkPage.test.tsx +++ b/dashboard/src/test/TunnelLinkPage.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { api } from "../api/client"; @@ -8,7 +8,13 @@ vi.mock("../api/client", () => ({ api: { myNodes: vi.fn() } })); // The owner's own list, which is the one feed that carries node_id: the id // the node's site is named after. -const NODE = { node_id: "ret1a2b3c4d", name: "Rooftop", status: "active", is_synthetic: false }; +const NODE = { + node_id: "ret1a2b3c4d", + node_ref: "nde0123456789", + name: "Rooftop", + status: "active", + is_synthetic: false, +}; describe("TunnelLinkPage", () => { beforeEach(() => { @@ -30,4 +36,20 @@ describe("TunnelLinkPage", () => { expect(await screen.findByText("Rooftop")).toBeInTheDocument(); expect(screen.queryByRole("link", { name: /Rooftop/ })).toBeNull(); }); + + it("labels a node with no name by its node_ref, never the id its site is named after", async () => { + vi.mocked(api.myNodes).mockResolvedValue([{ ...NODE, name: null }]); + render(); + const link = await screen.findByRole("link", { name: /nde0123456789/ }); + expect(link).toHaveAttribute("href", "https://ret1a2b3c4d.retnode.com"); + expect(screen.queryByText("ret1a2b3c4d")).not.toBeInTheDocument(); + }); + + it("finds a node by its node_ref", async () => { + vi.mocked(api.myNodes).mockResolvedValue([NODE, { ...NODE, node_id: "ret9f8e7d6c", node_ref: "nde9876543210", name: "Barn" }]); + render(); + fireEvent.change(await screen.findByPlaceholderText("Search nodes…"), { target: { value: "nde98" } }); + expect(screen.getByText("Barn")).toBeInTheDocument(); + expect(screen.queryByText("Rooftop")).not.toBeInTheDocument(); + }); }); diff --git a/dashboard/src/test/dataExplorer/AvailabilityTimeline.test.tsx b/dashboard/src/test/dataExplorer/AvailabilityTimeline.test.tsx index 1dad1aca4..f044949c5 100644 --- a/dashboard/src/test/dataExplorer/AvailabilityTimeline.test.tsx +++ b/dashboard/src/test/dataExplorer/AvailabilityTimeline.test.tsx @@ -11,7 +11,7 @@ const at = (iso: string) => Date.parse(iso); const FILES: ArchiveFile[] = [ { - key: "year=2026/month=09/day=16/node_id=ret-a/part-0.parquet", + key: "year=2026/month=09/day=16/node_ref=ret-a/part-0.parquet", name: "part-0.parquet", node: "ret-a", day: "2026-09-16", diff --git a/dashboard/src/test/dataExplorer/DataExplorerNodes.test.tsx b/dashboard/src/test/dataExplorer/DataExplorerNodes.test.tsx index c9501c779..ee1adb507 100644 --- a/dashboard/src/test/dataExplorer/DataExplorerNodes.test.tsx +++ b/dashboard/src/test/dataExplorer/DataExplorerNodes.test.tsx @@ -11,7 +11,7 @@ vi.mock("@retina/shared", () => ({ request: requestMock })); const isArchive = (url: string) => url.startsWith("/api/data/archive"); const row = (day: string, node: string) => ({ - key: `year=${day.slice(0, 4)}/month=${day.slice(5, 7)}/day=${day.slice(8, 10)}/node_id=${node}/part-0.parquet`, + key: `year=${day.slice(0, 4)}/month=${day.slice(5, 7)}/day=${day.slice(8, 10)}/node_ref=${node}/part-0.parquet`, size_bytes: 1024, modified: `${day}T06:00:00Z`, }); diff --git a/dashboard/src/test/dataExplorer/DataExplorerPage.test.tsx b/dashboard/src/test/dataExplorer/DataExplorerPage.test.tsx index ad8973167..287efe598 100644 --- a/dashboard/src/test/dataExplorer/DataExplorerPage.test.tsx +++ b/dashboard/src/test/dataExplorer/DataExplorerPage.test.tsx @@ -11,7 +11,7 @@ vi.mock("@retina/shared", () => ({ request: requestMock })); * requested: the page buckets a file by the day in its own key, so a fixture * that always names one day puts every response in one bucket. */ const row = (day: string, node: string, part: string) => ({ - key: `year=${day.slice(0, 4)}/month=${day.slice(5, 7)}/day=${day.slice(8, 10)}/node_id=${node}/${part}.parquet`, + key: `year=${day.slice(0, 4)}/month=${day.slice(5, 7)}/day=${day.slice(8, 10)}/node_ref=${node}/${part}.parquet`, size_bytes: 2 * 1024 * 1024 * 1024, modified: `${day}T06:00:00Z`, }); @@ -82,7 +82,7 @@ describe("DataExplorerPage", () => { it("scopes the listing to a single node named in the query string", async () => { renderAt("?node=ret-a&from=2026-09-17&to=2026-09-17"); await waitFor(() => expect(archiveCalls().length).toBeGreaterThan(0)); - expect(archiveCalls()[0]).toContain("node_id=ret-a"); + expect(archiveCalls()[0]).toContain("node_ref=ret-a"); }); it("writes a filter change back to the query string", async () => { @@ -147,7 +147,7 @@ describe("DataExplorerPage", () => { expect(screen.getByTestId("de-basket-count")).toHaveTextContent("1 file selected"); fireEvent.click(screen.getByRole("button", { name: "Show manifest" })); expect(screen.getByRole("textbox", { name: /manifest/i })).toHaveValue( - `${window.location.origin}/api/data/archive/year=2026/month=09/day=10/node_id=ret-a/part-0.parquet`, + `${window.location.origin}/api/data/archive/year=2026/month=09/day=10/node_ref=ret-a/part-0.parquet`, ); }); @@ -166,7 +166,7 @@ describe("DataExplorerPage", () => { renderAt("?from=2026-09-17&to=2026-09-17"); fireEvent.click(await screen.findByRole("button", { name: "Preview part-0.parquet" })); const dialog = screen.getByRole("dialog", { name: "part-0.parquet" }); - expect(dialog).toHaveTextContent("node_id=ret-a"); + expect(dialog).toHaveTextContent("node_ref=ret-a"); fireEvent.click(screen.getByRole("button", { name: "Add to basket" })); expect(screen.getByTestId("de-basket-count")).toHaveTextContent("1 file selected"); // The manifest opens with the addition, so it is seen to land. diff --git a/dashboard/src/test/dataExplorer/DownloadBasket.test.tsx b/dashboard/src/test/dataExplorer/DownloadBasket.test.tsx index ac60b6406..a250e2d97 100644 --- a/dashboard/src/test/dataExplorer/DownloadBasket.test.tsx +++ b/dashboard/src/test/dataExplorer/DownloadBasket.test.tsx @@ -7,7 +7,7 @@ import type { ArchiveFile } from "../../pages/user/dataExplorer/keys"; import { COPY_FEEDBACK_MS } from "../../pages/user/dataExplorer/useCopy"; const file = (node: string, name: string, size = 1024): ArchiveFile => ({ - key: `year=2026/month=09/day=17/node_id=${node}/${name}`, + key: `year=2026/month=09/day=17/node_ref=${node}/${name}`, name, node, day: "2026-09-17", diff --git a/dashboard/src/test/dataExplorer/FilePreviewDrawer.test.tsx b/dashboard/src/test/dataExplorer/FilePreviewDrawer.test.tsx index 9a5ec9675..465b77325 100644 --- a/dashboard/src/test/dataExplorer/FilePreviewDrawer.test.tsx +++ b/dashboard/src/test/dataExplorer/FilePreviewDrawer.test.tsx @@ -13,7 +13,7 @@ const { requestMock } = vi.hoisted(() => ({ requestMock: vi.fn() })); vi.mock("@retina/shared", () => ({ request: requestMock })); const FILE: ArchiveFile = { - key: "year=2026/month=09/day=17/node_id=ret-a/part-000001.parquet", + key: "year=2026/month=09/day=17/node_ref=ret-a/part-000001.parquet", name: "part-000001.parquet", node: "ret-a", day: "2026-09-17", @@ -66,7 +66,7 @@ function setup(over: Partial[0]> = {}) { beforeEach(() => { requestMock.mockReset(); - requestMock.mockResolvedValue({ node_id: "ret-a", detections: [frame(0), frame(60)] }); + requestMock.mockResolvedValue({ node_ref: "ret-a", detections: [frame(0), frame(60)] }); }); describe("FilePreviewDrawer", () => { @@ -123,7 +123,7 @@ describe("FilePreviewDrawer", () => { it("says how many signatures verified when not all of them did", async () => { requestMock.mockResolvedValue({ - node_id: "ret-a", + node_ref: "ret-a", detections: [frame(0), { ...frame(60), _signature_valid: false }], }); setup(); @@ -144,7 +144,7 @@ describe("FilePreviewDrawer", () => { }); it("says when a file decoded to nothing", async () => { - requestMock.mockResolvedValue({ node_id: "ret-a", detections: [] }); + requestMock.mockResolvedValue({ node_ref: "ret-a", detections: [] }); setup(); fireEvent.click(screen.getByRole("button", { name: /Load preview/ })); await waitFor(() => expect(screen.getByText(/decoded to zero frames/)).toBeInTheDocument()); diff --git a/dashboard/src/test/dataExplorer/ResultsTree.test.tsx b/dashboard/src/test/dataExplorer/ResultsTree.test.tsx index 39469411c..e052969ed 100644 --- a/dashboard/src/test/dataExplorer/ResultsTree.test.tsx +++ b/dashboard/src/test/dataExplorer/ResultsTree.test.tsx @@ -16,7 +16,7 @@ const file = (day: string, node: string, name: string, over: Partial ({ day, nodeId: null, status: "done", files: [] }); +const done = (day: string): DayEntry => ({ day, nodeRef: null, status: "done", files: [] }); type Props = Parameters[0]; @@ -77,7 +77,7 @@ describe("ResultsTree", () => { const { onRetry } = setup({ days: ["2026-09-17"], byDay: new Map(), - entryFor: (day) => ({ day, nodeId: null, status: "error", files: [], error: "HTTP 503" }), + entryFor: (day) => ({ day, nodeRef: null, status: "error", files: [], error: "HTTP 503" }), }); expect(screen.getByText(/HTTP 503/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Retry" })); diff --git a/dashboard/src/test/dataExplorer/archive.test.ts b/dashboard/src/test/dataExplorer/archive.test.ts index ef3772685..ebe0f9739 100644 --- a/dashboard/src/test/dataExplorer/archive.test.ts +++ b/dashboard/src/test/dataExplorer/archive.test.ts @@ -17,14 +17,14 @@ const MODIFIED = "2026-09-17T14:00:00Z"; /** `n` listing rows for one day, all on one node. */ const rows = (n: number, node = "ret-a") => Array.from({ length: n }, (_, i) => ({ - key: `year=2026/month=09/day=17/node_id=${node}/part-${i}.parquet`, + key: `year=2026/month=09/day=17/node_ref=${node}/part-${i}.parquet`, size_bytes: 100, modified: MODIFIED, })); const entry = (over: Partial): DayEntry => ({ day: "2026-09-17", - nodeId: null, + nodeRef: null, status: "done", files: [], ...over, @@ -54,13 +54,13 @@ describe("fetchDayListing", () => { it("scopes to one node when given one", async () => { requestMock.mockResolvedValue({ files: [], total: 0 }); await fetchDayListing("2026-09-17", "ret-a"); - expect(requestMock.mock.calls[0][0]).toContain("node_id=ret-a"); + expect(requestMock.mock.calls[0][0]).toContain("node_ref=ret-a"); }); - it("omits node_id when listing the whole day", async () => { + it("omits node_ref when listing the whole day", async () => { requestMock.mockResolvedValue({ files: [], total: 0 }); await fetchDayListing("2026-09-17", null); - expect(requestMock.mock.calls[0][0]).not.toContain("node_id"); + expect(requestMock.mock.calls[0][0]).not.toContain("node_ref"); }); it("keeps paging while `total` says there is more, even after a short page", async () => { @@ -103,7 +103,7 @@ describe("entryForScope", () => { it("answers from a node-scoped listing only when that node is the whole selection", () => { const cache = new Map([ - [cacheKey("2026-09-17", "ret-a"), entry({ nodeId: "ret-a" })], + [cacheKey("2026-09-17", "ret-a"), entry({ nodeRef: "ret-a" })], ]); expect(entryForScope(cache, "2026-09-17", new Set(["ret-a"]))).not.toBeNull(); expect(entryForScope(cache, "2026-09-17", new Set(["ret-a", "ret-b"]))).toBeNull(); diff --git a/dashboard/src/test/dataExplorer/download.test.ts b/dashboard/src/test/dataExplorer/download.test.ts index a751ee2fa..30b2d4dcc 100644 --- a/dashboard/src/test/dataExplorer/download.test.ts +++ b/dashboard/src/test/dataExplorer/download.test.ts @@ -13,7 +13,7 @@ import type { ArchiveFile } from "../../pages/user/dataExplorer/keys"; const ORIGIN = "https://app.retina.fm"; const file = (node: string, name: string, endHour: number): ArchiveFile => ({ - key: `year=2026/month=09/day=17/node_id=${node}/${name}`, + key: `year=2026/month=09/day=17/node_ref=${node}/${name}`, name, node, day: "2026-09-17", @@ -25,13 +25,13 @@ const file = (node: string, name: string, endHour: number): ArchiveFile => ({ describe("downloadUrl", () => { it("is absolute, on the download route, with the key unencoded", () => { expect(downloadUrl(file("ret-a", "part-000001.parquet", 6), ORIGIN)).toBe( - "https://app.retina.fm/api/data/archive/year=2026/month=09/day=17/node_id=ret-a/part-000001.parquet", + "https://app.retina.fm/api/data/archive/year=2026/month=09/day=17/node_ref=ret-a/part-000001.parquet", ); }); it("defaults to the page's own origin", () => { expect(downloadUrl(file("ret-a", "a.parquet", 6))).toBe( - `${window.location.origin}/api/data/archive/year=2026/month=09/day=17/node_id=ret-a/a.parquet`, + `${window.location.origin}/api/data/archive/year=2026/month=09/day=17/node_ref=ret-a/a.parquet`, ); }); }); @@ -41,7 +41,7 @@ describe("curlLine", () => { // Byte-significant: the route sets no Content-Disposition, so the explicit // -o is what stops a JSON body being written as part-000001.parquet. expect(curlLine(file("ret-a", "part-000001.parquet", 6), ORIGIN)).toBe( - 'curl -sS -o ret-a-part-000001.json "https://app.retina.fm/api/data/archive/year=2026/month=09/day=17/node_id=ret-a/part-000001.parquet"', + 'curl -sS -o ret-a-part-000001.json "https://app.retina.fm/api/data/archive/year=2026/month=09/day=17/node_ref=ret-a/part-000001.parquet"', ); }); diff --git a/dashboard/src/test/dataExplorer/keys.test.ts b/dashboard/src/test/dataExplorer/keys.test.ts index 8bda286ed..dc9e2ed6d 100644 --- a/dashboard/src/test/dataExplorer/keys.test.ts +++ b/dashboard/src/test/dataExplorer/keys.test.ts @@ -7,7 +7,7 @@ const MODIFIED = "2026-09-17T14:00:00Z"; describe("parseKey", () => { it("reads a Hive-partitioned key", () => { const f = parseKey( - "year=2026/month=09/day=17/node_id=ret-abc/part-000.parquet", + "year=2026/month=09/day=17/node_ref=ret-abc/part-000.parquet", 2048, MODIFIED, ); @@ -55,7 +55,7 @@ describe("parseKey", () => { // Six segments, so the fourth is no longer the node. The archive route // reads the second-to-last and this has to match it. const f = parseKey( - "year=2026/month=09/day=17/node_id=ret-abc/extra/part-000.parquet", + "year=2026/month=09/day=17/node_ref=ret-abc/extra/part-000.parquet", 1, MODIFIED, ); @@ -63,7 +63,7 @@ describe("parseKey", () => { }); it("keeps everything after the first equals, as the route does", () => { - const f = parseKey("2026/09/17/node_id=ret=abc/a.json", 1, MODIFIED); + const f = parseKey("2026/09/17/node_ref=ret=abc/a.json", 1, MODIFIED); expect(f.node).toBe("ret=abc"); }); }); diff --git a/dashboard/src/test/dataExplorer/preview.test.ts b/dashboard/src/test/dataExplorer/preview.test.ts index ddb565655..88a9beb5e 100644 --- a/dashboard/src/test/dataExplorer/preview.test.ts +++ b/dashboard/src/test/dataExplorer/preview.test.ts @@ -28,7 +28,7 @@ const frame = (offsetS: number, over: Record = {}) => ({ describe("summarisePreview", () => { it("reads the span, the counts and the geometry off the frames", () => { const summary = summarisePreview({ - node_id: "ret-a", + node_ref: "ret-a", detections: [frame(0), frame(60), frame(120)], }); expect(summary).toMatchObject({ @@ -49,8 +49,8 @@ describe("summarisePreview", () => { }); it("is null for a file that decoded to no frames", () => { - expect(summarisePreview({ node_id: "ret-a", detections: [] })).toBeNull(); - expect(summarisePreview({ node_id: "ret-a" })).toBeNull(); + expect(summarisePreview({ node_ref: "ret-a", detections: [] })).toBeNull(); + expect(summarisePreview({ node_ref: "ret-a" })).toBeNull(); expect(summarisePreview(null)).toBeNull(); expect(summarisePreview("nope")).toBeNull(); }); diff --git a/dashboard/src/test/dataExplorer/timeline.test.ts b/dashboard/src/test/dataExplorer/timeline.test.ts index 96080bcb9..26c8f1198 100644 --- a/dashboard/src/test/dataExplorer/timeline.test.ts +++ b/dashboard/src/test/dataExplorer/timeline.test.ts @@ -20,7 +20,7 @@ const at = (iso: string) => Date.parse(iso); function file(node: string, endIso: string): ArchiveFile { const endMs = at(endIso); return { - key: `year=2026/month=09/day=17/node_id=${node}/${endIso}.parquet`, + key: `year=2026/month=09/day=17/node_ref=${node}/${endIso}.parquet`, name: `${endIso}.parquet`, node, day: endIso.slice(0, 10), diff --git a/dashboard/src/test/kvTable.test.tsx b/dashboard/src/test/kvTable.test.tsx index 28be8564b..8094bbeb8 100644 --- a/dashboard/src/test/kvTable.test.tsx +++ b/dashboard/src/test/kvTable.test.tsx @@ -6,7 +6,7 @@ import rules from "../App.css?raw"; vi.mock("../api/client", () => ({ api: { nodeAnalytics: vi.fn(), nodes: vi.fn(), myNodes: vi.fn(), - adminStorage: vi.fn(), archive: vi.fn(), + adminStorage: vi.fn(), archive: vi.fn(), adminNodeRefs: vi.fn(), }, })); vi.mock("recharts", async (importOriginal) => ({ @@ -77,6 +77,7 @@ describe("key/value tables", () => { write_rate: { total_mb_per_day: 1, days_until_full: 400 }, }); vi.mocked(api.archive).mockResolvedValue({ files: [], total: 0 }); + vi.mocked(api.adminNodeRefs).mockResolvedValue({}); const { container } = render(); await screen.findByText("Total Write Rate"); const tables = kvTables(container); diff --git a/docs/architecture.md b/docs/architecture.md index d3f94fe56..de9dba53a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,10 +24,11 @@ All of them are one SPA, the console (`dashboard/`), served at the root. holding those routes alone, so nothing on screen leads to the login card. - **data explorer** (`/data`) — the public detection archive browser, one of the console's public routes. Old links to the standalone page it replaced keep - their filters through the redirect. It reads `/api/data/archive`, which is unauthenticated - and drops private nodes for every caller, so a signed-in owner sees their own - private nodes' files only once an authenticated listing exists; the public - route must not grow one. + their filters through the redirect. It reads `/api/data/archive`, which is + unauthenticated, names each file by its node's `node_ref` (the store is keyed + on `node_id` and the route translates both ways) and drops private nodes for + every caller, so a signed-in owner sees their own private nodes' files only + once an authenticated listing exists; the public route must not grow one. - **admin** (`admin.retina.fm`) — the dashboard bundle again, built at a root and serving the admin route table, which `dashboard/src/utils/surface.ts` selects from the hostname. It keeps a name of its own because a Cloudflare Access