Permit hourly checks for direct/routed repeaters. Closes #226.

This commit is contained in:
Jack Kingsman
2026-04-27 09:35:45 -07:00
parent 25041e1367
commit 047d713003
13 changed files with 697 additions and 34 deletions
@@ -221,16 +221,16 @@ export function TelemetryHistoryPane({
via the repeater pane, API calls to the endpoint (
<code className="text-[0.6875rem]">POST /api/contacts/&lt;key&gt;/repeater/status</code>
), or when the repeater is opted into interval telemetry polling, in which case the
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
into this flow in the{' '}
repeater will be polled for metrics automatically. Fetch frequency can be configured in{' '}
<a
href="#settings/database"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Database &amp; Messaging
</a>{' '}
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
of keeping mesh congestion reasonable.
Settings &rarr; Database &amp; Messaging
</a>
, where you can also see which repeaters are currently opted in. A maximum of{' '}
{MAX_TRACKED} repeaters may be opted into this for the sake of keeping mesh congestion
reasonable.
</p>
{isTracked ? (
@@ -259,7 +259,7 @@ export function TelemetryHistoryPane({
disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
{toggling ? 'Updating...' : 'Opt Repeater into Interval Metrics Tracking'}
</Button>
)}
</div>
@@ -92,7 +92,11 @@ export function SettingsDatabaseSection({
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters.length, appSettings.telemetry_interval_hours]);
}, [
trackedTelemetryRepeaters.length,
appSettings.telemetry_interval_hours,
appSettings.telemetry_routed_hourly,
]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
@@ -346,13 +350,41 @@ export function SettingsDatabaseSection({
restored if you drop back to a supported count.
</p>
)}
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
Next run at {formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
</div>
{/* Routed hourly toggle */}
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={appSettings.telemetry_routed_hourly}
onChange={() => {
const next = !appSettings.telemetry_routed_hourly;
void persistAppSettings({ telemetry_routed_hourly: next }, () => {});
}}
className="w-4 h-4 rounded border-input accent-primary mt-0.5"
/>
<div>
<span className="text-sm">Poll direct/routed-path repeaters hourly</span>
<p className="text-[0.8125rem] text-muted-foreground">
When enabled, tracked repeaters with a direct or routed path (not flood) are polled
every hour instead of on the scheduled interval above. Flood-only repeaters still
follow the normal schedule.
</p>
</div>
</label>
{schedule?.next_run_at != null && (
<p className="text-xs text-muted-foreground">
{schedule.routed_hourly ? 'Next flood run at' : 'Next run at'}{' '}
{formatTime(schedule.next_run_at)} (UTC top of hour).
</p>
)}
{schedule?.next_routed_run_at != null && (
<p className="text-xs text-muted-foreground">
Next direct/routed run at {formatTime(schedule.next_routed_run_at)} (UTC top of hour).
</p>
)}
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
@@ -362,6 +394,21 @@ export function SettingsDatabaseSection({
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const routeSource = contact?.effective_route_source ?? 'flood';
// A forced-flood override (path_len < 0) still reports source
// "override", but the actual route is flood. Check the real path.
const hasRealPath =
contact?.effective_route != null && contact.effective_route.path_len >= 0;
const routeLabel = !hasRealPath
? 'flood'
: routeSource === 'override'
? 'routed'
: routeSource === 'direct'
? 'direct'
: 'flood';
const routeColor = hasRealPath
? 'text-primary bg-primary/10'
: 'text-muted-foreground bg-muted';
const snap = latestTelemetry[key];
const d = snap?.data;
return (
@@ -369,9 +416,16 @@ export function SettingsDatabaseSection({
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<div className="flex items-center gap-1.5">
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
<span
className={`text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded font-medium ${routeColor}`}
>
{routeLabel}
</span>
</div>
</div>
{onToggleTrackedTelemetry && (
<Button
+2
View File
@@ -111,6 +111,7 @@ beforeEach(() => {
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
});
mockedApi.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
@@ -1050,6 +1051,7 @@ describe('SettingsFanoutSection', () => {
tracked_telemetry_repeaters: ['cc'.repeat(32)],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
});
renderSection();
+70
View File
@@ -5,6 +5,7 @@ import { SettingsModal } from '../components/SettingsModal';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
RadioAdvertMode,
RadioConfig,
@@ -71,6 +72,7 @@ const baseSettings: AppSettings = {
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
telemetry_routed_hourly: false,
};
function renderModal(overrides?: {
@@ -89,6 +91,8 @@ function renderModal(overrides?: {
meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
contacts?: Contact[];
trackedTelemetryRepeaters?: string[];
open?: boolean;
pageMode?: boolean;
externalSidebarNav?: boolean;
@@ -127,6 +131,8 @@ function renderModal(overrides?: {
onDiscoverMesh,
onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings,
contacts: overrides?.contacts,
trackedTelemetryRepeaters: overrides?.trackedTelemetryRepeaters,
};
const view = overrides?.externalSidebarNav
@@ -794,4 +800,68 @@ describe('SettingsModal', () => {
expect(screen.getByText('Network')).toBeInTheDocument();
});
});
it('renders routed hourly checkbox and calls save on toggle', async () => {
const onSaveAppSettings = vi.fn(async () => {});
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
onSaveAppSettings,
});
const checkbox = screen.getByRole('checkbox', {
name: /Poll direct\/routed-path repeaters hourly/i,
}) as HTMLInputElement;
expect(checkbox).toBeInTheDocument();
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({ telemetry_routed_hourly: true })
);
});
});
it('shows route badge per tracked repeater', async () => {
const directKey = 'bb'.repeat(32);
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
appSettings: {
...baseSettings,
tracked_telemetry_repeaters: [directKey],
},
trackedTelemetryRepeaters: [directKey],
contacts: [
{
public_key: directKey,
name: 'DirectRepeater',
type: 2,
flags: 0,
direct_path: 'aabb',
direct_path_len: 1,
direct_path_hash_mode: 1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
favorite: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
effective_route: { path: 'aabb', path_len: 1, path_hash_mode: 1 },
effective_route_source: 'direct',
},
],
});
expect(screen.getByText('DirectRepeater')).toBeInTheDocument();
expect(screen.getByText('direct')).toBeInTheDocument();
});
});
+4
View File
@@ -359,6 +359,7 @@ export interface AppSettings {
tracked_telemetry_repeaters: string[];
auto_resend_channel: boolean;
telemetry_interval_hours: number;
telemetry_routed_hourly: boolean;
}
export interface AppSettingsUpdate {
@@ -371,6 +372,7 @@ export interface AppSettingsUpdate {
blocked_names?: string[];
discovery_blocked_types?: number[];
telemetry_interval_hours?: number;
telemetry_routed_hourly?: boolean;
}
export interface TelemetrySchedule {
@@ -380,6 +382,8 @@ export interface TelemetrySchedule {
tracked_count: number;
max_tracked: number;
next_run_at: number | null;
routed_hourly: boolean;
next_routed_run_at: number | null;
}
export interface TrackedTelemetryResponse {