Skip to content

Commit 082e323

Browse files
dmmulroyclaude
andcommitted
fix: eliminate HTTP request storm that DDoses device API controller
Remove redundant status check calls and feedback loops that flood the device with dozens of concurrent HTTP requests on connect, causing the API controller to go offline within seconds. - Remove auto-refresh useEffect from useConnect (fired in every consumer, created feedback loop via reactive SQL → connList change → re-trigger) - Remove duplicate syncConnectionStatuses (identical to refreshStatuses) - Replace reactive status checks with 10s polling interval in ConnectPage - Remove redundant testHttpReachable pre-check from HTTP transport creation - Reduce default retries from 3 to 1 for status checks - Fix CORS/COEP by hitting /api/v1/fromradio instead of root URL - Preserve HTTPS certificate error guidance in ConnectionService catch block Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e900cb4 commit 082e323

5 files changed

Lines changed: 104 additions & 136 deletions

File tree

packages/web/src/features/connect/hooks/useConnect.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,6 @@ export function useConnect(options?: UseConnectOptions) {
7474
}
7575
}, [connList]);
7676

77-
// Sync statuses on mount and when connection list changes
78-
useEffect(() => {
79-
refreshStatuses();
80-
}, [refreshStatuses]);
81-
8277
// Listen for hardware changes to re-check statuses
8378
const onHardwareChange = useEffectEvent(() => {
8479
refreshStatuses();
@@ -176,10 +171,6 @@ export function useConnect(options?: UseConnectOptions) {
176171
[connList],
177172
);
178173

179-
const syncConnectionStatuses = useCallback(async () => {
180-
await ConnectionService.refreshStatuses(connList);
181-
}, [connList]);
182-
183174
return {
184175
connections: connList,
185176
addConnection,
@@ -189,7 +180,6 @@ export function useConnect(options?: UseConnectOptions) {
189180
setDefaultConnection,
190181
toggleAutoReconnect,
191182
refreshStatuses,
192-
syncConnectionStatuses,
193183
autoReconnectStatus,
194184
};
195185
}
@@ -219,16 +209,12 @@ export function useDefaultConnection() {
219209
* Returns undefined connection when nodeNum is null
220210
*/
221211
export function useConnectionByNodeNum(nodeNum: number | null) {
222-
// Use nodeNum 0 as a fallback (no connection will have nodeNum 0)
223-
// This ensures we always have a valid query for useReactiveSQL
224212
const effectiveNodeNum = nodeNum ?? 0;
225213
const query = useMemo(
226214
() => connectionRepo.buildConnectionByNodeNumQuery(effectiveNodeNum),
227215
[effectiveNodeNum],
228216
);
229217
const { data } = useReactiveSQL(connectionRepo.getClient(), query);
230-
231-
// Return undefined if nodeNum was null (disabled state)
232218
return { connection: nodeNum !== null ? data?.[0] : undefined };
233219
}
234220

packages/web/src/features/connect/pages/ConnectPage.tsx

Lines changed: 94 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -34,15 +34,15 @@ import { useNavigate } from "@tanstack/react-router";
3434
import {
3535
LinkIcon,
3636
MoreHorizontal,
37+
Plus,
3738
RefreshCw,
3839
RotateCw,
39-
RouterIcon,
4040
Star,
4141
StarOff,
4242
Trash2,
4343
X,
4444
} from "lucide-react";
45-
import { useCallback, useEffect, useMemo, useState } from "react";
45+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4646
import { useTranslation } from "react-i18next";
4747
import { AddConnectionDialog } from "../components/AddConnectionDialog/AddConnectionDialog.tsx";
4848
import { ConfigProgressIndicator } from "../components/ConfigProgressIndicator.tsx";
@@ -76,16 +76,20 @@ export const ConnectPage = () => {
7676
setDefaultConnection,
7777
toggleAutoReconnect,
7878
refreshStatuses,
79-
syncConnectionStatuses,
8079
} = useConnect({
8180
onNavigationIntent: handleNavigationIntent,
8281
});
8382

84-
// On first mount, sync statuses and refresh
83+
// Poll connection statuses every 10 seconds.
84+
// Restart interval when connections first load (connList is [] on first render).
85+
const refreshRef = useRef(refreshStatuses);
86+
refreshRef.current = refreshStatuses;
87+
const hasConnections = connections.length > 0;
8588
useEffect(() => {
86-
syncConnectionStatuses();
87-
refreshStatuses();
88-
}, []);
89+
refreshRef.current();
90+
const interval = setInterval(() => refreshRef.current(), 10_000);
91+
return () => clearInterval(interval);
92+
}, [hasConnections]);
8993

9094
const sorted = useMemo(() => {
9195
const copy = [...connections];
@@ -101,111 +105,96 @@ export const ConnectPage = () => {
101105
return (
102106
<div className="space-y-6 p-6">
103107
<header className="flex items-start justify-between">
104-
<div className="flex items-stretch gap-3">
105-
<div>
106-
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
107-
{t("page.title")}
108-
</h1>
109-
<p className="lg:w-4/6 md:w-5/6 text-slate-500 dark:text-slate-400 mt-1">
110-
{t("page.description")}
111-
</p>
112-
</div>
113-
</div>
114-
<div className="flex flex-col items-end ml-2 gap-2">
115-
<Button onClick={() => setAddOpen(true)} className="gap-2">
116-
<RouterIcon className="size-5" />
117-
{t("button.addConnection")}
118-
</Button>
119-
<LanguageSwitcher />
108+
<div>
109+
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
110+
{t("page.title")}
111+
</h1>
112+
<p className="lg:w-4/6 md:w-5/6 text-slate-500 dark:text-slate-400 mt-1">
113+
{t("page.description")}
114+
</p>
120115
</div>
116+
<LanguageSwitcher />
121117
</header>
122118

123119
<Separator />
124120

125-
{sorted.length === 0 ? (
126-
<Card className="border-dashed">
127-
<CardHeader>
128-
<CardTitle className="text-lg">
129-
{t("noConnections.title")}{" "}
130-
</CardTitle>
131-
</CardHeader>
132-
<CardContent className="text-slate-500 dark:text-slate-400">
133-
{t("noConnections.description")}
134-
</CardContent>
135-
<CardFooter>
136-
<Button onClick={() => setAddOpen(true)} className="gap-2">
137-
<RouterIcon className="size-5" />
121+
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
122+
<Card
123+
className="flex flex-col items-center justify-center border-dashed cursor-pointer hover:border-primary hover:bg-accent/50 transition-colors min-h-[180px]"
124+
onClick={() => setAddOpen(true)}
125+
>
126+
<CardContent className="flex flex-col items-center justify-center py-8">
127+
<Plus className="size-10 text-muted-foreground mb-2" />
128+
<span className="text-muted-foreground font-medium">
138129
{t("button.addConnection")}
139-
</Button>
140-
</CardFooter>
130+
</span>
131+
</CardContent>
141132
</Card>
142-
) : (
143-
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
144-
{sorted.map((c) => (
145-
<ConnectionCard
146-
key={c.id}
147-
connection={c}
148-
onConnect={async () => {
149-
const ok = await connect(c.id, { allowPrompt: true });
150-
toast({
151-
title: ok ? t("toasts.connected") : t("toasts.failed"),
152-
description: ok
153-
? t("toasts.nowConnected", {
154-
name: c.name,
155-
interpolation: { escapeValue: false },
156-
})
157-
: t("toasts.checkConnection"),
158-
});
159-
}}
160-
onDisconnect={async () => {
161-
await disconnect(c.id);
162-
toast({
163-
title: t("toasts.disconnected"),
164-
description: t("toasts.nowDisconnected", {
165-
name: c.name,
166-
interpolation: { escapeValue: false },
167-
}),
168-
});
169-
}}
170-
onSetDefault={() => {
171-
setDefaultConnection(c.id);
172-
toast({
173-
title: t("toasts.defaultSet"),
174-
description: t("toasts.defaultConnection", {
175-
name: c.name,
176-
interpolation: { escapeValue: false },
177-
}),
178-
});
179-
}}
180-
onToggleAutoReconnect={() => toggleAutoReconnect(c.id)}
181-
onDelete={async () => {
182-
await disconnect(c.id);
183-
removeConnection(c.id);
184-
toast({
185-
title: t("toasts.deleted"),
186-
description: t("toasts.deletedByName", {
187-
name: c.name,
188-
interpolation: { escapeValue: false },
189-
}),
190-
});
191-
}}
192-
onRetry={async () => {
193-
const ok = await connect(c.id, { allowPrompt: true });
194-
toast({
195-
title: ok ? t("toasts.connected") : t("toasts.failed"),
196-
description: ok
197-
? t("toasts.nowConnected", {
198-
name: c.name,
199-
interpolation: { escapeValue: false },
200-
})
201-
: t("toasts.pickConnectionAgain"),
202-
});
203-
// Navigation handled by ConnectionService after config complete
204-
}}
205-
/>
206-
))}
207-
</div>
208-
)}
133+
134+
{sorted.map((c) => (
135+
<ConnectionCard
136+
key={c.id}
137+
connection={c}
138+
onConnect={async () => {
139+
const ok = await connect(c.id, { allowPrompt: true });
140+
toast({
141+
title: ok ? t("toasts.connected") : t("toasts.failed"),
142+
description: ok
143+
? t("toasts.nowConnected", {
144+
name: c.name,
145+
interpolation: { escapeValue: false },
146+
})
147+
: t("toasts.checkConnection"),
148+
});
149+
}}
150+
onDisconnect={async () => {
151+
await disconnect(c.id);
152+
toast({
153+
title: t("toasts.disconnected"),
154+
description: t("toasts.nowDisconnected", {
155+
name: c.name,
156+
interpolation: { escapeValue: false },
157+
}),
158+
});
159+
}}
160+
onSetDefault={() => {
161+
setDefaultConnection(c.id);
162+
toast({
163+
title: t("toasts.defaultSet"),
164+
description: t("toasts.defaultConnection", {
165+
name: c.name,
166+
interpolation: { escapeValue: false },
167+
}),
168+
});
169+
}}
170+
onToggleAutoReconnect={() => toggleAutoReconnect(c.id)}
171+
onDelete={async () => {
172+
await disconnect(c.id);
173+
removeConnection(c.id);
174+
toast({
175+
title: t("toasts.deleted"),
176+
description: t("toasts.deletedByName", {
177+
name: c.name,
178+
interpolation: { escapeValue: false },
179+
}),
180+
});
181+
}}
182+
onRetry={async () => {
183+
const ok = await connect(c.id, { allowPrompt: true });
184+
toast({
185+
title: ok ? t("toasts.connected") : t("toasts.failed"),
186+
description: ok
187+
? t("toasts.nowConnected", {
188+
name: c.name,
189+
interpolation: { escapeValue: false },
190+
})
191+
: t("toasts.pickConnectionAgain"),
192+
});
193+
// Navigation handled by ConnectionService after config complete
194+
}}
195+
/>
196+
))}
197+
</div>
209198

210199
<AddConnectionDialog
211200
open={addOpen}

packages/web/src/features/connect/services/connection/ConnectionService.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,13 @@ class ConnectionServiceClass {
145145
this.state.delete(conn.id);
146146
}
147147

148-
await this.updateStatus(conn.id, "error", msg);
148+
// Provide HTTPS-specific guidance for certificate issues
149+
if (conn.type === "http" && conn.url?.startsWith("https:")) {
150+
const httpsMsg = `Cannot reach HTTPS endpoint. Open ${conn.url} in a new tab to accept the certificate.`;
151+
await this.updateStatus(conn.id, "error", httpsMsg);
152+
} else {
153+
await this.updateStatus(conn.id, "error", msg);
154+
}
149155
return false;
150156
}
151157
}

packages/web/src/features/connect/services/connection/transportFactory.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { TransportHTTP } from "@meshtastic/transport-http";
1010
import { TransportMock } from "@meshtastic/transport-mock";
1111
import { TransportWebBluetooth } from "@meshtastic/transport-web-bluetooth";
1212
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
13-
import { testHttpReachable } from "../../utils";
1413
import * as browserBluetooth from "../browserBluetooth";
1514
import * as browserSerial from "../browserSerial";
1615
import type { SerialDeviceInfo } from "../browserSerial";
@@ -66,18 +65,6 @@ async function createHttpTransport(conn: Connection): Promise<TransportResult> {
6665
throw new Error("HTTP connection missing URL");
6766
}
6867

69-
logger.debug(`[transportFactory] Testing HTTP reachability: ${conn.url}`);
70-
const ok = await testHttpReachable(conn.url);
71-
72-
if (!ok) {
73-
const url = new URL(conn.url);
74-
throw new Error(
75-
url.protocol === "https:"
76-
? `Cannot reach HTTPS endpoint. Open ${conn.url} in a new tab to accept the certificate.`
77-
: "HTTP endpoint not reachable",
78-
);
79-
}
80-
8168
const url = new URL(conn.url);
8269
logger.debug(`[transportFactory] Creating HTTP transport for ${url.host}`);
8370

packages/web/src/features/connect/utils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ async function tryFetch(url: string, timeoutMs: number): Promise<boolean> {
77
const controller = new AbortController();
88
const timer = setTimeout(() => controller.abort(), timeoutMs);
99
try {
10-
const response = await fetch(url, {
10+
const apiUrl = url.replace(/\/+$/, "") + "/api/v1/fromradio";
11+
const response = await fetch(apiUrl, {
1112
method: "GET",
12-
mode: "no-cors",
1313
cache: "no-store",
1414
signal: controller.signal,
1515
});
@@ -27,7 +27,7 @@ async function tryFetch(url: string, timeoutMs: number): Promise<boolean> {
2727
export async function testHttpReachable(
2828
url: string,
2929
timeoutMs = 4000,
30-
retries = 2,
30+
retries = 0,
3131
onAttempt?: (attempt: number, total: number) => void,
3232
): Promise<boolean> {
3333
logger.debug(`[testHttpReachable] Testing URL: ${url}`);

0 commit comments

Comments
 (0)