mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
Offer multiple timing windows for repeater telemetry pickup. Closes #192.
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user