@@ -163,6 +170,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
)}
+ {node.meshmapper &&
{node.minFirmwareVersion && }
diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts
index 40886a6..b01ca2a 100644
--- a/src/features/nodes/types.ts
+++ b/src/features/nodes/types.ts
@@ -39,6 +39,46 @@ export interface Node extends NodeSummary {
clockDriftSeconds?: number;
clockOutOfSync?: boolean;
clockCheckedAt?: number; // epoch ms
+ // MeshMapper's record for this repeater; node detail only, absent when the import is off or unmatched.
+ meshmapper?: MeshMapperRepeater;
+}
+
+// Imported from MeshMapper. Times are epoch ms; every string is admin-entered free text.
+export interface MeshMapperRepeater {
+ iata?: string;
+ fetchedAt: number;
+ site: MeshMapperSite;
+ carpeater: boolean;
+ admins: string[];
+ scopes: MeshMapperScope[];
+ scopesCheckedAt?: number; // set with empty scopes = it answered with none; absent = never asked
+ defaultScope?: MeshMapperDefaultScope; // separate from Node.defaultScope (Beacon's own match)
+}
+
+export interface MeshMapperSite {
+ power?: string;
+ hardware?: string;
+ antenna?: string;
+ heightM?: number; // above ground
+ heightAslM?: number;
+ powerSource?: "mains" | "solar" | "battery" | "poe";
+ notes?: string;
+}
+
+export interface MeshMapperScope {
+ scope: string;
+ observedAt?: number;
+ observedPackets?: number;
+ proof?: "multibyte" | "floor";
+ reportedAt?: number;
+ uploadedAt?: number;
+}
+
+export interface MeshMapperDefaultScope {
+ name?: string; // only when state is "named"
+ state: "named" | "unknown" | "none";
+ updatedAt?: number;
+ source?: "advert" | "reported";
}
// First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination).
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index a945368..2a858a8 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -282,7 +282,42 @@
"noRecentPackets": "No recent packets",
"observations_one": "{{formatted}} obs",
"observations_other": "{{formatted}} obs",
- "hops": "Hops"
+ "hops": "Hops",
+ "hardware": "Hardware",
+ "power": "Power",
+ "antenna": "Antenna",
+ "powerSource": "Power source",
+ "powerSources": {
+ "mains": "Mains",
+ "solar": "Solar",
+ "battery": "Battery",
+ "poe": "PoE"
+ },
+ "height": "Height",
+ "heightAgl": "{{value}} m AGL",
+ "heightAsl": "{{value}} m ASL",
+ "admins": "Admins",
+ "mmDefaultScope": "Default scope",
+ "scopeState": {
+ "unknown": "Unknown",
+ "none": "None"
+ },
+ "scopeSource": {
+ "advert": "advert",
+ "reported": "reported"
+ },
+ "confirmedScopes": "Confirmed scopes",
+ "noConfirmedScopes": "None confirmed",
+ "observedPackets_one": "observed {{count}} packet",
+ "observedPackets_other": "observed {{count}} packets",
+ "observed": "observed",
+ "reported": "reported",
+ "uploaded": "uploaded",
+ "meshmapper": "MeshMapper",
+ "fromMeshMapper": "From MeshMapper",
+ "updated": "updated",
+ "carpeater": "CARpeater",
+ "carpeaterHint": "Mobile repeater in a vehicle: its position moves."
},
"nodes": {
"lastHeard": "last heard {{ago}}",
diff --git a/tests/components/Tooltip.test.tsx b/tests/components/Tooltip.test.tsx
index e9b26d3..8951990 100644
--- a/tests/components/Tooltip.test.tsx
+++ b/tests/components/Tooltip.test.tsx
@@ -72,3 +72,19 @@ describe("Tooltip (mobile)", () => {
expect(onParentClick).not.toHaveBeenCalled();
});
});
+
+describe("Tooltip focusable", () => {
+ it("is a tab stop described by its label for keyboard and screen-reader users", () => {
+ setMobile(false);
+ render(target);
+ const trigger = screen.getByText("target").parentElement!;
+ expect(trigger).toHaveAttribute("tabindex", "0");
+ expect(trigger).toHaveAccessibleDescription("tip text");
+ });
+
+ it("is not a tab stop by default", () => {
+ setMobile(false);
+ render(target);
+ expect(screen.getByText("target").parentElement).not.toHaveAttribute("tabindex");
+ });
+});
diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx
index 6c9424a..664e773 100644
--- a/tests/features/nodes/NodeDetailPanel.test.tsx
+++ b/tests/features/nodes/NodeDetailPanel.test.tsx
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel";
import i18n from "../../../src/i18n";
import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client";
-import type { Node, NodeNeighbor } from "../../../src/features/nodes/types";
+import type { MeshMapperRepeater, Node, NodeNeighbor } from "../../../src/features/nodes/types";
vi.mock("../../../src/api/client", () => ({
getNode: vi.fn(),
@@ -45,8 +45,8 @@ function renderPanel(onViewNode = vi.fn()) {
const wrapper = ({ children }: { children: ReactNode }) => (
{children}
);
- render(, { wrapper });
- return { onViewNode };
+ const result = render(, { wrapper });
+ return { ...result, onViewNode };
}
beforeEach(() => {
@@ -227,3 +227,117 @@ describe("NodeDetailPanel in French", () => {
expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument();
});
});
+
+describe("NodeDetailPanel MeshMapper", () => {
+ const meshmapper: MeshMapperRepeater = {
+ iata: "YOW",
+ fetchedAt: Date.now() - 60_000,
+ site: {
+ power: "1W", hardware: "RAK4631", antenna: "5.8 dBi fibreglass",
+ heightM: 12, heightAslM: 341.6, powerSource: "solar",
+ notes: "On the fire tower, east face",
+ },
+ carpeater: false,
+ admins: ["MrAlders0n"],
+ scopes: [
+ { scope: "yow", observedAt: Date.now() - 3_600_000, observedPackets: 37, proof: "multibyte", reportedAt: Date.now() - 7_200_000 },
+ ],
+ scopesCheckedAt: Date.now() - 7_200_000,
+ defaultScope: { name: "#yow", state: "named", updatedAt: Date.now() - 180_000, source: "advert" },
+ };
+ const empty: MeshMapperRepeater = { fetchedAt: 1, site: {}, carpeater: false, admins: [], scopes: [] };
+
+ it("shows MeshMapper's site details, admins and scopes", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper });
+ renderPanel();
+
+ expect(await screen.findByText("MeshMapper")).toBeInTheDocument();
+ expect(screen.getByText("RAK4631")).toBeInTheDocument();
+ expect(screen.getByText("1W")).toBeInTheDocument();
+ expect(screen.getByText("5.8 dBi fibreglass")).toBeInTheDocument();
+ expect(screen.getByText("Solar")).toBeInTheDocument();
+ expect(screen.getByText("12 m AGL · 341.6 m ASL")).toBeInTheDocument();
+ expect(screen.getByText("MrAlders0n")).toBeInTheDocument();
+ expect(screen.getByText("#yow")).toBeInTheDocument();
+ expect(screen.getByText("yow")).toBeInTheDocument();
+ expect(screen.getByText("observed 37 packets · reported")).toBeInTheDocument();
+ expect(screen.getByText("On the fire tower, east face")).toBeInTheDocument();
+ expect(screen.getByText(/From MeshMapper/)).toBeInTheDocument();
+ expect(screen.queryByText("CARpeater")).not.toBeInTheDocument();
+ });
+
+ it("never shows MeshMapper's proof type", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", observedPackets: 3, proof: "floor" }] } });
+ renderPanel();
+ expect(await screen.findByText("observed 3 packets")).toBeInTheDocument();
+ expect(screen.queryByText(/floor/)).not.toBeInTheDocument();
+ });
+
+ it("describes a scope confirmed only by report or upload", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", reportedAt: 1, uploadedAt: 2 }] } });
+ renderPanel();
+ expect(await screen.findByText("reported · uploaded")).toBeInTheDocument();
+ });
+
+ it("shows height above ground alone when sea level is missing", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12 } } });
+ renderPanel();
+ expect(await screen.findByText("12 m AGL")).toBeInTheDocument();
+ });
+
+ it("formats heights in the reader's locale", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12.25, heightAslM: 341.6 } } });
+ await act(() => i18n.changeLanguage("fr"));
+ renderPanel();
+ expect(await screen.findByText("12,3 m AGL · 341,6 m ASL")).toBeInTheDocument();
+ await act(() => i18n.changeLanguage("en"));
+ });
+
+ it("is hidden when the node has no MeshMapper record", async () => {
+ renderPanel();
+ await screen.findByText("Self Node");
+ expect(screen.queryByText("MeshMapper")).not.toBeInTheDocument();
+ expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument();
+ });
+
+ it.each([
+ ["nothing set", empty],
+ ["only a CARpeater flag", { ...empty, carpeater: true }],
+ ["only a sea-level height", { ...empty, site: { heightAslM: 341.6 } }],
+ ])("is hidden when the record has %s", async (_, mm) => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: mm });
+ renderPanel();
+ await screen.findByText("Self Node");
+ expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument();
+ });
+
+ it("shows an unknown default scope and an answered-but-empty scope list", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopesCheckedAt: 1, defaultScope: { state: "unknown" } } });
+ renderPanel();
+ expect(await screen.findByText("Unknown")).toBeInTheDocument();
+ expect(screen.getByText("None confirmed")).toBeInTheDocument();
+ });
+
+ it("shows a default scope of none", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, defaultScope: { state: "none" } } });
+ renderPanel();
+ expect(await screen.findByText("None")).toBeInTheDocument();
+ });
+
+ it("badges a CARpeater next to the name, reachable by keyboard", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, carpeater: true } });
+ renderPanel();
+ const badge = await screen.findByText("CARpeater");
+ expect(badge.className).not.toContain("uppercase");
+ const trigger = badge.parentElement!;
+ expect(trigger).toHaveAttribute("tabindex", "0");
+ expect(trigger).toHaveAccessibleDescription("Mobile repeater in a vehicle: its position moves.");
+ });
+
+ it("renders admin-entered markup as plain text", async () => {
+ mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, site: { notes: "bold" } } });
+ const { container } = renderPanel();
+ expect(await screen.findByText("bold")).toBeInTheDocument();
+ expect(container.querySelector("b")).toBeNull();
+ });
+});