Offer multiple timing windows for repeater telemetry pickup. Closes #192.

This commit is contained in:
Jack Kingsman
2026-04-16 13:55:01 -07:00
parent 8efbbd97bd
commit 4b69ec4519
15 changed files with 1046 additions and 135 deletions
+3
View File
@@ -33,6 +33,7 @@ import type {
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
TelemetrySchedule,
TrackedTelemetryResponse,
StatisticsResponse,
TraceResponse,
@@ -332,6 +333,8 @@ export const api = {
body: JSON.stringify({ public_key: publicKey }),
}),
getTelemetrySchedule: () => fetchJson<TelemetrySchedule>('/settings/tracked-telemetry/schedule'),
// Favorites
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
@@ -15,6 +15,7 @@ import type {
Contact,
HealthStatus,
TelemetryHistoryEntry,
TelemetrySchedule,
} from '../../types';
export function SettingsDatabaseSection({
@@ -54,19 +55,45 @@ export function SettingsDatabaseSection({
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
const [schedule, setSchedule] = useState<TelemetrySchedule | null>(null);
const [intervalDraft, setIntervalDraft] = useState<number>(appSettings.telemetry_interval_hours);
// Serialization chain for every auto-persisted control on this page.
// Without this, rapid successive toggles (or mixed dropdown + checkbox
// interactions) can dispatch overlapping PATCHes that land out of order
// on HTTP/2 — a stale write then wins, reverting the user's last click.
// Each call awaits the previous one before sending its request, so the
// server sees updates in the order the user made them.
const saveChainRef = useRef<Promise<void>>(Promise.resolve());
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
setIntervalDraft(appSettings.telemetry_interval_hours);
}, [appSettings]);
// Re-fetch the scheduler derivation whenever the tracked list changes or
// the stored preference changes. Cheap: single GET, no radio lock.
useEffect(() => {
let cancelled = false;
api
.getTelemetrySchedule()
.then((s) => {
if (!cancelled) setSchedule(s);
})
.catch(() => {
// Non-critical: dropdown falls back to the unfiltered menu.
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters.length, appSettings.telemetry_interval_hours]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
@@ -132,28 +159,26 @@ export function SettingsDatabaseSection({
}
};
const handleSave = async () => {
setBusy(true);
setError(null);
try {
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
const currentBlocked = appSettings.discovery_blocked_types ?? [];
if (
discoveryBlockedTypes.length !== currentBlocked.length ||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
) {
update.discovery_blocked_types = discoveryBlockedTypes;
/**
* Apply an AppSettings PATCH after any already-queued saves finish, and
* revert local state if the save fails. Every auto-persist control on
* this page routes through here so the user-visible order of clicks is
* the order the backend sees, regardless of network reordering.
*/
const persistAppSettings = (update: AppSettingsUpdate, revert: () => void): Promise<void> => {
const chained = saveChainRef.current.then(async () => {
try {
await onSaveAppSettings(update);
} catch (err) {
console.error('Failed to save database settings:', err);
revert();
toast.error('Failed to save setting', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
await onSaveAppSettings(update);
toast.success('Database settings saved');
} catch (err) {
console.error('Failed to save database settings:', err);
setError(err instanceof Error ? err.message : 'Failed to save');
toast.error('Failed to save settings');
} finally {
setBusy(false);
}
});
saveChainRef.current = chained;
return chained;
};
return (
@@ -249,7 +274,14 @@ export function SettingsDatabaseSection({
<input
type="checkbox"
checked={autoDecryptOnAdvert}
onChange={(e) => setAutoDecryptOnAdvert(e.target.checked)}
onChange={(e) => {
const next = e.target.checked;
const prev = autoDecryptOnAdvert;
setAutoDecryptOnAdvert(next);
void persistAppSettings({ auto_decrypt_dm_on_advert: next }, () =>
setAutoDecryptOnAdvert(prev)
);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-decrypt historical DMs when new contact advertises</span>
@@ -266,10 +298,61 @@ export function SettingsDatabaseSection({
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
Repeaters opted into automatic telemetry collection are polled on a scheduled interval. To
limit mesh traffic, the app caps telemetry at 24 checks per day across all tracked
repeaters so fewer tracked repeaters allows shorter intervals, and more tracked
repeaters forces longer ones. Up to {schedule?.max_tracked ?? 8} repeaters may be tracked
at once ({trackedTelemetryRepeaters.length} / {schedule?.max_tracked ?? 8} slots used).
</p>
{/* Interval picker. Legal options depend on current tracked count;
we list only those. If the saved preference is no longer legal,
the effective interval is shown below so the user knows what the
scheduler is actually using. */}
<div className="space-y-1.5">
<Label htmlFor="telemetry-interval" className="text-sm">
Collection interval
</Label>
<div className="flex items-center gap-2">
<select
id="telemetry-interval"
value={intervalDraft}
onChange={(e) => {
const nextValue = Number(e.target.value);
if (!Number.isFinite(nextValue) || nextValue === intervalDraft) return;
const prevValue = intervalDraft;
setIntervalDraft(nextValue);
void persistAppSettings({ telemetry_interval_hours: nextValue }, () =>
setIntervalDraft(prevValue)
);
}}
className="h-9 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
{(schedule?.options ?? [1, 2, 3, 4, 6, 8, 12, 24]).map((hrs) => (
<option key={hrs} value={hrs}>
Every {hrs} hour{hrs === 1 ? '' : 's'} ({Math.floor(24 / hrs)} check
{Math.floor(24 / hrs) === 1 ? '' : 's'}/day)
</option>
))}
</select>
</div>
{schedule && schedule.effective_hours !== schedule.preferred_hours && (
<p className="text-xs text-warning">
Saved preference is {schedule.preferred_hours} hour
{schedule.preferred_hours === 1 ? '' : 's'}, but the scheduler is using{' '}
{schedule.effective_hours} hours because {schedule.tracked_count} repeater
{schedule.tracked_count === 1 ? '' : 's'}{' '}
{schedule.tracked_count === 1 ? 'is' : 'are'} tracked. Your preference will be
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>
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
@@ -341,16 +424,6 @@ export function SettingsDatabaseSection({
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* ── Contact Management ── */}
@@ -380,11 +453,14 @@ export function SettingsDatabaseSection({
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
onChange={() => {
const prev = discoveryBlockedTypes;
const next = checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode];
setDiscoveryBlockedTypes(next);
void persistAppSettings({ discovery_blocked_types: next }, () =>
setDiscoveryBlockedTypes(prev)
);
}}
className="rounded border-input"
/>
{label}
+68 -33
View File
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SettingsModal } from '../components/SettingsModal';
@@ -70,6 +70,7 @@ const baseSettings: AppSettings = {
discovery_blocked_types: [],
tracked_telemetry_repeaters: [],
auto_resend_channel: false,
telemetry_interval_hours: 8,
};
function renderModal(overrides?: {
@@ -442,52 +443,86 @@ describe('SettingsModal', () => {
expect(screen.getByText('iPhone')).toBeInTheDocument();
});
it('clears stale errors when switching external desktop sections', async () => {
it('reverts checkbox state when auto-persist fails on the database section', async () => {
// Auto-persist replaced the old "Save Settings" button on this section.
// The risk is now: a toggle gets applied optimistically, the PATCH fails,
// and we're left with the UI out of sync with saved state. Verify the
// revert-on-error path keeps the checkbox consistent with the server.
const onSaveAppSettings = vi.fn(async () => {
throw new Error('Save failed');
});
const { view } = renderModal({
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
onSaveAppSettings,
});
fireEvent.click(screen.getByRole('button', { name: 'Save Settings' }));
const checkbox = screen.getByRole('checkbox', {
name: /Auto-decrypt historical DMs/i,
}) as HTMLInputElement;
const initialChecked = checkbox.checked;
fireEvent.click(checkbox);
await waitFor(() => {
expect(screen.getByText('Save failed')).toBeInTheDocument();
expect(onSaveAppSettings).toHaveBeenCalled();
});
await waitFor(() => {
expect(checkbox.checked).toBe(initialChecked);
});
});
it('serializes rapid auto-persist clicks so stale writes cannot win', async () => {
// Regression test for a race where rapid consecutive checkbox toggles
// fire overlapping PATCHes that can land out of order. The page now
// chains saves through a single promise, so the server sees them in
// the order the user clicked. This test hand-controls resolution
// order to force the "stale write" scenario if serialization were off.
const deferred: { resolve: () => void }[] = [];
const callOrder: number[] = [];
const onSaveAppSettings = vi.fn(async (_update: unknown) => {
const index = deferred.length;
callOrder.push(index);
await new Promise<void>((res) => {
deferred.push({ resolve: res });
});
});
await act(async () => {
view.rerender(
<SettingsModal
open
externalSidebarNav
desktopSection="fanout"
config={baseConfig}
health={baseHealth}
appSettings={baseSettings}
onClose={vi.fn()}
onSave={vi.fn(async () => {})}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
meshDiscovery={null}
meshDiscoveryLoadingTarget={null}
onDiscoverMesh={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
/>
);
await Promise.resolve();
renderModal({
externalSidebarNav: true,
desktopSection: 'database',
onSaveAppSettings,
});
expect(api.getFanoutConfigs).toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
// Two distinct checkboxes in quick succession.
const blockClients = screen.getByRole('checkbox', { name: /Block clients/i });
const blockRepeaters = screen.getByRole('checkbox', { name: /Block repeaters/i });
fireEvent.click(blockClients);
fireEvent.click(blockRepeaters);
// Wait for the first PATCH to be registered. Only the first should be
// in-flight — the second must be queued behind it.
await waitFor(() => {
expect(deferred.length).toBe(1);
});
expect(callOrder).toEqual([0]);
// Resolve the first PATCH. The chain should now dispatch the second.
deferred[0].resolve();
await waitFor(() => {
expect(deferred.length).toBe(2);
});
expect(callOrder).toEqual([0, 1]);
// Resolve the second so the test tears down cleanly.
deferred[1].resolve();
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledTimes(2);
});
});
it('does not call onClose after save/reboot flows in page mode', async () => {
+12
View File
@@ -355,6 +355,7 @@ export interface AppSettings {
discovery_blocked_types: number[];
tracked_telemetry_repeaters: string[];
auto_resend_channel: boolean;
telemetry_interval_hours: number;
}
export interface AppSettingsUpdate {
@@ -366,11 +367,22 @@ export interface AppSettingsUpdate {
blocked_keys?: string[];
blocked_names?: string[];
discovery_blocked_types?: number[];
telemetry_interval_hours?: number;
}
export interface TelemetrySchedule {
preferred_hours: number;
effective_hours: number;
options: number[];
tracked_count: number;
max_tracked: number;
next_run_at: number | null;
}
export interface TrackedTelemetryResponse {
tracked_telemetry_repeaters: string[];
names: Record<string, string>;
schedule: TelemetrySchedule;
}
/** Contact type constants */