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>
407 lines
14 KiB
TypeScript
407 lines
14 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { WsManager } from "../../src/api/ws-manager";
|
|
|
|
class MockWebSocket {
|
|
static instances: MockWebSocket[] = [];
|
|
static OPEN = 1;
|
|
static CLOSED = 3;
|
|
static CONNECTING = 0;
|
|
static CLOSING = 2;
|
|
readyState = 0;
|
|
onopen: (() => void) | null = null;
|
|
onclose: ((e: { code: number }) => void) | null = null;
|
|
onmessage: ((e: { data: string }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
sent: string[] = [];
|
|
|
|
constructor(public url: string) {
|
|
MockWebSocket.instances.push(this);
|
|
}
|
|
|
|
send(data: string) {
|
|
this.sent.push(data);
|
|
}
|
|
|
|
close() {
|
|
this.readyState = 3;
|
|
this.onclose?.({ code: 1000 });
|
|
}
|
|
|
|
simulateOpen() {
|
|
this.readyState = 1;
|
|
this.onopen?.();
|
|
}
|
|
|
|
simulateMessage(data: unknown) {
|
|
this.onmessage?.({ data: JSON.stringify(data) });
|
|
}
|
|
|
|
simulateClose(code = 1006) {
|
|
this.readyState = 3;
|
|
this.onclose?.({ code });
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
MockWebSocket.instances = [];
|
|
vi.stubGlobal("WebSocket", MockWebSocket);
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("WsManager", () => {
|
|
it("connects and sends subscribe on hello", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
|
|
expect(ws.sent).toHaveLength(1);
|
|
const sub = JSON.parse(ws.sent[0]!);
|
|
expect(sub.type).toBe("subscribe");
|
|
expect(sub.scope.iatas).toEqual(["YOW"]);
|
|
});
|
|
|
|
it("exposes connected status after hello", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
expect(mgr.getStatus()).toBe("disconnected");
|
|
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
expect(mgr.getStatus()).toBe("connecting");
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
expect(mgr.getStatus()).toBe("connected");
|
|
});
|
|
|
|
it("calls packet handler on packetObservation event", () => {
|
|
const handler = vi.fn();
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.onPacketObservation(handler);
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
ws.simulateMessage({
|
|
v: 1,
|
|
type: "event",
|
|
event: "packetObservation",
|
|
data: { packetHash: "abc123", packet: {}, observation: {} },
|
|
});
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
expect(handler.mock.calls[0]![0].packetHash).toBe("abc123");
|
|
});
|
|
|
|
it("reconnects with jittered backoff on unexpected close", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
ws.simulateClose(1006);
|
|
|
|
expect(mgr.getStatus()).toBe("connecting");
|
|
expect(MockWebSocket.instances).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(1500);
|
|
expect(MockWebSocket.instances).toHaveLength(2);
|
|
});
|
|
|
|
it("resubscribes on reconnect", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws1 = MockWebSocket.instances[0]!;
|
|
ws1.simulateOpen();
|
|
ws1.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
ws1.simulateClose(1006);
|
|
|
|
vi.advanceTimersByTime(1500);
|
|
const ws2 = MockWebSocket.instances[1]!;
|
|
ws2.simulateOpen();
|
|
ws2.simulateMessage({ v: 1, type: "hello", serverTime: 456, connectionId: "def" });
|
|
|
|
const sub = JSON.parse(ws2.sent[0]!);
|
|
expect(sub.type).toBe("subscribe");
|
|
expect(sub.scope.iatas).toEqual(["YOW"]);
|
|
});
|
|
|
|
it("sends a configure frame when resolvePath is enabled while connected", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
|
|
mgr.setResolvePath(true);
|
|
|
|
const configure = JSON.parse(ws.sent.at(-1)!);
|
|
expect(configure.type).toBe("configure");
|
|
expect(configure.resolvePath).toBe(true);
|
|
});
|
|
|
|
it("re-sends the resolvePath configure after a reconnect", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws1 = MockWebSocket.instances[0]!;
|
|
ws1.simulateOpen();
|
|
ws1.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
mgr.setResolvePath(true);
|
|
ws1.simulateClose(1006);
|
|
|
|
vi.advanceTimersByTime(1500);
|
|
const ws2 = MockWebSocket.instances[1]!;
|
|
ws2.simulateOpen();
|
|
ws2.simulateMessage({ v: 1, type: "hello", serverTime: 456, connectionId: "def" });
|
|
|
|
const frames = ws2.sent.map((s) => JSON.parse(s));
|
|
expect(frames.some((f) => f.type === "configure" && f.resolvePath === true)).toBe(true);
|
|
});
|
|
|
|
it("handles a configured reply without throwing", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
|
|
expect(() =>
|
|
ws.simulateMessage({ v: 1, type: "configured", id: "cfg-1", resolvePath: true }),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it("updates subscription without reconnecting", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
ws.simulateMessage({ v: 1, type: "subscribed", id: "sub-1", subscriptionId: "s-1" });
|
|
|
|
mgr.updateSubscription({ iatas: ["SEA"] });
|
|
|
|
expect(MockWebSocket.instances).toHaveLength(1);
|
|
const unsub = JSON.parse(ws.sent[1]!);
|
|
expect(unsub.type).toBe("unsubscribe");
|
|
const newSub = JSON.parse(ws.sent[2]!);
|
|
expect(newSub.scope.iatas).toEqual(["SEA"]);
|
|
});
|
|
|
|
it("fires status listeners on state changes", () => {
|
|
const listener = vi.fn();
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.onStatusChange(listener);
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
expect(listener).toHaveBeenCalledWith("connecting");
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
|
|
expect(listener).toHaveBeenCalledWith("connected");
|
|
});
|
|
|
|
it("calls lagged handler on lagged message", () => {
|
|
const handler = vi.fn();
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.onLagged(handler);
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
ws.simulateMessage({ v: 1, type: "lagged", droppedCount: 47, since: 100, lastObservationId: 12340 });
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
expect(handler.mock.calls[0]![0].droppedCount).toBe(47);
|
|
});
|
|
|
|
it("refreshes the last-event timestamp on lagged and pong messages", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" });
|
|
|
|
const baseline = mgr.getLastEventTimestamp();
|
|
|
|
// a lag notice is still server traffic and should reset the stale timer
|
|
vi.advanceTimersByTime(5000);
|
|
ws.simulateMessage({ v: 1, type: "lagged", droppedCount: 1, since: 0, lastObservationId: 0 });
|
|
const afterLagged = mgr.getLastEventTimestamp();
|
|
expect(afterLagged).toBeGreaterThan(baseline);
|
|
|
|
// so should a heartbeat pong
|
|
vi.advanceTimersByTime(5000);
|
|
ws.simulateMessage({ v: 1, type: "pong", id: "p-1" });
|
|
expect(mgr.getLastEventTimestamp()).toBeGreaterThan(afterLagged);
|
|
});
|
|
|
|
it("dispatches channelMessage events to handlers", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
const handler = vi.fn();
|
|
mgr.onChannelMessage(handler);
|
|
|
|
mgr.connect({ events: ["channelMessage"] });
|
|
const ws = MockWebSocket.instances[0];
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
|
|
const msgData = {
|
|
id: 1,
|
|
packetHash: "abc123",
|
|
channelHash: "f3",
|
|
senderName: "TestNode",
|
|
content: "hello mesh",
|
|
sentAt: 1779804000000, // epoch ms (2026-05-26T14:00:00Z)
|
|
};
|
|
|
|
ws.simulateMessage({ v: 1, type: "event", event: "channelMessage", data: msgData });
|
|
expect(handler).toHaveBeenCalledWith(msgData);
|
|
});
|
|
|
|
it("unsubscribes a superseded subscription when its late ack arrives", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: undefined }); // "all"
|
|
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
// region resolves before the first subscribe is acked — the real-world firehose repro
|
|
mgr.updateSubscription({ iatas: ["YOW"] });
|
|
|
|
const sent = () => ws.sent.map((s) => JSON.parse(s));
|
|
const [firstSub, secondSub] = sent().filter((m) => m.type === "subscribe");
|
|
expect(firstSub).toBeTruthy();
|
|
expect(secondSub.scope.iatas).toEqual(["YOW"]);
|
|
|
|
// late ack for the superseded "all" subscribe must be unsubscribed immediately
|
|
ws.simulateMessage({ v: 1, type: "subscribed", id: firstSub.id, subscriptionId: "s-all" });
|
|
const unsubs = sent().filter((m) => m.type === "unsubscribe");
|
|
expect(unsubs).toHaveLength(1);
|
|
expect(unsubs[0].subscriptionId).toBe("s-all");
|
|
|
|
// the current subscribe's ack is kept, and a later update unsubscribes it
|
|
ws.simulateMessage({ v: 1, type: "subscribed", id: secondSub.id, subscriptionId: "s-yow" });
|
|
mgr.updateSubscription({ iatas: ["YYZ"] });
|
|
const unsubs2 = sent().filter((m) => m.type === "unsubscribe");
|
|
expect(unsubs2).toHaveLength(2);
|
|
expect(unsubs2[1].subscriptionId).toBe("s-yow");
|
|
});
|
|
|
|
it("ignores close events from a torn-down socket (no reconnect treadmill)", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
const ws1 = MockWebSocket.instances[0]!;
|
|
|
|
mgr.disconnect();
|
|
mgr.connect({ iatas: ["YOW"] }); // StrictMode-style connect/disconnect/connect
|
|
expect(MockWebSocket.instances).toHaveLength(2);
|
|
|
|
// a late close from the dead socket must not spawn a parallel reconnect loop
|
|
ws1.simulateClose(1006);
|
|
vi.advanceTimersByTime(60_000);
|
|
expect(MockWebSocket.instances).toHaveLength(2);
|
|
});
|
|
|
|
it("does not double the ping interval on a second hello", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c1" });
|
|
|
|
ws.sent = [];
|
|
vi.advanceTimersByTime(30_000);
|
|
expect(ws.sent.map((s) => JSON.parse(s)).filter((m) => m.type === "ping")).toHaveLength(1);
|
|
});
|
|
|
|
it("forces a reconnect when pongs stop coming (half-open link)", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
const ws = MockWebSocket.instances[0]!;
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
|
|
// pings go out but nothing ever comes back
|
|
vi.advanceTimersByTime(120_000);
|
|
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
|
|
});
|
|
|
|
it("clears the stale subscriptionId across reconnects", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
const ws1 = MockWebSocket.instances[0]!;
|
|
ws1.simulateOpen();
|
|
ws1.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
const sub1 = JSON.parse(ws1.sent[0]!);
|
|
ws1.simulateMessage({ v: 1, type: "subscribed", id: sub1.id, subscriptionId: "s-old" });
|
|
|
|
ws1.simulateClose(1006);
|
|
vi.advanceTimersByTime(1500);
|
|
const ws2 = MockWebSocket.instances[1]!;
|
|
ws2.simulateOpen();
|
|
ws2.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c2" });
|
|
|
|
mgr.updateSubscription({ iatas: ["YYZ"] });
|
|
const unsubs = ws2.sent.map((s) => JSON.parse(s)).filter((m) => m.type === "unsubscribe");
|
|
expect(unsubs.map((u) => u.subscriptionId)).not.toContain("s-old");
|
|
});
|
|
|
|
it("signals lagged handlers after a reconnect so views can heal the gap", () => {
|
|
const handler = vi.fn();
|
|
const mgr = new WsManager("ws://test/ws");
|
|
mgr.onLagged(handler);
|
|
mgr.connect({ iatas: ["YOW"] });
|
|
const ws1 = MockWebSocket.instances[0]!;
|
|
ws1.simulateOpen();
|
|
ws1.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
expect(handler).not.toHaveBeenCalled(); // first connect is not a gap
|
|
|
|
ws1.simulateClose(1006);
|
|
vi.advanceTimersByTime(1500);
|
|
const ws2 = MockWebSocket.instances[1]!;
|
|
ws2.simulateOpen();
|
|
ws2.simulateMessage({ v: 1, type: "hello", serverTime: 2, connectionId: "c2" });
|
|
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it("unsubscribes channelMessage handler on cleanup", () => {
|
|
const mgr = new WsManager("ws://test/ws");
|
|
const handler = vi.fn();
|
|
const unsub = mgr.onChannelMessage(handler);
|
|
unsub();
|
|
|
|
mgr.connect({ events: ["channelMessage"] });
|
|
const ws = MockWebSocket.instances[0];
|
|
ws.simulateOpen();
|
|
ws.simulateMessage({ v: 1, type: "hello", serverTime: 1, connectionId: "c1" });
|
|
ws.simulateMessage({
|
|
v: 1,
|
|
type: "event",
|
|
event: "channelMessage",
|
|
data: { id: 1, packetHash: "x", channelHash: "f3", senderName: "N", content: "hi", sentAt: 1779804000000 },
|
|
});
|
|
|
|
expect(handler).not.toHaveBeenCalled();
|
|
});
|
|
});
|