mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 06:22:51 +00:00
Analyzer: one observations list, transport scope breakdown (#112)
This commit is contained in:
1 parent
e2d272e08e
commit
85e4d90cbe
7 files changed
+92
-54
No files matched your search
@@ -1,10 +1,15 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { VARIANT_CLASSES } from "./badge-utils";
|
||||
|
||||
// Small secondary chip marking a transport scope, shared across packet rows, the analyzer, and the
|
||||
// trace/observer panels so scope labels read identically everywhere.
|
||||
export function ScopeTag({ children, className }: { children: ReactNode; className?: string }) {
|
||||
// `boxed` matches Badge's box for when it sits beside one; scope names stay case-sensitive, so no uppercase.
|
||||
export function ScopeTag({ children, className, boxed }: { children: ReactNode; className?: string; boxed?: boolean }) {
|
||||
const base = boxed
|
||||
? `inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider ${VARIANT_CLASSES.group}`
|
||||
: "font-mono text-[11px] text-secondary tracking-wide bg-secondary/8 px-1.5 py-px rounded-sm";
|
||||
return (
|
||||
<span className={`font-mono text-[11px] text-secondary tracking-wide bg-secondary/8 px-1.5 py-px rounded-sm${className ? ` ${className}` : ""}`}>
|
||||
<span className={`${base}${className ? ` ${className}` : ""}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -64,6 +64,15 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{obs.radio && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1.5 font-mono text-[11px] text-text-muted">
|
||||
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-0.5">Radio</span>
|
||||
{obs.radio.freqMhz != null && <span>{obs.radio.freqMhz} MHz</span>}
|
||||
{obs.radio.spreadFactor != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>SF{obs.radio.spreadFactor}</span></>}
|
||||
{obs.radio.bandwidthKhz != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>{obs.radio.bandwidthKhz} kHz</span></>}
|
||||
{obs.radio.codingRate != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>CR 4/{obs.radio.codingRate}</span></>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{obs.pathBytes && (
|
||||
|
||||
@@ -11,7 +11,7 @@ import { payloadTypeVariant } from "../../components/badge-utils";
|
||||
import { ScopeTag } from "../../components/ScopeTag";
|
||||
import { formatHex, formatPropagation } from "../../lib/formatters";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, ObservationDetail } from "./packet-structure";
|
||||
import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, transportCodeHex } from "./packet-structure";
|
||||
import { PayloadBreakdown } from "./payload-renderers";
|
||||
import { PathData } from "./PathData";
|
||||
import { buildPacketPaths } from "../map/packet-path";
|
||||
@@ -109,7 +109,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
|
||||
<Badge variant={payloadTypeVariant(detail.header.payloadType)}>
|
||||
{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "Unknown"}
|
||||
</Badge>
|
||||
{detail.scope && <ScopeTag>{detail.scope}</ScopeTag>}
|
||||
{detail.scope && <ScopeTag boxed>{detail.scope}</ScopeTag>}
|
||||
<Tooltip
|
||||
label={t("investigation.reportedBy", { count: observerCount })}
|
||||
className="ml-auto"
|
||||
@@ -149,13 +149,6 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{selectedObs && (
|
||||
<DrawerSection title="Observation">
|
||||
<ObservationDetail observation={selectedObs} />
|
||||
</DrawerSection>
|
||||
)}
|
||||
|
||||
|
||||
<PacketInvestigation key={detail.packetHash} detail={detail} selectedId={selectedObs?.id ?? null} onSelect={selectReport} onViewObserver={onViewObserver} onViewPath={onViewPath} onViewNode={onViewNode} mappedKeys={pathKeys} observerCount={observerCount} />
|
||||
|
||||
{rawHex && (
|
||||
@@ -184,8 +177,21 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
|
||||
{/* Transport codes */}
|
||||
{detail.transportCodes && (
|
||||
<ColorAccentField field="transport">
|
||||
<span className="text-text-dim">Transport </span>
|
||||
<span className="text-text-normal">{detail.transportCodes.regionCode} / {detail.transportCodes.subRegionCode}</span>
|
||||
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Transport Codes</div>
|
||||
<div>
|
||||
<span className="text-text-dim">Scope code </span>
|
||||
<span className="text-text-normal">{transportCodeHex(detail.transportCodes.regionCode).toUpperCase()}</span>
|
||||
<span className="text-text-dim"> (2B) = </span>
|
||||
<span className="text-text-normal">{detail.transportCodes.regionCode}</span>
|
||||
<span className="text-text-dim"> → </span>
|
||||
{detail.scope ? <ScopeTag>{detail.scope}</ScopeTag> : <span className="text-text-dim">no known scope</span>}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-text-dim">Code 2 </span>
|
||||
<span className="text-text-normal">{transportCodeHex(detail.transportCodes.subRegionCode).toUpperCase()}</span>
|
||||
<span className="text-text-dim"> (2B) = </span>
|
||||
<span className="text-text-normal">{detail.transportCodes.subRegionCode}</span>
|
||||
</div>
|
||||
</ColorAccentField>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,9 +2,6 @@
|
||||
import { Fragment, useState } from "react";
|
||||
import type { PacketDetail, Observation } from "../../types/api";
|
||||
import { RouteType, PayloadType } from "../../types/enums";
|
||||
import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
|
||||
import { Timestamp } from "../../components/Timestamp";
|
||||
import { IataChip } from "../../components/IataChip";
|
||||
|
||||
// Advert device-role (ADV_TYPE) low-nibble names, shared with the DISCOVER payload renderers.
|
||||
export const DEVICE_ROLE_NAMES: Record<number, string> = {
|
||||
@@ -23,15 +20,21 @@ export interface ByteRange {
|
||||
end: number;
|
||||
}
|
||||
|
||||
// A transport code as its two on-air bytes (little-endian uint16).
|
||||
export function transportCodeHex(code: number): string {
|
||||
return [code & 0xff, (code >> 8) & 0xff].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// Reconstruct the full on-air frame for one observer:
|
||||
// header.raw + pathLength.raw + pathBytes + rawPayload
|
||||
// header.raw + transport codes + pathLength.raw + pathBytes + rawPayload
|
||||
// Header/payload are packet-scope (observer-independent); the path is per-observer.
|
||||
export function buildObservationFrame(detail: PacketDetail, obs: Observation | null): string {
|
||||
// Header and path-length are each one on-air byte that computeFieldRanges always reserves, so force
|
||||
// every value to a full 2-char byte: pad a stray nibble, and coerce a missing/empty value to "00"
|
||||
// rather than dropping a byte and shifting every field after it out of alignment.
|
||||
const pad = (b?: string) => (!b ? "00" : b.length === 1 ? "0" + b : b);
|
||||
const header = pad(detail.header.raw);
|
||||
const tc = detail.transportCodes;
|
||||
const header = pad(detail.header.raw) + (tc ? transportCodeHex(tc.regionCode) + transportCodeHex(tc.subRegionCode) : "");
|
||||
const payload = detail.rawPayload ?? "";
|
||||
if (!obs) return header + payload;
|
||||
const pathLen = pad(obs.pathLength.raw);
|
||||
@@ -396,36 +399,3 @@ export function ColorAccentField({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ObservationDetail({ observation }: { observation: Observation }) {
|
||||
const level = snrLevel(observation.snr);
|
||||
const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 font-mono text-[13px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-normal font-semibold">{observation.observerName ?? observation.observerId.slice(0, 8)}</span>
|
||||
<IataChip>{observation.iata}</IataChip>
|
||||
<Timestamp value={observation.heardAt} className="text-text-dim ml-auto text-[13px]" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-0.5 text-[13px]">
|
||||
<span><span className="text-text-dim">SNR </span><span className={sigClass}>{formatSnr(observation.snr)}</span></span>
|
||||
<span><span className="text-text-dim">RSSI </span><span className={sigClass}>{observation.rssi ?? "—"}</span></span>
|
||||
<span><span className="text-text-dim">Prop </span><span className="text-text-normal">{formatPropagation(observation.propagationTimeMs)}</span></span>
|
||||
<span><span className="text-text-dim">Hops </span><span className="text-text-normal">{observation.pathLength.hopCount}</span></span>
|
||||
</div>
|
||||
|
||||
{observation.radio && (
|
||||
<div className="flex items-center gap-1.5 text-[13px] text-text-muted">
|
||||
<span className="text-text-dim text-xs font-medium uppercase tracking-wider mr-0.5">Radio</span>
|
||||
{observation.radio.freqMhz != null && <span>{observation.radio.freqMhz} MHz</span>}
|
||||
{observation.radio.spreadFactor != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>SF{observation.radio.spreadFactor}</span></>}
|
||||
{observation.radio.bandwidthKhz != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>{observation.radio.bandwidthKhz} kHz</span></>}
|
||||
{observation.radio.codingRate != null && <><span className="text-[6px] text-border" aria-hidden>·</span><span>CR 4/{observation.radio.codingRate}</span></>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,14 @@ describe("ObservationCard", () => {
|
||||
expect(tokens(name)).toEqual(expect.arrayContaining(["flex-1", "min-w-0", "truncate"]));
|
||||
});
|
||||
|
||||
it("shows the observer's radio settings", () => {
|
||||
render(<ObservationCard observation={obs({ radio: { freqMhz: 910.525, spreadFactor: 7, bandwidthKhz: 62.5, codingRate: 5 } })} />);
|
||||
expect(screen.getByText("910.525 MHz")).toBeInTheDocument();
|
||||
expect(screen.getByText("SF7")).toBeInTheDocument();
|
||||
expect(screen.getByText("62.5 kHz")).toBeInTheDocument();
|
||||
expect(screen.getByText("CR 4/5")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lays the four stats out as equal grid columns that can shrink", () => {
|
||||
render(<ObservationCard observation={obs()} />);
|
||||
|
||||
|
||||
@@ -67,12 +67,12 @@ describe("packet reception evidence", () => {
|
||||
expect(within(section).queryByText(/Reports include all received regions/, { selector: "p" })).not.toBeInTheDocument();
|
||||
expect(within(section).queryByText(/Mapping needs at least two/, { selector: "p" })).not.toBeInTheDocument();
|
||||
});
|
||||
it("sits below the selected observation, above the raw packet", () => {
|
||||
it("shows the selected report only in the observations list, above the raw packet", () => {
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={reports()} selectedObservationId={1} onClose={() => {}} /></MemoryRouter>);
|
||||
const list = screen.getByRole("region", { name: "Observations" });
|
||||
const detail = screen.getByText("Observation", { exact: true });
|
||||
expect(screen.queryByText("Observation", { exact: true })).not.toBeInTheDocument();
|
||||
expect(within(list).getByRole("button", { name: /Inspect report.*Alpha/ })).toHaveAttribute("aria-pressed", "true");
|
||||
const raw = screen.getByText("Raw Packet");
|
||||
expect(detail.compareDocumentPosition(list) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(list.compareDocumentPosition(raw) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
it("reports an expired selection without quietly showing another report", () => {
|
||||
@@ -106,6 +106,33 @@ describe("packet reception evidence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transport scope", () => {
|
||||
const transport = () => {
|
||||
const d = makeDetail([]);
|
||||
d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" };
|
||||
d.transportCodes = { regionCode: 49240, subRegionCode: 0 };
|
||||
d.scope = "#yow";
|
||||
return d;
|
||||
};
|
||||
it("boxes the summary scope chip like the type badge", () => {
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={transport()} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
|
||||
const chip = within(screen.getByText("Summary").parentElement!).getByText("#yow");
|
||||
expect((chip.getAttribute("class") ?? "").split(/\s+/)).toEqual(expect.arrayContaining(["border", "px-2", "py-0.5"]));
|
||||
});
|
||||
it("breaks the transport codes out of the frame and shows the matched scope", () => {
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={transport()} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
|
||||
const field = screen.getByText("Transport Codes").parentElement!;
|
||||
expect(field).toHaveTextContent("Scope code 58C0 (2B) = 49240");
|
||||
expect(within(field).getByText("#yow")).toBeInTheDocument();
|
||||
expect(field).toHaveTextContent("Code 2 0000 (2B) = 0");
|
||||
});
|
||||
it("says when a scope code matched no known scope", () => {
|
||||
const d = transport(); delete d.scope;
|
||||
render(<MemoryRouter><PacketAnalyzerDrawer detail={d} selectedObservationId={null} onClose={() => {}} /></MemoryRouter>);
|
||||
expect(screen.getByText("Transport Codes").parentElement!).toHaveTextContent("no known scope");
|
||||
});
|
||||
});
|
||||
|
||||
const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] });
|
||||
|
||||
function makeDetail(resolvedPath: unknown[]): PacketDetail {
|
||||
|
||||
@@ -147,3 +147,16 @@ describe("computeFieldRanges — ANON_REQ (lean backend shape, no `type` field)"
|
||||
expect(ranges.ciphertext).toEqual({ start: 36, end: 46 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("transport frames", () => {
|
||||
it("puts the transport codes back after the header as little-endian bytes", () => {
|
||||
const d = makeDetail({ payloadType: PayloadType.GROUP_TEXT, rawPayload: "3b5c22" });
|
||||
d.header = { ...d.header, raw: "14", routeType: RouteType.TRANSPORT_FLOOD, routeTypeName: "TRANSPORT_FLOOD" };
|
||||
d.transportCodes = { regionCode: 49240, subRegionCode: 0 };
|
||||
const obs = makeObs({ raw: "81", hashSize: 3, hopCount: 1, pathBytes: "bd6089" });
|
||||
const frame = buildObservationFrame(d, obs);
|
||||
expect(frame).toBe("14" + "58c00000" + "81" + "bd6089" + "3b5c22");
|
||||
const ranges = computeFieldRanges(d, obs, frame.length / 2);
|
||||
expect(frame.slice(ranges.pathLength!.start * 2, ranges.pathLength!.end * 2)).toBe("81");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user