mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 03:01:37 +00:00
Keep dev1 Atlas compact and packet-only; publish isolated dev1 images
This commit is contained in:
1 parent
7423db2c68
commit
5692b7760d
14 files changed
+116
-37
No files matched your search
@@ -4,7 +4,7 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main, dev, dev1]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -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.
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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 <svg width="100%" height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} preserveAspectRatio="none" className="beacon-sparkline mt-1 overflow-visible" style={{color}} aria-hidden>
|
||||
<title>{t("charts.gapLegend")}</title>
|
||||
{known.length === 0 && <line data-no-data x1="0" x2={SPARK_W} y1={SPARK_H/2} y2={SPARK_H/2} stroke={color} strokeWidth="0.6" strokeDasharray="2 4" opacity="0.18" />}
|
||||
{bridges.map(([a,b]) => <line key={`gap-${a}`} data-gap x1={x(a)} y1={y(values[a]!)} x2={x(b)} y2={y(values[b]!)} stroke={color} strokeWidth="1.3" strokeDasharray="3 3" opacity="0.55" vectorEffect="non-scaling-stroke" />)}
|
||||
{runs.map(run => run.length > 1 ? <polyline key={run[0]} fill="none" stroke={color} strokeWidth="1.8" points={run.join(" ")} vectorEffect="non-scaling-stroke" /> : <polyline key={run[0]} data-sample points={`${run[0]} ${run[0]}`} fill="none" stroke={color} strokeWidth="3" strokeLinecap="round" vectorEffect="non-scaling-stroke" />)}
|
||||
{markPeak && known.length > 0 && <line data-peak x1={x(peak)} x2={x(peak)} y1={y(max)} y2={y(max)} stroke={color} strokeWidth="4" strokeLinecap="round" vectorEffect="non-scaling-stroke" />}
|
||||
</svg>;
|
||||
}
|
||||
@@ -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 <div className="min-w-0">
|
||||
<div className="mb-1 flex flex-col gap-0.5"><span className="text-xs">{label}</span><span className="whitespace-nowrap font-mono text-sm font-semibold text-text-bright">{value === null ? "—" : text}</span></div>
|
||||
<div role={value === null ? "img" : "meter"} aria-label={`${label}: ${text}`} aria-valuemin={value === null ? undefined : min} aria-valuemax={value === null ? undefined : max} aria-valuenow={value === null ? undefined : Math.max(min, Math.min(max, value))} aria-valuetext={text} className="flex h-4 gap-0.5">
|
||||
{Array.from({ length: 16 }, (_, index) => <span key={index} aria-hidden="true" className={`flex-1 rounded-[2px] ${index < level ? color : "bg-border-subtle"}`} />)}
|
||||
</div>
|
||||
return <div className="min-w-0" role={value===null ? "img" : "meter"} aria-label={`${label}: ${text}`} aria-valuemin={min} aria-valuemax={max} aria-valuenow={value===null ? undefined : Math.max(min,Math.min(max,value))} aria-valuetext={text}>
|
||||
<div className="flex justify-between gap-1 text-[10px]"><span>{label}</span><span className="font-mono font-semibold" style={{color}}>{value===null ? "—" : text}</span></div>
|
||||
<Sparkline values={values} color={color} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -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 <article aria-label={name} className="min-w-0 self-start overflow-hidden rounded-xl border border-border bg-bg-surface shadow-sm">
|
||||
<div className="h-1 bg-gradient-to-r from-primary via-secondary to-green" />
|
||||
<div className="space-y-4 p-4">
|
||||
<header>
|
||||
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 <article aria-label={name} className="flex min-w-0 h-full flex-col overflow-hidden rounded-sm border border-border bg-bg-surface shadow-sm">
|
||||
<div className="h-1 shrink-0 bg-gradient-to-r from-primary via-secondary to-green" />
|
||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||
<header className="min-h-12">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="min-w-0 break-words text-base font-semibold text-text-bright"><button type="button" disabled={!node} onClick={() => node && onViewNode(node.id)} className="text-left hover:underline">{name}</button></h2>
|
||||
<span className="shrink-0 rounded bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary">{t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })}</span>
|
||||
<h2 className="min-w-0 break-words text-sm font-semibold text-text-bright"><button type="button" disabled={!node} onClick={() => node && onViewNode(node.id)} className="text-left hover:underline">{name}</button></h2>
|
||||
<span className="shrink-0 rounded-sm bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary">{t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })}</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full ${node?.stale === false ? "bg-green" : node?.stale ? "bg-warn" : "bg-text-muted"}`} />
|
||||
@@ -62,23 +60,23 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo
|
||||
{query.isPending ? <p role="status">{t("common.loading")}</p> : query.isError && <p role="status" className="text-warn">{t(node ? "atlas.refreshFailed" : "atlas.nodeUnavailable")} <button type="button" className="min-h-11 underline" onClick={() => void query.refetch()}>{t("atlas.retry")}</button></p>}
|
||||
{node && <>
|
||||
<section aria-label={t("atlas.activity")}>
|
||||
<div className="flex items-baseline justify-between gap-2"><span className="text-xs">{t("atlas.reports")}</span><strong className="font-mono text-xl text-text-bright">{number(sample.reports.length)}</strong></div>
|
||||
<svg role="img" aria-label={t("atlas.activityLabel", { count: sample.reports.length, range: t(`atlas.ranges.${range}`) })} viewBox={`0 0 ${sample.bins.length * 5} 40`} preserveAspectRatio="none" className="mt-2 h-14 w-full text-primary">
|
||||
{sample.bins.map((count, index) => <rect key={index} x={index * 5} y={40 - Math.max(1, count / maximum * 38)} width="3.5" height={Math.max(1, count / maximum * 38)} rx="0.6" fill={count ? "currentColor" : "var(--color-border)"}><title>{time(sample.since + index * 3_600_000)}: {count}</title></rect>)}
|
||||
<div className="flex items-baseline justify-between gap-2"><span className="text-[11px]">{t("atlas.reports")}</span><strong className="font-mono text-base text-text-bright">{number(sample.reports.length)}</strong></div>
|
||||
<svg role="img" aria-label={t("atlas.activityLabel", { count: sample.reports.length, range: t(`atlas.ranges.${range}`) })} viewBox={`0 0 ${sample.bins.length * 5} 40`} preserveAspectRatio="none" className="mt-1 h-8 w-full text-primary">
|
||||
{sample.bins.map((count, index) => <rect key={index} x={index * 5} y={40 - Math.max(1, count / maximum * 38)} width="3.5" height={Math.max(1, count / maximum * 38)} rx="0" fill={count ? "currentColor" : "var(--color-border)"}><title>{time(sample.since + index * 3_600_000)}: {count}</title></rect>)}
|
||||
</svg>
|
||||
<div className="mt-1 flex justify-between text-[11px] text-text-muted"><span>{t(`atlas.ranges.${range}`)}</span><span>{t("atlas.now")}</span></div>
|
||||
<p className="mt-1 text-[11px] text-text-normal">{t("atlas.sample", { limit: REPORT_LIMIT })}</p>
|
||||
<p className="text-[10px] text-text-muted">{t("atlas.sample", { limit: REPORT_LIMIT })}</p>
|
||||
</section>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<SignalMeter label={t("atlas.snr")} value={sample.snr} min={-30} max={30} unit="dB" color="bg-green" />
|
||||
<SignalMeter label={t("atlas.rssi")} value={sample.rssi} min={-140} max={0} unit="dBm" color="bg-secondary" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<SignalMeter label={t("atlas.snr")} value={sample.snr} values={sample.snrBins} min={-30} max={30} unit="dB" color="var(--color-green)" />
|
||||
<SignalMeter label={t("atlas.rssi")} value={sample.rssi} values={sample.rssiBins} min={-140} max={0} unit="dBm" color="var(--color-secondary)" />
|
||||
</div>
|
||||
{!sample.reports.length && <p className="text-xs">{t("atlas.noReports")}</p>}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button type="button" className={button} aria-expanded={heardOpen} aria-controls={`${id}-heard`} onClick={onHeardToggle}>{t("atlas.heardBy")} <span aria-hidden="true">{heardOpen ? "−" : "+"}</span></button>
|
||||
<button type="button" className={button} onClick={() => onViewNode(node.id)}>{t("atlas.openNode")} <span aria-hidden="true">↗</span></button>
|
||||
</div>
|
||||
{heardOpen && <section id={`${id}-heard`} aria-label={t("atlas.heardBy")} className="rounded-lg border border-border p-3 text-xs">
|
||||
{heardOpen && <section id={`${id}-heard`} aria-label={t("atlas.heardBy")} className="rounded-sm border border-border p-3 text-xs">
|
||||
<p className="mb-2 text-text-bright">{t("atlas.latestAdvert")}</p>
|
||||
{!latest ? <p>{t("atlas.noReports")}</p> : heard.isPending ? <p role="status">{t("common.loading")}</p> : heard.isError || !correctOrigin ? <p>{t("atlas.heardUnavailable")}</p> : <>
|
||||
<p className="mb-2">{t("atlas.observers", { count: observers.length })}</p>
|
||||
@@ -87,7 +85,7 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo
|
||||
</>}
|
||||
</section>}
|
||||
<details className="text-xs">
|
||||
<summary className="min-h-8 cursor-pointer content-center text-text-normal">{t("atlas.statistics")}</summary>
|
||||
<summary className="min-h-7 cursor-pointer content-center text-text-normal">{t("atlas.statistics")}</summary>
|
||||
<div className="space-y-3 pt-2">
|
||||
<p>{t("atlas.definition")}</p>
|
||||
<p>{t("atlas.signalDefinition")}</p>
|
||||
@@ -99,13 +97,13 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo
|
||||
<dt>{t("atlas.scope")}</dt><dd className="break-words text-right">{node.defaultScope || "—"}</dd>
|
||||
</dl>
|
||||
<p className="break-all font-mono text-[10px]">{node.publicKey}</p>
|
||||
<details><summary className="min-h-8 cursor-pointer content-center">{t("atlas.chartValues")}</summary><div className="max-h-40 overflow-auto"><table className="w-full"><thead><tr><th className="text-left">{t("atlas.hour")}</th><th className="text-right">{t("atlas.reports")}</th></tr></thead><tbody>{sample.bins.map((count, index) => <tr key={index}><td>{time(sample.since + index * 3_600_000)}</td><td className="text-right">{count}</td></tr>)}</tbody></table></div></details>
|
||||
<details><summary className="min-h-7 cursor-pointer content-center">{t("atlas.chartValues")}</summary><div className="max-h-40 overflow-auto"><table className="w-full"><thead><tr><th className="text-left">{t("atlas.hour")}</th><th className="text-right">{t("atlas.reports")}</th></tr></thead><tbody>{sample.bins.map((count, index) => <tr key={index}><td>{time(sample.since + index * 3_600_000)}</td><td className="text-right">{count}</td></tr>)}</tbody></table></div></details>
|
||||
<p>{t("atlas.updated")} <Timestamp value={query.dataUpdatedAt} /></p>
|
||||
</div>
|
||||
</details>
|
||||
</>}
|
||||
<details className="border-t border-border-subtle pt-2 text-xs">
|
||||
<summary className="min-h-8 cursor-pointer content-center">{t("atlas.manage")}</summary>
|
||||
<details className="mt-auto border-t border-border-subtle pt-1 text-xs">
|
||||
<summary className="min-h-7 cursor-pointer content-center">{t("atlas.manage")}</summary>
|
||||
<div className="mt-2 flex flex-wrap gap-2"><button type="button" className={button} disabled={first} onClick={() => onMove(-1)}>{t("atlas.earlier")}</button><button type="button" className={button} disabled={last} onClick={() => onMove(1)}>{t("atlas.later")}</button><button type="button" className={`${button} text-danger`} onClick={onRemove}>{t("atlas.remove")}</button></div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
|
||||
{(["24h", "3d"] as AtlasRange[]).map(range => <button type="button" key={range} aria-pressed={saved.range === range} className={`min-h-10 rounded-md px-4 text-xs font-medium ${saved.range === range ? "bg-primary/15 text-primary" : "hover:bg-bg-raised"}`} onClick={() => update(value => ({ ...value, range }))}>{t(`atlas.ranges.${range}`)}</button>)}
|
||||
</div>
|
||||
</header>
|
||||
<details open={searchOpen} onToggle={event => setSearchOpen(event.currentTarget.open)} className="rounded-xl border border-border bg-bg-surface px-4 py-2">
|
||||
<details open={searchOpen} onToggle={event => setSearchOpen(event.currentTarget.open)} className="rounded-sm border border-border bg-bg-surface px-4 py-2">
|
||||
<summary className="min-h-11 cursor-pointer content-center text-sm text-text-bright">{t("atlas.addNodes")} <span className="ml-2 text-xs font-mono text-text-normal">{t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}</span></summary>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<div className="flex min-w-0 flex-1 basis-72 items-center gap-2">
|
||||
@@ -58,7 +58,8 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
|
||||
</div>}
|
||||
</details>
|
||||
<p className={`text-xs ${storageFailed ? "text-warn" : "text-text-normal"}`} role="status">{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}</p>
|
||||
{empty ? <section className="rounded-xl border border-dashed border-border px-5 py-12 text-center"><h2 className="text-lg font-medium text-text-bright">{t("atlas.emptyTitle")}</h2><p className="mx-auto mt-2 max-w-lg text-sm">{t("atlas.emptyDescription")}</p><div aria-hidden="true" className="mx-auto mt-6 flex h-10 max-w-48 items-end justify-center gap-1 opacity-40">{[3, 5, 4, 8, 6, 10, 7, 9, 6, 11, 8, 10].map((height, index) => <span key={index} className="w-3 rounded-t bg-primary" style={{ height: height * 3 }} />)}</div></section> : <div className="grid gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||
<p className="text-[10px] text-text-muted">{t("charts.gapLegend")}</p>
|
||||
{empty ? <section className="rounded-sm border border-dashed border-border px-5 py-12 text-center"><h2 className="text-lg font-medium text-text-bright">{t("atlas.emptyTitle")}</h2><p className="mx-auto mt-2 max-w-lg text-sm">{t("atlas.emptyDescription")}</p><div aria-hidden="true" className="mx-auto mt-6 flex h-10 max-w-48 items-end justify-center gap-1 opacity-40">{[3, 5, 4, 8, 6, 10, 7, 9, 6, 11, 8, 10].map((height, index) => <span key={index} className="w-3 rounded-t bg-primary" style={{ height: height * 3 }} />)}</div></section> : <div className="grid items-stretch gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{saved.nodes.map((pin, index) => <AtlasCard key={pin.publicKey} pin={pin} range={saved.range} active={active} first={index === 0} last={index === saved.nodes.length - 1} heardOpen={heardKey === pin.publicKey} onHeardToggle={() => 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;
|
||||
|
||||
@@ -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)) };
|
||||
}
|
||||
@@ -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).",
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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(<Sparkline values={values} color="cyan" />);
|
||||
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(<Sparkline values={[1, 2, 3]} times={[0, 1, 100]} gapMs={10} color="green" />);
|
||||
expect(container.querySelectorAll("[data-gap]")).toHaveLength(1);
|
||||
rerender(<Sparkline values={[0]} color="green" />);
|
||||
expect(container.querySelectorAll("[data-sample]")).toHaveLength(1);
|
||||
expect(container.querySelectorAll("[data-gap]")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user