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 * Restore files clobbered by the main merge * Bump version to 1.2.1 * Drop empty observer telemetry frames and show airtime as duration * Back out frontend zero-frame filter; fixing at the source instead * Add 'view path on map' popup to the packet analyzer * Collapse the path map attribution to a bare (i) by default * Add path-map deep links, per-observer propagation, and tappable nodes * Keep path map at a fixed height on narrow screens * Show packet source and destination on the path map and analyzer * Add top advertisers to Mesh stats and a Talkers leaderboard tab * Add public key prefix search to the nodes table * Show device clock drift on the node detail panel * Only plot unambiguously-resolved path endpoints on the map * Fix public-key node search field switching and non-hex input * Add tests for path endpoint, trace, and clock-drift edge cases * Refresh stale radio-freq and telemetry comments for current backend * Send multi-select packet filters to the server * Group top advertisers and talkers on the Talkers tab * Lay out Talkers side by side and label advertiser node types * Mark each top advertiser with its IATA * Pin overview sparklines to a fixed 24h window The Observations and Active observers KPI cards show a fixed 24h snapshot (from /stats/overview), but their sparklines were fed by the range-driven observations series. Switching to 7d/30d reshaped the sparklines even though the numbers beside them stayed at 24h, since aggregateByHour drops empty hours and slice(-24) then spans a different set of hours. Give the sparklines their own dedicated 24h observations series so the whole top row is a consistent 24h snapshot; the range selector now only drives the charts below. React Query dedupes the extra call when the selector is already on 24h. * Split top advertisers into flood and direct adverts Surface each advertiser's flood vs direct advert counts with a per-day rate for the selected window, as a sortable table on the Talkers tab. * Add a Clock Drift analytics tab List repeaters and room servers whose advert-derived clock has drifted past the server threshold, worst first, in a sortable table. It's each node's latest reading, so there's no time range to pick. * Add a toggleable IATA border layer to the map Draw each active IATA's GeoJSON region border as an outline over a faint fill beneath the node markers, behind a Map Settings toggle that's off by default and shareable via a URL param. IATAs with no border answer 204 and are skipped. * Carry path and endpoint fields from WS observations into packet summaries * Extract usePacketDetail and share it across the analyzer and overlay * Split ?hash into row expansion and ?analyze for the analyzer drawer * Fix ?path deep link when ?analyze is absent * Add a pure path-summary model for the packet table's path line * Cover pathBytes-absent and unresolved-endpoint cases in path summary tests * Render the packet path line with truncation and confidence tints * Cover endpoint pairs, high tint, and unresolved-run chips in PacketPathLine * Add the two-line packet table row * Restore n/a fallback in observer and IATA cells, scope the covering test * Add the sticky packet table header * Add the nested observation table for expanded packet rows * Fix PathData sizing and add null-SNR test to ObservationTable * Add the expanded packet region with skeleton, error and empty states * Reorder PacketExpansion to show errors before empty state * Wire the packet table and expandable rows into the virtual list * Carry the picked observation into the analyzer and finish two other spec gaps - Stop resetting selectedObservationId when opening the analyzer, so picking an observation inside an expanded row lands the drawer on it instead of observations[0] - Disable View path on map in the expanded row when the detail has no resolvable path, matching the drawer's existing button - Strip a ?hash deep link once the first page has loaded and it matches no packet * Stop stripping ?hash on mount, it breaks deep links to older packets The analyzer drawer and path-map restore both read the live ?hash and fetch by hash directly, independent of whether the packet is in the loaded list. Deleting the param once the first page settles without a match unmounts the drawer and can drop path-map restores, for any shared link more than a couple of minutes old. * Keep the packet card layout below md * Keep the widest heard window when a live packet is re-observed * Close the analyzer on a mobile tab change and add an expanded-row copy link * Drop a stale doc pointer from two map comments * Collapse the packet row to a single line with hops and endpoints The header and the rows are separate grids, so the shared track list can't use ch or auto — each resolved them differently and the columns drifted apart. Observer moves into the expansion to free the column. * Open the analyzer from an observation and add a hash size column Drops the Open analyzer and Copy link buttons from the expansion; the analyzer popup keeps its own copy link. Table rows butt up against each other now, so the whole strip is a click target. * Filter the region picker by IATA or airport name Typing narrows both groups: airports match on code or display name, regions match on their name or on any member code — a code-only match tags the row with the code that hit, so it doesn't look like a stray result. Empty groups drop their headers so the divider never dangles. The panel body moves into its own component so the query dies with the dropdown, and focus returns to the trigger on close. * Animate the live map path from source through to destination Share the endpoint-gating chain builder with the path map so both plot source and destination when the backend resolved them unambiguously. * Preserve linebreaks in message bodies --------- 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>
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
import { PacketVirtualList } from "../../../src/features/packets/PacketVirtualList";
|
|
import type { PacketSummary } from "../../../src/types/api";
|
|
|
|
// PacketExpansion fetches through usePacketDetail; stub it so the list renders without a query client.
|
|
const usePacketDetail = vi.fn(() => ({ data: { packetHash: "AA11", header: { payloadType: 1 }, observations: [] } }));
|
|
vi.mock("../../../src/features/packets/usePacketDetail", () => ({
|
|
usePacketDetail: (h: string | null) => usePacketDetail(h),
|
|
}));
|
|
|
|
const VIEWPORT_H = 1300;
|
|
const ROW_H = 60;
|
|
const EXPANDED_H = 400;
|
|
|
|
// jsdom has no layout and no ResizeObserver, so feed the virtualizer both: offsetHeight answers for
|
|
// the viewport and for each measured item (taller once it holds an expansion), and flushResize()
|
|
// stands in for the browser noticing a row changed height.
|
|
type Observed = { cb: ResizeObserverCallback; targets: Set<Element> };
|
|
const observers: Observed[] = [];
|
|
|
|
class StubResizeObserver {
|
|
private entry: Observed;
|
|
constructor(cb: ResizeObserverCallback) {
|
|
this.entry = { cb, targets: new Set() };
|
|
observers.push(this.entry);
|
|
}
|
|
observe(target: Element) { this.entry.targets.add(target); }
|
|
unobserve(target: Element) { this.entry.targets.delete(target); }
|
|
disconnect() { this.entry.targets.clear(); }
|
|
}
|
|
vi.stubGlobal("ResizeObserver", StubResizeObserver);
|
|
|
|
function flushResize() {
|
|
act(() => {
|
|
for (const o of observers) {
|
|
const entries = [...o.targets].map((target) => ({ target })) as unknown as ResizeObserverEntry[];
|
|
if (entries.length > 0) o.cb(entries, {} as ResizeObserver);
|
|
}
|
|
});
|
|
}
|
|
|
|
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
|
configurable: true,
|
|
get(this: HTMLElement) {
|
|
if (!this.hasAttribute("data-index")) return VIEWPORT_H;
|
|
return this.querySelector("[data-testid='packet-expansion']") ? EXPANDED_H : ROW_H;
|
|
},
|
|
});
|
|
|
|
const pkt = (hash: string): PacketSummary => ({
|
|
packetHash: hash, payloadType: 1, payloadTypeName: "ADVERT",
|
|
routeType: 1, routeTypeName: "FLOOD",
|
|
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1,
|
|
});
|
|
|
|
const many = (n: number) => Array.from({ length: n }, (_, i) => pkt(`AA${i}`));
|
|
|
|
function makeHandlers() {
|
|
return {
|
|
hasNextPage: false,
|
|
isFetchingNextPage: false,
|
|
fetchNextPage: vi.fn(),
|
|
onScrollAwayFromTop: vi.fn(),
|
|
onAtTopChange: vi.fn(),
|
|
onToggleExpand: vi.fn(),
|
|
onOpenAnalyzer: vi.fn(),
|
|
onViewPath: vi.fn(),
|
|
selectedObservationId: null,
|
|
onSelectObservation: vi.fn(),
|
|
};
|
|
}
|
|
|
|
// the scroll container is the component's root; the spacer under it carries the virtualizer's total size
|
|
const scroller = (container: HTMLElement) => container.firstElementChild as HTMLElement;
|
|
function totalSize(container: HTMLElement) {
|
|
const spacer = scroller(container).lastElementChild as HTMLElement;
|
|
const height = parseFloat(spacer.style.height);
|
|
expect(Number.isFinite(height)).toBe(true);
|
|
return height;
|
|
}
|
|
|
|
function setScrollMetrics(el: HTMLElement, { scrollHeight, clientHeight, scrollTop }: {
|
|
scrollHeight: number; clientHeight: number; scrollTop: number;
|
|
}) {
|
|
Object.defineProperty(el, "scrollHeight", { configurable: true, value: scrollHeight });
|
|
Object.defineProperty(el, "clientHeight", { configurable: true, value: clientHeight });
|
|
el.scrollTop = scrollTop;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
observers.length = 0;
|
|
usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: { payloadType: 1 }, observations: [] } });
|
|
});
|
|
|
|
describe("PacketVirtualList expansion", () => {
|
|
it("mounts the expansion inside the measured wrapper", () => {
|
|
render(<PacketVirtualList packets={[pkt("AA11")]} expandedHash="AA11" {...makeHandlers()} />);
|
|
|
|
const wrapper = screen.getByTestId("packet-item-AA11");
|
|
expect(wrapper).toHaveAttribute("data-index", "0");
|
|
expect(wrapper.querySelector("[data-testid='packet-expansion']")).not.toBeNull();
|
|
});
|
|
|
|
it("expands only the row named by expandedHash", () => {
|
|
render(<PacketVirtualList packets={many(30)} expandedHash="AA3" {...makeHandlers()} />);
|
|
|
|
expect(screen.getAllByTestId("packet-expansion")).toHaveLength(1);
|
|
expect(screen.getByTestId("packet-item-AA3").querySelector("[data-testid='packet-expansion']")).not.toBeNull();
|
|
});
|
|
|
|
it("renders no expansion when nothing is expanded", () => {
|
|
render(<PacketVirtualList packets={many(30)} expandedHash={null} {...makeHandlers()} />);
|
|
|
|
expect(screen.queryByTestId("packet-expansion")).toBeNull();
|
|
});
|
|
|
|
it("counts the expanded height in the virtualizer's total size", () => {
|
|
const packets = many(30);
|
|
const handlers = makeHandlers();
|
|
const { container, rerender } = render(
|
|
<PacketVirtualList packets={packets} expandedHash={null} {...handlers} />,
|
|
);
|
|
const collapsed = totalSize(container);
|
|
|
|
rerender(<PacketVirtualList packets={packets} expandedHash="AA3" {...handlers} />);
|
|
flushResize();
|
|
|
|
expect(totalSize(container)).toBe(collapsed + (EXPANDED_H - ROW_H));
|
|
});
|
|
|
|
it("forwards the expansion's actions", () => {
|
|
const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] });
|
|
usePacketDetail.mockReturnValue({
|
|
data: {
|
|
packetHash: "AA11",
|
|
header: { payloadType: 1 },
|
|
observations: [{ id: 1, observerId: "o1", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)] }],
|
|
},
|
|
});
|
|
const handlers = makeHandlers();
|
|
render(<PacketVirtualList packets={[pkt("AA11")]} expandedHash="AA11" {...handlers} />);
|
|
|
|
// clicking an observation is what opens the analyzer now — there is no button for it
|
|
fireEvent.click(screen.getByText("o1"));
|
|
fireEvent.click(screen.getByRole("button", { name: "View path on map" }));
|
|
|
|
expect(handlers.onSelectObservation).toHaveBeenCalledWith(1);
|
|
expect(handlers.onOpenAnalyzer).toHaveBeenCalledTimes(1);
|
|
expect(handlers.onViewPath).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
describe("PacketVirtualList header", () => {
|
|
it("renders the sticky header once, outside measured item space", () => {
|
|
const { container } = render(
|
|
<PacketVirtualList packets={many(30)} expandedHash={null} {...makeHandlers()} />,
|
|
);
|
|
|
|
const headings = screen.getAllByText("Hash");
|
|
expect(headings).toHaveLength(1);
|
|
const header = headings[0]!.parentElement as HTMLElement;
|
|
expect(header.closest("[data-index]")).toBeNull();
|
|
expect(header.parentElement).toBe(scroller(container));
|
|
expect(header.previousElementSibling).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("PacketVirtualList scrolling", () => {
|
|
it("pages when scrolled near the bottom", () => {
|
|
const handlers = makeHandlers();
|
|
const { container } = render(
|
|
<PacketVirtualList packets={many(30)} expandedHash={null} {...handlers} hasNextPage />,
|
|
);
|
|
|
|
const el = scroller(container);
|
|
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 3800 });
|
|
fireEvent.scroll(el);
|
|
|
|
expect(handlers.fetchNextPage).toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not page while a page is already in flight", () => {
|
|
const handlers = makeHandlers();
|
|
const { container } = render(
|
|
<PacketVirtualList packets={many(30)} expandedHash={null} {...handlers} hasNextPage isFetchingNextPage />,
|
|
);
|
|
|
|
const el = scroller(container);
|
|
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 3800 });
|
|
fireEvent.scroll(el);
|
|
|
|
expect(handlers.fetchNextPage).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("reports at-top and scrolled-away as the scroll position moves", () => {
|
|
const handlers = makeHandlers();
|
|
const { container } = render(
|
|
<PacketVirtualList packets={many(30)} expandedHash={null} {...handlers} />,
|
|
);
|
|
|
|
const el = scroller(container);
|
|
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 150 });
|
|
fireEvent.scroll(el);
|
|
expect(handlers.onAtTopChange).toHaveBeenLastCalledWith(false);
|
|
expect(handlers.onScrollAwayFromTop).toHaveBeenLastCalledWith(true);
|
|
|
|
setScrollMetrics(el, { scrollHeight: 5000, clientHeight: 1000, scrollTop: 0 });
|
|
fireEvent.scroll(el);
|
|
expect(handlers.onAtTopChange).toHaveBeenLastCalledWith(true);
|
|
expect(handlers.onScrollAwayFromTop).toHaveBeenLastCalledWith(false);
|
|
});
|
|
|
|
it("does not page when a row collapses near the bottom", () => {
|
|
const packets = many(30);
|
|
const handlers = makeHandlers();
|
|
const { rerender } = render(
|
|
<PacketVirtualList packets={packets} expandedHash="AA29" {...handlers} hasNextPage />,
|
|
);
|
|
handlers.fetchNextPage.mockClear();
|
|
|
|
rerender(<PacketVirtualList packets={packets} expandedHash={null} {...handlers} hasNextPage />);
|
|
flushResize();
|
|
|
|
expect(handlers.fetchNextPage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
// mirrors DataTable.test.tsx's mobile stub, keeping the max-width query the only configurable one
|
|
// so hover-driven components (e.g. Tooltip, used by PacketRow) keep their default hover behaviour
|
|
function setMobile(matches: boolean) {
|
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
|
matches: /max-width/.test(query) ? matches : /hover/.test(query),
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})) as unknown as typeof window.matchMedia;
|
|
}
|
|
|
|
describe("PacketVirtualList responsive row", () => {
|
|
afterEach(() => {
|
|
setMobile(false); // back to the desktop default so later tests in this file aren't affected
|
|
});
|
|
|
|
it("renders the card row below md", () => {
|
|
setMobile(true);
|
|
const handlers = makeHandlers();
|
|
const { container } = render(
|
|
<PacketVirtualList packets={[pkt("AA11")]} expandedHash={null} {...handlers} />,
|
|
);
|
|
|
|
// PacketRow has no button role; PacketTableRow's toggle is a real <button>
|
|
expect(screen.queryByRole("button")).toBeNull();
|
|
const card = container.querySelector("[aria-pressed]");
|
|
expect(card).not.toBeNull();
|
|
|
|
fireEvent.click(card!);
|
|
expect(handlers.onToggleExpand).toHaveBeenCalledWith("AA11");
|
|
});
|
|
|
|
it("renders the grid row at md and up", () => {
|
|
const { container } = render(
|
|
<PacketVirtualList packets={[pkt("AA11")]} expandedHash={null} {...makeHandlers()} />,
|
|
);
|
|
|
|
expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "false");
|
|
expect(container.querySelector("[aria-pressed]")).toBeNull();
|
|
});
|
|
|
|
it("expands the card row below md", () => {
|
|
setMobile(true);
|
|
render(<PacketVirtualList packets={[pkt("AA11")]} expandedHash="AA11" {...makeHandlers()} />);
|
|
|
|
expect(screen.getByTestId("packet-expansion")).toBeInTheDocument();
|
|
});
|
|
});
|