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:
n30nex authored and GitHub committed 2026-09-19 22:58:52 -04:00
1 parent 88e2c0f319
commit 3178713bdb
8 files changed
+61 -11

No files matched your search

+11
View File
@@ -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>
);
}
+3 -1
View File
@@ -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}>
+11 -6
View File
@@ -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) => (
+4 -2
View File
@@ -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]!;
+2
View File
@@ -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 {
+2
View File
@@ -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")]);
+16 -2
View File
@@ -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", () => {