mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 16:31:21 +00:00
feat(nodes): display the foreign-repeater indication (#49)
* feat(nodes): display the server foreign-repeater indication * style(ws): align node update event declaration
This commit is contained in:
1 parent
88e2c0f319
commit
3178713bdb
8 files changed
+61
-11
No files matched your search
@@ -0,0 +1,11 @@
|
||||
import { Badge } from "../../components/Badge";
|
||||
import { Tooltip } from "../../components/Tooltip";
|
||||
|
||||
export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) {
|
||||
if (possiblyForeign !== true) return null;
|
||||
return (
|
||||
<Tooltip label="Reported position is outside this server's configured local borders.">
|
||||
<Badge variant="default">Possibly foreign</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { IataChip } from "../../components/IataChip";
|
||||
import { formatHex, formatSnr, snrLevel, formatRadio, formatClockDrift, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import type { NodeObservation, NodeNeighbor } from "./types";
|
||||
import { ForeignNodeBadge } from "./ForeignNodeBadge";
|
||||
|
||||
function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) {
|
||||
return (
|
||||
@@ -113,11 +114,12 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
|
||||
{node && (
|
||||
<>
|
||||
<Section title="Summary" first>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className={`font-mono text-xs font-semibold tracking-wider ${node.name ? "text-primary" : "text-text-dim italic"}`}>
|
||||
{node.name ?? formatHex(node.id)}
|
||||
</span>
|
||||
<Badge variant="default">{node.nodeTypeName}</Badge>
|
||||
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={node.publicKey}>
|
||||
|
||||
@@ -16,6 +16,7 @@ import { LoadingPill } from "../../components/LoadingPill";
|
||||
import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar";
|
||||
import { nodeSearchParams } from "./node-search";
|
||||
import { patchNodeSummary } from "./node-updates";
|
||||
import { ForeignNodeBadge } from "./ForeignNodeBadge";
|
||||
import type { NodeSummary } from "./types";
|
||||
import type { CursorPage } from "../../types/api";
|
||||
import type { WsManager } from "../../api/ws-manager";
|
||||
@@ -44,12 +45,15 @@ const COLUMNS: Column<NodeSummary>[] = [
|
||||
header: "Type",
|
||||
sortValue: (node) => node.nodeTypeName,
|
||||
cell: (node) => (
|
||||
<Badge variant="default">
|
||||
{node.isObserver && (
|
||||
<Tooltip label="Observer" className="mr-1"><ObserverIcon /></Tooltip>
|
||||
)}
|
||||
{node.nodeTypeName}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge variant="default">
|
||||
{node.isObserver && (
|
||||
<Tooltip label="Observer" className="mr-1"><ObserverIcon /></Tooltip>
|
||||
)}
|
||||
{node.nodeTypeName}
|
||||
</Badge>
|
||||
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -114,6 +118,7 @@ function renderNodeCard(node: NodeSummary) {
|
||||
{location && <span>· {location}</span>}
|
||||
{node.knownNeighborCount > 0 && <span>· {node.knownNeighborCount.toLocaleString()} neighbors</span>}
|
||||
</div>
|
||||
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
|
||||
{node.iatas && node.iatas.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{node.iatas.map((entry) => (
|
||||
|
||||
@@ -20,11 +20,12 @@ export function patchNodeSummary(
|
||||
const name = data.name || prev.name;
|
||||
const lat = data.lat ?? prev.lat;
|
||||
const lng = data.lng ?? prev.lng;
|
||||
const possiblyForeign = data.possiblyForeign === undefined ? prev.possiblyForeign : data.possiblyForeign ?? undefined;
|
||||
// a re-advert that re-sends the same values must keep the SAME ref so patchInfinitePages no-ops
|
||||
// (otherwise an unchanged node would trigger a full map FeatureCollection rebuild + setData)
|
||||
if (name === prev.name && lat === prev.lat && lng === prev.lng) return list;
|
||||
if (name === prev.name && lat === prev.lat && lng === prev.lng && possiblyForeign === prev.possiblyForeign) return list;
|
||||
const updated = [...list];
|
||||
updated[idx] = { ...prev, name, lat, lng };
|
||||
updated[idx] = { ...prev, name, lat, lng, possiblyForeign };
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -54,6 +55,7 @@ export function upsertNodePages(
|
||||
// meeting for the first time has none resolved yet, so start at 0 until a reload fills it in
|
||||
knownNeighborCount: 0,
|
||||
isObserver: data.isObserver,
|
||||
possiblyForeign: data.possiblyForeign ?? undefined,
|
||||
};
|
||||
const pages = [...old.pages];
|
||||
const last = pages[pages.length - 1]!;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface NodeSummary {
|
||||
// map's observer-pip marker variant; observerId, when present, links to that observer's detail.
|
||||
isObserver?: boolean;
|
||||
observerId?: string;
|
||||
// Server verdict against its configured local border union; absence is unknown/disabled.
|
||||
possiblyForeign?: boolean;
|
||||
}
|
||||
|
||||
export interface Node extends NodeSummary {
|
||||
|
||||
@@ -101,6 +101,8 @@ export interface WsNodeUpdate {
|
||||
name: string;
|
||||
nodeType: number;
|
||||
nodeTypeName: string;
|
||||
// Omitted leaves the prior verdict unchanged; null clears an unknown/reset position.
|
||||
possiblyForeign?: boolean | null;
|
||||
iata: string;
|
||||
// decimal degrees, same as REST /nodes (api/nodes.go serializes *float64 degrees to both)
|
||||
lat?: number;
|
||||
|
||||
@@ -58,6 +58,18 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("NodeDetailPanel neighbors", () => {
|
||||
it("shows the server's possibly-foreign indication", async () => {
|
||||
mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true });
|
||||
renderPanel();
|
||||
expect(await screen.findByText("Possibly foreign")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([false, undefined])("does not label unflagged nodes as foreign (%s)", async (possiblyForeign) => {
|
||||
mockGetNode.mockResolvedValue({ ...node, possiblyForeign });
|
||||
renderPanel();
|
||||
await screen.findByText("Self Node");
|
||||
expect(screen.queryByText("Possibly foreign")).not.toBeInTheDocument();
|
||||
});
|
||||
it("lists each neighbor's name in a Neighbors section", async () => {
|
||||
mockGetNodeNeighbors.mockResolvedValue([neighbor("n-1", "Neighbor A"), neighbor("n-2", "Neighbor B")]);
|
||||
|
||||
|
||||
@@ -24,6 +24,20 @@ function update(overrides: Partial<WsNodeUpdate["data"]>): WsNodeUpdate["data"]
|
||||
}
|
||||
|
||||
describe("patchNodeSummary", () => {
|
||||
it("applies foreign status changes without moving coordinates and clears explicit null", () => {
|
||||
const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: true })];
|
||||
const local = patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: false }))!;
|
||||
expect(local[0]!.possiblyForeign).toBe(false);
|
||||
expect(local).not.toBe(list);
|
||||
const unknown = patchNodeSummary(local, update({ nodeId: "a", name: "Keep", lat: 0, lng: 0, possiblyForeign: null }))!;
|
||||
expect(unknown[0]!.possiblyForeign).toBeUndefined();
|
||||
expect(list[0]!.possiblyForeign).toBe(true);
|
||||
});
|
||||
|
||||
it("retains the flag and list identity when an advert omits the position/classification", () => {
|
||||
const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: true })];
|
||||
expect(patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: undefined, lng: undefined }))).toBe(list);
|
||||
});
|
||||
it("returns the list unchanged (same ref) when it is undefined", () => {
|
||||
expect(patchNodeSummary(undefined, update({}))).toBeUndefined();
|
||||
});
|
||||
@@ -84,12 +98,12 @@ describe("upsertNodePages", () => {
|
||||
const old = pages([node({ id: "a" })], [node({ id: "b" })]);
|
||||
const out = upsertNodePages(
|
||||
old,
|
||||
update({ nodeId: "new1", name: "Fresh", nodeTypeName: "companion", publicKey: "pk2", lat: 50.5, lng: -100.25, isObserver: false, iatas: [] }),
|
||||
update({ nodeId: "new1", name: "Fresh", nodeTypeName: "repeater", publicKey: "pk2", lat: 50.5, lng: -100.25, isObserver: false, iatas: [], possiblyForeign: true }),
|
||||
)!;
|
||||
expect(out).not.toBe(old);
|
||||
expect(out.pages[0]).toBe(old.pages[0]); // earlier pages keep their refs
|
||||
expect(out.pages[1]!.items.map((n) => n.id)).toEqual(["b", "new1"]);
|
||||
expect(out.pages[1]!.items[1]).toMatchObject({ name: "Fresh", lat: 50.5, lng: -100.25 });
|
||||
expect(out.pages[1]!.items[1]).toMatchObject({ name: "Fresh", lat: 50.5, lng: -100.25, possiblyForeign: true });
|
||||
});
|
||||
|
||||
it("keeps the same ref for a re-advert that changes nothing", () => {
|
||||
|
||||
Reference in new issue
Block a user