Merge branch 'main' into pr-339

# Conflicts:
#	frontend/AGENTS.md
This commit is contained in:
Jack Kingsman
2026-07-25 15:19:51 -07:00
12 changed files with 940 additions and 34 deletions
+22 -3
View File
@@ -41,9 +41,13 @@ frontend/src/
├── themes.css # Color theme definitions
├── contexts/
│ ├── DistanceUnitContext.tsx # Browser-local distance-unit context/provider
│ ├── PathHopWidthContext.tsx # Browser-local path hop-width display preference
│ ├── RichPayloadContext.tsx # Browser-local rich MeshCore payload rendering preference
│ └── PushSubscriptionContext.tsx # Push subscription state context/provider
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── networkGraph/
│ └── packetNetworkGraph.ts # Packet→network graph construction shared by visualizer surfaces
├── stores/
│ └── rawPacketStore.ts # Overheard packet stream + session stats, outside React
├── hooks/
@@ -62,6 +66,7 @@ frontend/src/
│ ├── useBrowserNotifications.ts # Per-conversation browser notification preferences + dispatch
│ ├── usePushSubscription.ts # Web Push subscription lifecycle, per-conversation filters
│ ├── useFaviconBadge.ts # Browser tab unread badge state
│ ├── useEntranceSettled.ts # Defers entrance animation work until layout settles
│ └── useRememberedServerPassword.ts # Browser-local repeater/room password persistence
├── components/
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals, security warning
@@ -83,6 +88,10 @@ frontend/src/
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── rawPacketStats.ts # Session packet stats windows, rankings, and coverage helpers
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
│ ├── meshcoreOpenPayloads.ts # Rich MeshCore Open payload detection/rendering helpers
│ ├── textReplace.ts # Shared message text substitution helpers
│ ├── pathHopWidthPreference.ts # LocalStorage persistence for hop-width display toggle
│ ├── richPayloadPreference.ts # LocalStorage persistence for rich payload rendering toggle
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
@@ -144,7 +153,7 @@ frontend/src/
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsRadioAppSection.tsx # Radio-App Management: tracked telemetry, contact management, blocked lists
│ │ ├── SettingsDatabaseSection.tsx # Database: DB size, storage cleanup, auto-decrypt
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats (incl. region-scope adoption)
│ │ ├── SettingsAboutSection.tsx # Version, author, license, links
│ │ ├── ThemeSelector.tsx # Color theme picker
│ │ └── BulkDeleteContactsModal.tsx # Bulk contact deletion dialog
@@ -155,6 +164,7 @@ frontend/src/
│ │ ├── RepeaterAclPane.tsx # Permission table
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
│ │ ├── RepeaterRegionsPane.tsx # Region hierarchy / flood-allowed region names
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
│ │ ├── RepeaterTelemetryHistoryPane.tsx # Historical telemetry chart/table
@@ -417,9 +427,9 @@ State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`.
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`. The frontend sends exactly one request; the backend internally escalates a timed-out login to one flood retry (see `app/AGENTS.md` § "Server login route escalation"), so a single call may take up to two response windows. Do not add a client-side login retry loop on top — a `LOGIN_FAILED` result means the password was refused, not that the route needs another attempt.
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Regions, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. The Regions pane prefers the admin CLI hierarchy and falls back to the guest anon flood-allowed names, so its payload carries a `source` of `cli` or `anon`. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
@@ -474,6 +484,15 @@ Key conventions documented in the reference:
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
### Region-scope adoption panel
`SettingsStatisticsSection.tsx` renders `stats.region_scope_24h` via `RegionScopeStatsPanel`. Two presentation rules exist because regional adoption is currently very sparse, and both are deliberate:
- **Fractions, not bare percentages.** "3 of 117" carries the sample size that "2.6%" hides.
- **The traffic percentage is withheld** when the scoped count is at or below `false_positive_floor` (corrupt-capture noise) or when the share would round to `0.0%`. The floor caveat is always shown alongside a non-zero scoped count. The sender figure is never suppressed — it requires successful decryption and so carries no noise.
Traffic and sender figures use different denominators (all channels vs. decryptable-only) and are not expected to match.
## Security Posture (intentional)
- No authentication UI.
@@ -13,12 +13,76 @@ import {
} from 'recharts';
import { Separator } from '../ui/separator';
import { api } from '../../api';
import type { StatisticsResponse } from '../../types';
import type { RegionScopeStats, StatisticsResponse } from '../../types';
function formatPercent(value: number): string {
return `${value.toFixed(1)}%`;
}
/**
* Regional flood-scope adoption. Deliberately shows fractions rather than bare
* percentages: with adoption this sparse, "3 of 117" communicates the sample
* size that "2.6%" hides.
*/
function RegionScopeStatsPanel({ stats }: { stats: RegionScopeStats }) {
// Corrupt RF captures land in the packet table with random headers, some of
// which claim to be region-scoped. At or below the measured floor there is
// nothing to report but noise, so withhold the percentage and say so.
const floor = stats.false_positive_floor;
const withinNoise = stats.scoped_messages <= floor;
// Real-world scoping is currently rare enough that the share rounds to "0.0%",
// which reads as a broken widget. Below that resolution the fraction alone is
// the honest presentation.
const showTrafficPct = stats.total_messages > 0 && !withinNoise && stats.scoped_pct >= 0.05;
return (
<div>
<h3 className="text-base font-semibold tracking-tight mb-2">Region Scope (24h)</h3>
<p className="text-[0.8125rem] text-muted-foreground mb-3">
How much local traffic uses regional flood scoping. Traffic covers all channel messages
heard, including channels you have no key for; senders only counts channels you can decrypt,
so the two use different denominators and will not match.
</p>
<div className="space-y-2">
<div className="flex justify-between items-center gap-4">
<span className="text-sm text-muted-foreground">Scoped messages</span>
<span className="font-medium text-right">
{stats.scoped_messages.toLocaleString()} of {stats.total_messages.toLocaleString()}
{showTrafficPct && (
<span className="text-muted-foreground"> ({formatPercent(stats.scoped_pct)})</span>
)}
</span>
</div>
<div className="flex justify-between items-center gap-4">
<span className="text-sm text-muted-foreground">Senders using regions</span>
<span className="font-medium text-right">
{stats.scoped_senders.toLocaleString()} of {stats.total_senders.toLocaleString()}
{stats.total_senders > 0 && (
<span className="text-muted-foreground">
{' '}
({formatPercent(stats.scoped_senders_pct)})
</span>
)}
</span>
</div>
</div>
{floor > 0 && stats.scoped_messages > 0 && (
<p className="text-[0.8125rem] text-muted-foreground mt-2">
{withinNoise
? `Scoped message count is at or below the estimated false-positive floor (${floor.toFixed(0)}) from corrupt packet captures, so it is not evidence of regional adoption.`
: `Includes an estimated ${floor.toFixed(0)} false positives from corrupt packet captures.`}{' '}
The sender count is unaffected it requires successful decryption.
</p>
)}
{stats.total_messages === 0 && (
<p className="text-sm text-muted-foreground mt-2">
No channel messages heard in the last 24 hours.
</p>
)}
</div>
);
}
const CHANNEL_BAR_COLORS = ['#0ea5e9', '#10b981', '#f59e0b', '#f43f5e', '#8b5cf6'];
const TOOLTIP_STYLE = {
@@ -404,6 +468,11 @@ export function SettingsStatisticsSection({ className }: { className?: string })
)}
</div>
<Separator />
{/* Region Scope */}
<RegionScopeStatsPanel stats={stats.region_scope_24h} />
{/* Busiest Channels */}
{stats.busiest_channels_24h.length > 0 && (
<>
+158
View File
@@ -777,6 +777,15 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
region_scope_24h: {
total_messages: 120,
scoped_messages: 40,
scoped_pct: 33.3,
false_positive_floor: 2,
total_senders: 12,
scoped_senders: 3,
scoped_senders_pct: 25,
},
packets_per_hour_72h: [
{ timestamp: 1711792800, count: 12 },
{ timestamp: 1711796400, count: 8 },
@@ -824,6 +833,146 @@ describe('SettingsModal', () => {
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
expect(screen.getByText('Busiest Channels (24h)')).toBeInTheDocument();
expect(screen.getByText('Noise Floor (24h)')).toBeInTheDocument();
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
// Fractions, not bare percentages — the sample size matters at this sparsity
expect(screen.getByText(/40 of 120/)).toBeInTheDocument();
expect(screen.getByText(/3 of 12/)).toBeInTheDocument();
// 40 scoped is well above the floor of 2, so the percentage is shown
expect(screen.getByText(/33\.3%/)).toBeInTheDocument();
expect(
screen.queryByText(/at or below the estimated false-positive floor/)
).not.toBeInTheDocument();
});
it('discloses the false-positive floor and withholds a sub-0.1% scoped share', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
contact_count: 0,
repeater_count: 0,
channel_count: 0,
total_packets: 0,
decrypted_packets: 0,
undecrypted_packets: 0,
total_dms: 0,
total_channel_messages: 0,
total_outgoing: 0,
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
path_hash_width_24h: {
total_packets: 0,
single_byte: 0,
double_byte: 0,
triple_byte: 0,
single_byte_pct: 0,
double_byte_pct: 0,
triple_byte_pct: 0,
},
// Mirrors real-world data: 70 "scoped" packets against a measured floor of
// 60 is corrupt-capture noise, not adoption.
region_scope_24h: {
total_messages: 391757,
scoped_messages: 70,
scoped_pct: 0.0179,
false_positive_floor: 60.3,
total_senders: 117,
scoped_senders: 3,
scoped_senders_pct: 2.56,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 60,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockStats), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
await waitFor(() => {
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
});
// 70 scoped sits just above the 60.3 floor, so most of it is corrupt captures
expect(screen.getByText(/Includes an estimated 60 false positives/)).toBeInTheDocument();
// 0.0179% would render as a meaningless "0.0%", so the share is withheld
expect(screen.queryByText(/0\.0%/)).not.toBeInTheDocument();
expect(screen.getByText(/70 of 391,757/)).toBeInTheDocument();
// ...but the decryption-backed sender figure still stands
expect(screen.getByText(/3 of 117/)).toBeInTheDocument();
expect(screen.getByText(/2\.6%/)).toBeInTheDocument();
});
it('reports scoped traffic as noise when it is at or below the floor', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
contact_count: 0,
repeater_count: 0,
channel_count: 0,
total_packets: 0,
decrypted_packets: 0,
undecrypted_packets: 0,
total_dms: 0,
total_channel_messages: 0,
total_outgoing: 0,
contacts_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
repeaters_heard: { last_hour: 0, last_24_hours: 0, last_week: 0 },
known_channels_active: { last_hour: 0, last_24_hours: 0, last_week: 0 },
path_hash_width_24h: {
total_packets: 0,
single_byte: 0,
double_byte: 0,
triple_byte: 0,
single_byte_pct: 0,
double_byte_pct: 0,
triple_byte_pct: 0,
},
region_scope_24h: {
total_messages: 5000,
scoped_messages: 12,
scoped_pct: 0.24,
false_positive_floor: 20,
total_senders: 40,
scoped_senders: 0,
scoped_senders_pct: 0,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 60,
coverage_seconds: 0,
latest_noise_floor_dbm: null,
latest_timestamp: null,
samples: [],
},
};
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(JSON.stringify(mockStats), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
);
renderModal({ externalSidebarNav: true, desktopSection: 'statistics' });
await waitFor(() => {
expect(screen.getByText('Region Scope (24h)')).toBeInTheDocument();
});
expect(
screen.getByText(/at or below the estimated false-positive floor \(20\)/)
).toBeInTheDocument();
// Percentage withheld even though 0.24% would round visibly — it is noise
expect(screen.queryByText(/0\.2%/)).not.toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
@@ -850,6 +999,15 @@ describe('SettingsModal', () => {
double_byte_pct: 30,
triple_byte_pct: 20,
},
region_scope_24h: {
total_messages: 0,
scoped_messages: 0,
scoped_pct: 0,
false_positive_floor: 0,
total_senders: 0,
scoped_senders: 0,
scoped_senders_pct: 0,
},
packets_per_hour_72h: [],
noise_floor_24h: {
sample_interval_seconds: 60,
+19
View File
@@ -674,6 +674,24 @@ interface PacketsPerHourBucket {
count: number;
}
/**
* Regional flood-scope adoption over the last 24h. Two views with different
* denominators that will not agree traffic spans all channels including
* undecryptable ones (so it carries a false-positive floor from corrupt RF
* captures), while senders requires decryption and is therefore noise-free but
* limited to channels we hold keys for.
*/
export interface RegionScopeStats {
total_messages: number;
scoped_messages: number;
scoped_pct: number;
/** Estimated false positives in scoped_messages. At or below this = not adoption. */
false_positive_floor: number;
total_senders: number;
scoped_senders: number;
scoped_senders_pct: number;
}
export interface StatisticsResponse {
busiest_channels_24h: BusyChannel[];
contact_count: number;
@@ -697,6 +715,7 @@ export interface StatisticsResponse {
double_byte_pct: number;
triple_byte_pct: number;
};
region_scope_24h: RegionScopeStats;
packets_per_hour_72h: PacketsPerHourBucket[];
noise_floor_24h: NoiseFloorHistoryStats;
}