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 ;
+}
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") })}
@@ -62,23 +60,23 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo
{query.isPending ?
{t("common.loading")}
: query.isError &&
{t(node ? "atlas.refreshFailed" : "atlas.nodeUnavailable")}
}
{node && <>
- {t("atlas.reports")}{number(sample.reports.length)}
-
-
-
-
+
+
+
{!sample.reports.length &&
{t("atlas.noReports")}
}
- {heardOpen &&
+ {heardOpen &&
{t("atlas.latestAdvert")}
{!latest ? {t("atlas.noReports")}
: heard.isPending ? {t("common.loading")}
: heard.isError || !correctOrigin ? {t("atlas.heardUnavailable")}
: <>
{t("atlas.observers", { count: observers.length })}
@@ -87,7 +85,7 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo
>}
}
- {t("atlas.statistics")}
+ {t("atlas.statistics")}
{t("atlas.definition")}
{t("atlas.signalDefinition")}
@@ -99,13 +97,13 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo
{t("atlas.scope")}{node.defaultScope || "—"}
{node.publicKey}
-
{t("atlas.chartValues")}
| {t("atlas.hour")} | {t("atlas.reports")} |
{sample.bins.map((count, index) => | {time(sample.since + index * 3_600_000)} | {count} |
)}
+
{t("atlas.chartValues")}
| {t("atlas.hour")} | {t("atlas.reports")} |
{sample.bins.map((count, index) => | {time(sample.since + index * 3_600_000)} | {count} |
)}
{t("atlas.updated")}
>}
-
- {t("atlas.manage")}
+
+ {t("atlas.manage")}
diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx
index ed5beb4..b38c908 100644
--- a/src/features/atlas/MyAtlasPage.tsx
+++ b/src/features/atlas/MyAtlasPage.tsx
@@ -33,7 +33,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
{(["24h", "3d"] as AtlasRange[]).map(range =>
)}
-
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")}
{[3, 5, 4, 8, 6, 10, 7, 9, 6, 11, 8, 10].map((height, index) => )}
:
+
{t("charts.gapLegend")}
+ {empty ?
{t("atlas.emptyTitle")}
{t("atlas.emptyDescription")}
{[3, 5, 4, 8, 6, 10, 7, 9, 6, 11, 8, 10].map((height, index) => )}
:
{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);