mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-02 09:03:44 +00:00
* stats: node-types donut, multi-IATA filtering, preset split - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption replaces the "Coming soon" card with a live donut - stats endpoints take iatas[] instead of a single iata, so multi-IATA regions filter instead of falling back to all (drops useStatsIata) - radio presets keep the node/observer split via stacked presetBarsOption - drop client-side telemetry ms-normalization (backend now emits epoch ms on both paths); charts pick delta-vs-raw counters off the response interval - MeshTab: range-driven charts lead, all-time charts follow; KPI window from overview.windowHours instead of a hardcoded 24h - ci: docker-publish builds a :dev image on dev-branch pushes * feat: traces vs pings feat: SNR for trace path in list feat: known niehgbour count * fix: github workflow, post images publically * build(deps): bump undici from 7.27.2 to 7.28.0 (#9) Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 7.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v7.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * feat: discover renderers, neighbor map lines, live packet flow - Discover packets decoded: DISCOVER_REQ/DISCOVER_RESP render real fields (type-filter roles, tag, node-to-node request SNR) instead of a raw dump. - Neighbor lines on the map: On/Selected/Off control draws links between nodes and their known neighbors. - Live packet flow: a play button animates packets hop-to-hop between repeaters in real time (off by default). * map: live mode dims nodes, flashes each packet's route * map: shoot a comet along each packet's route in live mode * map: rework live mode — packet dot, dashed trail, per-hop flash * map: fade live-mode clusters more so dense ones stay see-through * map: persist clustering + node-type, default neighbor lines to selected * map: fade live-mode node markers more so overlaps stay see-through * map: fade the live-mode node flash in sync with its packet trail * nodes: let the mobile detail panel minimize without deselecting * map: dim other nodes to spotlight a selected node and its neighbours * map: colour a selected node's neighbour lines by obs count and freshness * Add a neighbour graph under the renamed Analytics tab * Focus the neighbour graph into an ego view on click * Wrap the analytics sub-header so the range stays on-screen on mobile * Use a dropdown for the analytics sections on mobile * Gate the neighbour graph to a region and size labels by busyness * Add a name search that spotlights matches and dims the rest of the mesh * Add a copy button beside node and observer public keys * Add a copy link to node and observer detail panels Deep-linked ?node/?observer selections now survive load: the region reset watches the raw selection instead of the async-resolved regionKey, so slug expansion no longer wipes a restored panel. * Add .env options for hiding tabs, MeshMapper themes, app name and a GitHub link * Make VITE_ENABLED_THEMES an exclusive theme allowlist * Add a .env option to skip the load splash * Keep the packets list static while scrolled down instead of jumping on new packets * Add Map deep-links for camera and settings with a copy-link button * Filter packet history on the server when a single type, route or scope is selected * Show loading feedback while packet history loads * build(deps): bump echarts from 5.5.1 to 6.1.0 (#14) * Fix: github workflow + stats tab + add traces vs pings (#10) * stats: node-types donut, multi-IATA filtering, preset split - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption replaces the "Coming soon" card with a live donut - stats endpoints take iatas[] instead of a single iata, so multi-IATA regions filter instead of falling back to all (drops useStatsIata) - radio presets keep the node/observer split via stacked presetBarsOption - drop client-side telemetry ms-normalization (backend now emits epoch ms on both paths); charts pick delta-vs-raw counters off the response interval - MeshTab: range-driven charts lead, all-time charts follow; KPI window from overview.windowHours instead of a hardcoded 24h - ci: docker-publish builds a :dev image on dev-branch pushes * feat: traces vs pings feat: SNR for trace path in list feat: known niehgbour count * fix: github workflow, post images publically * build(deps): bump echarts from 5.5.1 to 6.1.0 Bumps [echarts](https://github.com/apache/echarts) from 5.5.1 to 6.1.0. - [Release notes](https://github.com/apache/echarts/releases) - [Commits](https://github.com/apache/echarts/compare/5.5.1...6.1.0) --- updated-dependencies: - dependency-name: echarts dependency-version: 6.1.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] <support@github.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Co-authored-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump version to 1.2.0 --------- Signed-off-by: dependabot[bot] <support@github.com> Signed-off-by: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
226 lines
9.1 KiB
TypeScript
226 lines
9.1 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, buildFocusedNeighborEdges } from "../../../src/features/map/node-geojson";
|
|
import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types";
|
|
|
|
function node(overrides: Partial<NodeSummary>): NodeSummary {
|
|
return {
|
|
id: "n1",
|
|
publicKey: "pk",
|
|
nodeType: 1,
|
|
nodeTypeName: "repeater",
|
|
name: "Node 1",
|
|
lat: 45,
|
|
lng: -75,
|
|
iatas: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("nodesToFeatureCollection", () => {
|
|
it("returns an empty FeatureCollection for no nodes", () => {
|
|
const fc = nodesToFeatureCollection([]);
|
|
expect(fc.type).toBe("FeatureCollection");
|
|
expect(fc.features).toEqual([]);
|
|
});
|
|
|
|
it("maps a located node to a Point feature in [lng, lat] order", () => {
|
|
const fc = nodesToFeatureCollection([node({ lat: 45.3, lng: -75.6 })]);
|
|
expect(fc.features).toHaveLength(1);
|
|
expect(fc.features[0]!.geometry).toEqual({ type: "Point", coordinates: [-75.6, 45.3] });
|
|
});
|
|
|
|
it("keeps whole-degree coordinates intact (the API sends decimal degrees, never microdegrees)", () => {
|
|
const fc = nodesToFeatureCollection([node({ lat: 51, lng: -114 })]);
|
|
expect(fc.features[0]!.geometry.coordinates).toEqual([-114, 51]);
|
|
});
|
|
|
|
it("carries id/name/nodeTypeName/isObserver as feature properties", () => {
|
|
const fc = nodesToFeatureCollection([
|
|
node({ id: "abc", name: "Relay A", nodeType: 2, nodeTypeName: "sensor" }),
|
|
]);
|
|
expect(fc.features[0]!.properties).toEqual({
|
|
id: "abc",
|
|
name: "Relay A",
|
|
nodeTypeName: "sensor",
|
|
isObserver: false,
|
|
});
|
|
});
|
|
|
|
it("defaults isObserver to false and reflects it when set", () => {
|
|
const fc = nodesToFeatureCollection([
|
|
node({ id: "plain" }),
|
|
node({ id: "obs", isObserver: true }),
|
|
]);
|
|
expect(fc.features[0]!.properties.isObserver).toBe(false);
|
|
expect(fc.features[1]!.properties.isObserver).toBe(true);
|
|
});
|
|
|
|
it("drops nodes missing lat or lng", () => {
|
|
const fc = nodesToFeatureCollection([
|
|
node({ id: "a", lat: null, lng: -75 }),
|
|
node({ id: "b", lat: 45, lng: null }),
|
|
node({ id: "c", lat: 45, lng: -75 }),
|
|
]);
|
|
expect(fc.features.map((f) => f.properties.id)).toEqual(["c"]);
|
|
});
|
|
|
|
it("keeps nodes at the 0/0 coordinate (0 is a valid coordinate)", () => {
|
|
const fc = nodesToFeatureCollection([node({ id: "z", lat: 0, lng: 0 })]);
|
|
expect(fc.features).toHaveLength(1);
|
|
expect(fc.features[0]!.geometry.coordinates).toEqual([0, 0]);
|
|
});
|
|
|
|
it("passes decimal coordinates through untouched (api/nodes.go sends *float64 degrees)", () => {
|
|
const fc = nodesToFeatureCollection([node({ lat: 49.28, lng: -123.12 })]);
|
|
expect(fc.features[0]!.geometry.coordinates).toEqual([-123.12, 49.28]);
|
|
});
|
|
});
|
|
|
|
describe("buildNeighborEdges", () => {
|
|
const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] });
|
|
const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] });
|
|
const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] });
|
|
|
|
it("emits one undirected edge per neighbor pair (a<->b counted once) in [lng, lat] order", () => {
|
|
const fc = buildNeighborEdges([a, b], "on", null);
|
|
expect(fc.type).toBe("FeatureCollection");
|
|
expect(fc.features).toHaveLength(1);
|
|
expect(fc.features[0]!.geometry).toEqual({
|
|
type: "LineString",
|
|
coordinates: [[-75, 45], [-76, 46]],
|
|
});
|
|
});
|
|
|
|
it("skips neighbor ids absent from the set or without coordinates", () => {
|
|
const lonely = node({ id: "a", lat: 45, lng: -75, neighborIds: ["ghost"] });
|
|
const noCoord = node({ id: "b", lat: null, lng: null, neighborIds: ["a"] });
|
|
expect(buildNeighborEdges([lonely, noCoord], "on", null).features).toEqual([]);
|
|
});
|
|
|
|
it("in 'selected' mode keeps only edges incident to the selected node", () => {
|
|
const fc = buildNeighborEdges([a, b, c], "selected", "b");
|
|
expect(fc.features).toHaveLength(1); // only a<->b
|
|
expect(fc.features[0]!.properties.selected).toBe(true);
|
|
});
|
|
|
|
it("in 'on' mode emits all edges and flags those incident to the selected node", () => {
|
|
const fc = buildNeighborEdges([a, b, c], "on", "b");
|
|
expect(fc.features).toHaveLength(2); // a<->b and a<->c
|
|
expect(fc.features.filter((f) => f.properties.selected)).toHaveLength(1); // a<->b
|
|
});
|
|
|
|
it("ignores a node that lists itself as a neighbor (no zero-length edge)", () => {
|
|
const selfRef = node({ id: "a", lat: 45, lng: -75, neighborIds: ["a", "b"] });
|
|
const fc = buildNeighborEdges([selfRef, b], "on", null);
|
|
expect(fc.features).toHaveLength(1); // a<->b only, not a<->a
|
|
});
|
|
});
|
|
|
|
describe("neighborFocusIds", () => {
|
|
const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] });
|
|
const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] });
|
|
const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] });
|
|
|
|
const sorted = (ids: string[] | null) => (ids ? [...ids].sort() : ids);
|
|
|
|
it("returns null when nothing is selected", () => {
|
|
expect(neighborFocusIds([a, b, c], null)).toBeNull();
|
|
});
|
|
|
|
it("includes the selected node and its located neighbors", () => {
|
|
expect(sorted(neighborFocusIds([a, b, c], "a"))).toEqual(["a", "b", "c"]);
|
|
});
|
|
|
|
it("treats neighbor links as undirected (a node listing the selection counts)", () => {
|
|
// b lists a; c does not list b, so only a<->b makes c irrelevant to b's focus set
|
|
expect(sorted(neighborFocusIds([a, b, c], "b"))).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("skips neighbor ids that are absent or unlocated", () => {
|
|
const noCoord = node({ id: "c", lat: null, lng: null, neighborIds: ["a"] });
|
|
expect(sorted(neighborFocusIds([a, b, noCoord], "a"))).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("returns null when the selected node is unlocated (no marker to keep lit)", () => {
|
|
const unlocated = node({ id: "a", lat: null, lng: null, neighborIds: ["b"] });
|
|
expect(neighborFocusIds([unlocated, b], "a")).toBeNull();
|
|
});
|
|
|
|
it("returns null when the selected node has no located neighbors", () => {
|
|
const lonely = node({ id: "x", lat: 45, lng: -75, neighborIds: ["ghost"] });
|
|
expect(neighborFocusIds([lonely, b], "x")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("buildFocusedNeighborEdges", () => {
|
|
const DAY = 86400000;
|
|
const NOW = 1000 * DAY;
|
|
const sel = node({ id: "a", lat: 45, lng: -75 });
|
|
function nb(o: Partial<NodeNeighbor>): NodeNeighbor {
|
|
return { id: "b", publicKey: "pk", nodeType: 2, nodeTypeName: "repeater", iata: "YOW", observationCount: 10, firstSeen: 0, lastSeen: NOW, lat: 46, lng: -76, ...o };
|
|
}
|
|
|
|
it("returns empty when nothing is selected", () => {
|
|
expect(buildFocusedNeighborEdges(null, [nb({})], NOW).features).toEqual([]);
|
|
});
|
|
|
|
it("returns empty when the selected node has no coordinates", () => {
|
|
expect(buildFocusedNeighborEdges(node({ id: "a", lat: null, lng: null }), [nb({})], NOW).features).toEqual([]);
|
|
});
|
|
|
|
it("draws one edge selected->neighbor with obs, in [lng, lat] order", () => {
|
|
const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lat: 46, lng: -76, observationCount: 42, lastSeen: NOW })], NOW);
|
|
expect(fc.features).toHaveLength(1);
|
|
expect(fc.features[0]!.geometry.coordinates).toEqual([[-75, 45], [-76, 46]]);
|
|
expect(fc.features[0]!.properties.obs).toBe(42);
|
|
expect(fc.features[0]!.properties.selected).toBe(true);
|
|
expect(fc.features[0]!.properties.ageDays).toBe(0);
|
|
});
|
|
|
|
it("computes ageDays from lastSeen relative to now", () => {
|
|
const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lastSeen: NOW - 3 * DAY })], NOW);
|
|
expect(fc.features[0]!.properties.ageDays).toBeCloseTo(3);
|
|
});
|
|
|
|
it("aggregates a neighbor's per-iata rows: sums obs, keeps the freshest lastSeen", () => {
|
|
const fc = buildFocusedNeighborEdges(sel, [
|
|
nb({ id: "b", iata: "YOW", observationCount: 30, lastSeen: NOW - 5 * DAY }),
|
|
nb({ id: "b", iata: "YYZ", observationCount: 12, lastSeen: NOW - 1 * DAY }),
|
|
], NOW);
|
|
expect(fc.features).toHaveLength(1);
|
|
expect(fc.features[0]!.properties.obs).toBe(42);
|
|
expect(fc.features[0]!.properties.ageDays).toBeCloseTo(1);
|
|
});
|
|
|
|
it("skips neighbors without coordinates and any self-referential row", () => {
|
|
const fc = buildFocusedNeighborEdges(sel, [
|
|
nb({ id: "b", lat: undefined, lng: undefined }),
|
|
nb({ id: "a", lat: 45, lng: -75 }), // self — no zero-length edge
|
|
nb({ id: "c", lat: 47, lng: -77, observationCount: 5 }),
|
|
], NOW);
|
|
expect(fc.features.map((f) => f.properties.obs)).toEqual([5]);
|
|
});
|
|
});
|
|
|
|
describe("filterByNodeType", () => {
|
|
const fc = nodesToFeatureCollection([
|
|
node({ id: "r1", nodeTypeName: "repeater" }),
|
|
node({ id: "c1", nodeTypeName: "companion" }),
|
|
node({ id: "r2", nodeTypeName: "repeater" }),
|
|
]);
|
|
|
|
it("returns all features (same reference) for an empty type = All", () => {
|
|
expect(filterByNodeType(fc, "")).toBe(fc);
|
|
});
|
|
|
|
it("keeps only features matching the given type", () => {
|
|
expect(filterByNodeType(fc, "repeater").features.map((f) => f.properties.id)).toEqual(["r1", "r2"]);
|
|
});
|
|
|
|
it("returns an empty collection when nothing matches", () => {
|
|
const out = filterByNodeType(fc, "sensor");
|
|
expect(out.type).toBe("FeatureCollection");
|
|
expect(out.features).toEqual([]);
|
|
});
|
|
});
|