mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-09 18:23:20 +02:00
Merge branch 'main' into pr-339
# Conflicts: # frontend/AGENTS.md
This commit is contained in:
@@ -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 && (
|
||||
<>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user