Merge pull request #160 from MeshCore-Beacon/dev

Release v2.0.5
This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-12 13:32:44 -04:00
commit ac5e9e62aa
11 files changed
+322 -12

No files matched your search

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.0.4",
"version": "2.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.0.4",
"version": "2.0.5",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nazka/map-gl-js-spiderfy": "^2.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "beacon-web",
"private": true,
"version": "2.0.4",
"version": "2.0.5",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import { VARIANT_CLASSES, type BadgeVariant } from "./badge-utils";
export function Badge({ variant, children }: { variant: BadgeVariant; children: ReactNode }) {
export function Badge({ variant, children, normalCase = false }: { variant: BadgeVariant; children: ReactNode; normalCase?: boolean }) {
return (
<span
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase ${VARIANT_CLASSES[variant]}`}
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider ${normalCase ? "" : "uppercase "}${VARIANT_CLASSES[variant]}`}
>
{children}
</span>
+7 -2
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { useState, useRef, useEffect, useId, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useHasHover } from "../hooks/useMediaQuery";
// Portals to <body> with fixed positioning so overflow parents (the data tables) can't clip it.
// Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap.
export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) {
// `focusable` makes a non-interactive trigger a tab stop whose label is exposed to screen readers.
export function Tooltip({ label, children, className = "", wrap = false, focusable = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean; focusable?: boolean }) {
const hasHover = useHasHover();
const descId = useId();
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
const [anchor, setAnchor] = useState<DOMRect | null>(null);
@@ -62,9 +64,12 @@ export function Tooltip({ label, children, className = "", wrap = false }: { lab
onClick={hasHover ? undefined : toggle}
onFocus={hasHover ? show : undefined}
onBlur={hasHover ? hide : undefined}
tabIndex={focusable ? 0 : undefined}
aria-describedby={focusable ? descId : undefined}
className={`inline-flex ${className}`}
>
{children}
{focusable && <span id={descId} hidden>{label}</span>}
{anchor &&
createPortal(
<span
+1 -1
View File
@@ -6,7 +6,7 @@ export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolea
const { t } = useTranslation();
if (possiblyForeign !== true) return null;
return (
<Tooltip label={t("nodes.possiblyForeignHint")}>
<Tooltip label={t("nodes.possiblyForeignHint")} focusable>
<Badge variant="default">{t("nodes.possiblyForeign")}</Badge>
</Tooltip>
);
+91
View File
@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Section, Field } from "../../components/DetailPanel";
import { IataChip } from "../../components/IataChip";
import { Timestamp } from "../../components/Timestamp";
import type { MeshMapperRepeater, MeshMapperScope } from "./types";
function ScopeRow({ scope }: { scope: MeshMapperScope }) {
const { t } = useTranslation();
const how = [
scope.observedPackets != null ? t("nodeDetail.observedPackets", { count: scope.observedPackets }) : scope.observedAt != null ? t("nodeDetail.observed") : null,
scope.reportedAt != null ? t("nodeDetail.reported") : null,
scope.uploadedAt != null ? t("nodeDetail.uploaded") : null,
].filter(Boolean).join(" · ");
const latest = Math.max(scope.observedAt ?? 0, scope.reportedAt ?? 0, scope.uploadedAt ?? 0);
return (
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-text-normal truncate min-w-0" title={scope.scope}>{scope.scope}</span>
{how && <span className="text-text-muted text-[11px] truncate min-w-0">{how}</span>}
{latest > 0 && <Timestamp value={latest} className="ml-auto shrink-0 text-text-dim text-[11px]" />}
</div>
);
}
export function MeshMapperSection({ mm }: { mm: MeshMapperRepeater }) {
const { t, i18n } = useTranslation();
const { site, defaultScope } = mm;
const hasSite = Boolean(site.hardware || site.power || site.powerSource || site.antenna || site.heightM != null || site.notes);
const hasScopes = mm.scopes.length > 0 || mm.scopesCheckedAt != null;
// carpeater alone is covered by the badge next to the node name
if (!hasSite && !hasScopes && mm.admins.length === 0 && !defaultScope) return null;
const num = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 });
const height = site.heightM != null
? [t("nodeDetail.heightAgl", { value: num.format(site.heightM) }), site.heightAslM != null ? t("nodeDetail.heightAsl", { value: num.format(site.heightAslM) }) : null].filter(Boolean).join(" · ")
: null;
return (
<Section title={t("nodeDetail.meshmapper")}>
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{(site.hardware || site.power || site.powerSource) && (
<div className="flex flex-wrap gap-x-4 gap-y-0.5">
{site.hardware && <Field label={t("nodeDetail.hardware")} value={site.hardware} />}
{site.power && <Field label={t("nodeDetail.power")} value={site.power} />}
{site.powerSource && <Field label={t("nodeDetail.powerSource")} value={t(`nodeDetail.powerSources.${site.powerSource}`, { defaultValue: site.powerSource })} />}
</div>
)}
{site.antenna && <Field label={t("nodeDetail.antenna")} value={site.antenna} />}
{height && <Field label={t("nodeDetail.height")} value={height} />}
{mm.admins.length > 0 && <Field label={t("nodeDetail.admins")} value={mm.admins.join(", ")} />}
{defaultScope && (
<Field
label={t("nodeDetail.mmDefaultScope")}
value={
<>
{defaultScope.state === "named" && defaultScope.name ? defaultScope.name : t(`nodeDetail.scopeState.${defaultScope.state === "none" ? "none" : "unknown"}`)}
{(defaultScope.source || defaultScope.updatedAt != null) && (
<span className="text-text-dim text-[11px]">
{" "}({defaultScope.source && t(`nodeDetail.scopeSource.${defaultScope.source}`, { defaultValue: defaultScope.source })}
{defaultScope.source && defaultScope.updatedAt != null && ", "}
{defaultScope.updatedAt != null && <Timestamp value={defaultScope.updatedAt} />})
</span>
)}
</>
}
/>
)}
{hasScopes && (
<div>
<span className="text-text-dim">{t("nodeDetail.confirmedScopes")}</span>
<div className="pl-3 flex flex-col gap-0.5">
{mm.scopes.length > 0 ? (
mm.scopes.map((s, i) => <ScopeRow key={`${s.scope}-${i}`} scope={s} />)
) : (
<div className="flex items-baseline gap-2">
<span className="text-text-muted">{t("nodeDetail.noConfirmedScopes")}</span>
<Timestamp value={mm.scopesCheckedAt!} className="ml-auto text-text-dim text-[11px]" />
</div>
)}
</div>
</div>
)}
{site.notes && <p className="mt-1 text-text-muted whitespace-pre-line break-words">{site.notes}</p>}
</div>
<div className="mt-2 flex items-center gap-1.5 font-mono text-[11px] text-text-dim">
<span>{t("nodeDetail.fromMeshMapper")}</span>
{mm.iata && <IataChip>{mm.iata}</IataChip>}
<span className="ml-auto">{t("nodeDetail.updated")} <Timestamp value={mm.fetchedAt} /></span>
</div>
</Section>
);
}
+9
View File
@@ -11,6 +11,8 @@ import { Timestamp } from "../../components/Timestamp";
import { hasMapLocation } from "../map/location";
import type { NodeObservation, NodeNeighbor } from "./types";
import { ForeignNodeBadge } from "./ForeignNodeBadge";
import { MeshMapperSection } from "./MeshMapperSection";
import { Tooltip } from "../../components/Tooltip";
function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) {
const { t } = useTranslation();
@@ -126,6 +128,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</span>
<Badge variant="default">{node.nodeTypeName}</Badge>
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
{node.meshmapper?.carpeater && (
<Tooltip label={t("nodeDetail.carpeaterHint")} focusable>
<Badge variant="trace" normalCase>{t("nodeDetail.carpeater")}</Badge>
</Tooltip>
)}
</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}>
@@ -163,6 +170,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</Section>
)}
{node.meshmapper && <MeshMapperSection mm={node.meshmapper} />}
<Section title={t("nodeDetail.capabilities")}>
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{node.minFirmwareVersion && <Field label={t("nodeDetail.minFirmware")} value={node.minFirmwareVersion} />}
+40
View File
@@ -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).
+36 -1
View File
@@ -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}}",
+16
View File
@@ -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(<Tooltip label="tip text" focusable><span>target</span></Tooltip>);
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(<Tooltip label="tip text"><span>target</span></Tooltip>);
expect(screen.getByText("target").parentElement).not.toHaveAttribute("tabindex");
});
});
+117 -3
View File
@@ -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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
render(<NodeDetailPanel nodeId="node-self" onClose={vi.fn()} onViewObserver={vi.fn()} onViewNode={onViewNode} />, { wrapper });
return { onViewNode };
const result = render(<NodeDetailPanel nodeId="node-self" onClose={vi.fn()} onViewObserver={vi.fn()} onViewNode={onViewNode} />, { 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: "<b>bold</b>" } } });
const { container } = renderPanel();
expect(await screen.findByText("<b>bold</b>")).toBeInTheDocument();
expect(container.querySelector("b")).toBeNull();
});
});