Analyzer: one observations list, transport scope breakdown (#112)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-09-30 21:18:10 -04:00
1 parent e2d272e08e
commit 85e4d90cbe
7 files changed
+92 -54

No files matched your search

+7 -2
View File
@@ -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>
);
+9
View File
@@ -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 && (
+17 -11
View File
@@ -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>
)}
+8 -38
View File
@@ -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");
});
});