From 5692b7760d365db4efa3144651b4e4686a53218a Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 9 Oct 2026 22:07:42 -0400 Subject: [PATCH] Keep dev1 Atlas compact and packet-only; publish isolated dev1 images --- .github/workflows/ci.yml | 2 +- .github/workflows/docker-publish.yml | 3 +- docs/my-atlas.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/components/Sparkline.tsx | 31 ++++++++++++++ src/features/atlas/AtlasCard.tsx | 50 +++++++++++------------ src/features/atlas/MyAtlasPage.tsx | 5 ++- src/features/atlas/atlas.ts | 5 ++- src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- tests/components/Sparkline.test.tsx | 21 ++++++++++ tests/features/atlas/MyAtlasPage.test.tsx | 10 +++++ tests/features/atlas/atlas.test.ts | 10 +++++ 14 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 src/components/Sparkline.tsx create mode 100644 tests/components/Sparkline.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5355c1..8d2f8e4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ name: CI on: push: - branches: [main, dev] + branches: [main, dev, dev1] pull_request: jobs: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c5cd733..497dd6b 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and Publish Docker Image on: push: - branches: [main, dev] + branches: [main, dev, dev1] tags: ["v*"] env: @@ -42,6 +42,7 @@ jobs: tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=raw,value=dev1,enable=${{ github.ref == 'refs/heads/dev1' }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha diff --git a/docs/my-atlas.md b/docs/my-atlas.md index e3b6985..ebd3393 100644 --- a/docs/my-atlas.md +++ b/docs/my-atlas.md @@ -28,7 +28,7 @@ An unavailable node stays in the collection and remains removable. - **Mean SNR / RSSI** average finite, available measurements from that sample. The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR with a real RSSI is included. These are last-hop measurements at the observers, - not a measurement of the origin's radio health. Meters include numbers and + not a measurement of the origin's radio health. Signal charts include numbers and accessible values; missing measurements display a dash. - Freshness uses the server's `stale` verdict. It does not infer online/offline status from the report count or invent a local threshold. @@ -60,3 +60,5 @@ exact-key recovery, rename display, bounded/lazy loading, missing signals, expired evidence, range selection, order/removal and both languages. Release QA also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, and the matching public source offer. + +This basic Atlas build uses packet observations only. It includes no battery, environmental or Collector telemetry integration. Dashed signal-chart segments indicate estimates across missing samples; empty hours are not measured zeroes. diff --git a/package-lock.json b/package-lock.json index 797f55b..b8457bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.2", + "version": "2.0.3-dev1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.2", + "version": "2.0.3-dev1.1", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 137b014..3091f4e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.2", + "version": "2.0.3-dev1.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx new file mode 100644 index 0000000..cdd9e96 --- /dev/null +++ b/src/components/Sparkline.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "react-i18next"; + +const SPARK_W = 120; +const SPARK_H = 24; + +export function Sparkline({ values, color, markPeak, times, gapMs }: { values: (number | null)[]; color: string; markPeak?: boolean; times?: number[]; gapMs?: number }) { + const { t } = useTranslation(); + const known = values.filter((v): v is number => v !== null && Number.isFinite(v)); + const max = known.length ? Math.max(...known) : 0, min = known.length ? Math.min(...known) : 0; + const range = max - min; + const timed = times?.length === values.length && times.every((v,i) => Number.isFinite(v) && (!i || v > times[i-1]!)) && times.at(-1)! > times[0]! ? times : null; + const x = (i: number) => timed ? 2 + (timed[i]! - timed[0]!) / (timed.at(-1)! - timed[0]!) * (SPARK_W - 4) : values.length > 1 ? 2 + i / (values.length - 1) * (SPARK_W - 4) : SPARK_W / 2; + const y = (v: number) => range ? SPARK_H - 3 - (v - min) / range * (SPARK_H - 6) : SPARK_H / 2; + const runs: string[][] = [], bridges: [number,number][] = []; + let previous = -1; + values.forEach((v,i) => { + if (v === null || !Number.isFinite(v)) return; + const gap = previous >= 0 && (i > previous + 1 || !!(timed && gapMs && timed[i]! - timed[previous]! > gapMs)); + if (previous < 0 || gap) runs.push([]); + if (gap) bridges.push([previous,i]); + runs.at(-1)!.push(`${x(i)},${y(v)}`); previous = i; + }); + const peak = values.indexOf(max); + return + {t("charts.gapLegend")} + {known.length === 0 && } + {bridges.map(([a,b]) => )} + {runs.map(run => run.length > 1 ? : )} + {markPeak && known.length > 0 && } + ; +} diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 1ddd095..bc0cd36 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -1,3 +1,4 @@ +import { Sparkline } from "../../components/Sparkline"; import { useId } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; @@ -12,15 +13,12 @@ export interface AtlasActions { onAnalyzePacket: (hash: string, observationId?: number) => void; } -function SignalMeter({ label, value, min, max, unit, color }: { label: string; value: number | null; min: number; max: number; unit: string; color: string }) { +function SignalMeter({ label, value, values, min, max, unit, color }: { label: string; value: number | null; values: (number|null)[]; min: number; max: number; unit: string; color: string }) { const { t, i18n } = useTranslation(); const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; - const level = value === null ? 0 : Math.round(16 * Math.max(0, Math.min(1, (value - min) / (max - min)))); - return
-
{label}{value === null ? "—" : text}
-
- {Array.from({ length: 16 }, (_, index) =>
+ return
+
{label}{value===null ? "—" : text}
+
; } @@ -44,14 +42,14 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); const maximum = Math.max(1, ...sample.bins); const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); - const button = "min-h-11 rounded border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; - return
-
-
-
+ const button = "min-h-8 rounded-sm border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; + return
+
+
+
-

- {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })} +

+ {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })}
-
setSearchOpen(event.currentTarget.open)} className="rounded-xl border border-border bg-bg-surface px-4 py-2"> +
setSearchOpen(event.currentTarget.open)} className="rounded-sm border border-border bg-bg-surface px-4 py-2"> {t("atlas.addNodes")} {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}
@@ -58,7 +58,8 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
}

{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}

- {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
+

{t("charts.gapLegend")}

+ {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
{saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; if (from < 0 || to < 0 || to >= nodes.length) return value; diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index 7662b65..d5074f8 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -62,5 +62,8 @@ export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; }; - return { reports, bins, since, snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; + const hourly = (field: "snr" | "rssi") => bins.map((_, index) => mean(signal + .filter(row => Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)) === index) + .map(row => row[field]))); + return { reports, bins, since, snrBins: hourly("snr"), rssiBins: hourly("rssi"), snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9ed728a..b258d6c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -852,7 +852,8 @@ }, "charts": { "uniquePackets": "Unique packets", - "busy": "Busy" + "busy": "Busy", + "gapLegend": "Solid lines and dots show readings. Dashed lines estimate between missing samples." }, "packetList": { "pathSearchInvalid": "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4).", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d9d5dde..dd315ce 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -886,7 +886,8 @@ }, "charts": { "uniquePackets": "Paquets uniques", - "busy": "Occupation" + "busy": "Occupation", + "gapLegend": "Traits pleins et points : mesures. Tirets : estimation entre des données manquantes." }, "packetList": { "pathSearchInvalid": "Saisissez des hashs de saut complets de même longueur, séparés par des espaces, des virgules ou → (p. ex. 7f a4).", diff --git a/tests/components/Sparkline.test.tsx b/tests/components/Sparkline.test.tsx new file mode 100644 index 0000000..fbf13de --- /dev/null +++ b/tests/components/Sparkline.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { Sparkline } from "../../src/components/Sparkline"; + +describe("sparkline evidence", () => { + it("draws isolated measured zeros and dashed bridges, without changing the input", () => { + const values = [null, 0, null, 4, 5, null]; + const { container } = render(); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); + expect(values).toEqual([null, 0, null, 4, 5, null]); + }); + it("marks a long time gap and a single reading, without extrapolating measured points", () => { + const { container, rerender } = render(); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + rerender(); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(0); + }); +}); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 3c19fec..210f35c 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -34,6 +34,16 @@ beforeEach(() => { afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); describe("My Atlas", () => { + it("shows packet activity and signal charts without telemetry panels", async () => { + save(); mount(); + const card = await screen.findByRole("article", { name: "Renamed repeater" }); + await within(card).findByRole("button", { name: "Open node" }); + expect(within(card).getByRole("img", { name: /received reports/i })).toBeInTheDocument(); + expect(within(card).getByRole("meter", { name: "Mean SNR: 0 dB" })).toBeInTheDocument(); + expect(within(card).queryByText(/battery|temperature|humidity|telemetry/i)).not.toBeInTheDocument(); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + }); + it("does not scan nodes or packets before a search or selection", () => { mount(); expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts index 35e1c01..cee540a 100644 --- a/tests/features/atlas/atlas.test.ts +++ b/tests/features/atlas/atlas.test.ts @@ -20,6 +20,16 @@ describe("saved Atlas", () => { }); describe("origin report sample", () => { + it("aligns signal charts with the activity window and preserves missing hours", () => { + const sample = summarizeReports([report(1, 0, { snr: 0, rssi: -100 }), report(2, 2, { snr: 8, rssi: -90 }), report(3, 24, { snr: 20, rssi: -70 })], "24h", now); + expect(sample.snrBins[23]).toBe(0); + expect(sample.rssiBins[23]).toBe(-100); + expect(sample.snrBins[22]).toBe(8); + expect(sample.snrBins[21]).toBeNull(); + expect(sample.snrBins[0]).toBeNull(); + expect(sample.bins[23]).toBe(1); + }); + it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; const sample = summarizeReports(rows, "24h", now);