Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/interfaces/Account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface Account {
dns_domain: string;
network_range?: string;
lazy_connection_enabled: boolean;
// Phase 1 of issue #5989: connection-mode + idle timeouts. All three
// fields are optional and nullable on the wire (NULL means "use
// built-in default"). The dashboard treats null and undefined the same.
connection_mode?: "relay-forced" | "p2p" | "p2p-lazy" | "p2p-dynamic" | null;
relay_timeout_seconds?: number | null;
p2p_timeout_seconds?: number | null;
embedded_idp_enabled?: boolean;
auto_update_version: string;
auto_update_always: boolean;
Expand Down
121 changes: 88 additions & 33 deletions src/modules/settings/ClientSettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ import ReverseProxyIcon from "@/assets/icons/ReverseProxyIcon";
import useGroupHelper from "@/modules/groups/useGroupHelper";
import { useGroups } from "@/contexts/GroupsProvider";
import { SkeletonSettings } from "@components/skeletons/SkeletonSettings";
import {
ConnectionModeValue,
DEFAULT_RELAY_TIMEOUT_SECONDS,
MODE_META,
VISIBLE_MODE_OPTIONS,
modeImpliesLegacyLazy,
resolveLegacyLazyBool,
} from "@/modules/settings/connectionmode/modeOptions";

type Props = {
account: Account;
Expand Down Expand Up @@ -70,8 +78,16 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
const { mutate } = useSWRConfig();
const saveRequest = useApiCall<Account>("/accounts/" + account.id, true);

const [lazyConnection, setLazyConnection] = useState(
account.settings?.lazy_connection_enabled ?? false,
// Phase 1 of issue #5989: replaced the binary lazy-toggle with a
// 2-value dropdown (p2p / p2p-lazy). The dashboard preserves the legacy
// lazy_connection_enabled boolean alongside the new connection_mode for
// backwards-compat with older daemon versions.
const [connectionMode, setConnectionMode] = useState<ConnectionModeValue>(
(account.settings?.connection_mode as ConnectionModeValue | null | undefined) ??
resolveLegacyLazyBool(account.settings?.lazy_connection_enabled),
);
const [relayTimeoutSeconds, setRelayTimeoutSeconds] = useState<number | null>(
account.settings?.relay_timeout_seconds ?? null,
);

const autoUpdateSetting = account.settings?.auto_update_version;
Expand Down Expand Up @@ -181,28 +197,55 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
});
};

const toggleLazyConnection = async (toggle: boolean) => {
// Phase 1 (#5989): persist mode + timeout, AND mirror onto the legacy
// lazy_connection_enabled boolean so older daemon versions stay in sync.
const saveConnectionMode = async (
nextMode: ConnectionModeValue,
nextRelayTimeout: number | null,
) => {
setConnectionMode(nextMode);
setRelayTimeoutSeconds(nextRelayTimeout);

notify({
title: "Lazy Connections",
description: `Lazy Connections successfully ${
toggle ? "enabled" : "disabled"
}.`,
title: "Connection Mode",
description: "Connection mode updated.",
promise: saveRequest
.put({
id: account.id,
settings: {
...account.settings,
lazy_connection_enabled: toggle,
connection_mode: nextMode,
relay_timeout_seconds: nextRelayTimeout,
lazy_connection_enabled: modeImpliesLegacyLazy(nextMode),
},
})
.then(() => {
setLazyConnection(toggle);
mutate("/accounts");
}),
loadingMessage: "Updating Lazy Connections setting...",
loadingMessage: "Updating connection mode...",
});
};
Comment on lines +200 to 227

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Optimistic state update without rollback on failure.

Lines 206-207 update local state before the API call completes. If saveRequest.put() fails, the UI will show the new values while the server retains the old ones.

Consider capturing the previous state and reverting on error:

🛡️ Proposed fix to add rollback on failure
   const saveConnectionMode = async (
     nextMode: ConnectionModeValue,
     nextRelayTimeout: number | null,
   ) => {
+    const prevMode = connectionMode;
+    const prevTimeout = relayTimeoutSeconds;
     setConnectionMode(nextMode);
     setRelayTimeoutSeconds(nextRelayTimeout);

     notify({
       title: "Connection Mode",
       description: "Connection mode updated.",
       promise: saveRequest
         .put({
           id: account.id,
           settings: {
             ...account.settings,
             connection_mode: nextMode,
             relay_timeout_seconds: nextRelayTimeout,
             lazy_connection_enabled: modeImpliesLegacyLazy(nextMode),
           },
         })
         .then(() => {
           mutate("/accounts");
+        })
+        .catch((err) => {
+          setConnectionMode(prevMode);
+          setRelayTimeoutSeconds(prevTimeout);
+          throw err;
         }),
       loadingMessage: "Updating connection mode...",
     });
   };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/settings/ClientSettingsTab.tsx` around lines 200 - 227, The
saveConnectionMode function currently performs an optimistic update via
setConnectionMode and setRelayTimeoutSeconds before saveRequest.put completes;
capture the previous values (e.g., const prevMode = connectionMode; const
prevTimeout = relayTimeoutSeconds) then perform the optimistic set, call
saveRequest.put(...).then(() => mutate("/accounts")), and in the .catch() revert
state with setConnectionMode(prevMode) and setRelayTimeoutSeconds(prevTimeout)
and surface an error notify; ensure the notify promise uses the saveRequest.put
promise (so rollback runs on failure) and keep references to saveConnectionMode,
setConnectionMode, setRelayTimeoutSeconds, saveRequest.put, and
mutate("/accounts") when implementing the rollback.


const handleModeChange = (next: string) => {
// Mode-change preserves the persisted relay timeout (per spec
// section 5.3): users only lose their entered value if they clear
// the input explicitly, not via mode-switch.
saveConnectionMode(next as ConnectionModeValue, relayTimeoutSeconds);
};

const handleRelayTimeoutChange = (raw: string) => {
const trimmed = raw.trim();
if (trimmed === "") {
saveConnectionMode(connectionMode, null);
return;
}
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed < 0) return;
saveConnectionMode(connectionMode, parsed);
};

const currentMeta = MODE_META[connectionMode];

return (
<Tabs.Content value={"clients"}>
<div className={"p-default py-6 max-w-2xl"}>
Expand Down Expand Up @@ -368,13 +411,17 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
<div>
<Label>
<FlaskConicalIcon size={15} />
Experimental
Experimental: Connection Mode
</Label>

<HelpText>
Lazy connections are an experimental feature. Functionality and
behavior may evolve. Instead of maintaining always-on connections,
NetBird activates them on-demand based on activity or signaling.{" "}
Choose how NetBird clients establish peer-to-peer connections.{" "}
<span className={"text-white font-medium"}>P2P</span> keeps
connections always on (best latency, more bandwidth).{" "}
<span className={"text-white font-medium"}>P2P Lazy</span>{" "}
opens connections on demand and tears them down after the relay
timeout (much lower bandwidth on metered links like LTE).
Changes take effect after the client restarts.{" "}
<InlineLink
href={"https://docs.netbird.io/how-to/lazy-connection"}
target={"_blank"}
Expand All @@ -383,25 +430,33 @@ function ClientSettingsTabContent({ account }: Readonly<Props>) {
<ExternalLinkIcon size={12} />
</InlineLink>
</HelpText>
<FancyToggleSwitch
className={"mt-2"}
value={lazyConnection}
onChange={toggleLazyConnection}
label={
<>
<ClockFadingIcon size={15} />
Enable Lazy Connections
</>
}
helpText={
<>
Allow to establish connections between peers only when
required. This requires NetBird client v0.45 or higher.
Changes will only take effect after restarting the clients.
</>
}
disabled={!permission.settings.update}
/>
<div className={"gap-4 items-center grid grid-cols-2 mt-2"}>
<SelectDropdown
value={connectionMode}
onChange={handleModeChange}
options={VISIBLE_MODE_OPTIONS}
/>
{currentMeta.showsRelayTimeout && (
<Input
value={
relayTimeoutSeconds === null
? ""
: String(relayTimeoutSeconds)
}
customPrefix={<ClockFadingIcon size={14} />}
placeholder={String(DEFAULT_RELAY_TIMEOUT_SECONDS)}
onChange={(e) => handleRelayTimeoutChange(e.target.value)}
disabled={!permission.settings.update}
/>
)}
Comment on lines +439 to +451

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add accessible label for the relay timeout input.

The timeout input uses only an icon as customPrefix with no visible or accessible label. Screen reader users won't understand the field's purpose.

♿ Proposed fix to add aria-label
               <Input
                 value={
                   relayTimeoutSeconds === null
                     ? ""
                     : String(relayTimeoutSeconds)
                 }
                 customPrefix={<ClockFadingIcon size={14} />}
                 placeholder={String(DEFAULT_RELAY_TIMEOUT_SECONDS)}
                 onChange={(e) => handleRelayTimeoutChange(e.target.value)}
                 disabled={!permission.settings.update}
+                aria-label="Relay timeout in seconds"
               />
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{currentMeta.showsRelayTimeout && (
<Input
value={
relayTimeoutSeconds === null
? ""
: String(relayTimeoutSeconds)
}
customPrefix={<ClockFadingIcon size={14} />}
placeholder={String(DEFAULT_RELAY_TIMEOUT_SECONDS)}
onChange={(e) => handleRelayTimeoutChange(e.target.value)}
disabled={!permission.settings.update}
/>
)}
{currentMeta.showsRelayTimeout && (
<Input
value={
relayTimeoutSeconds === null
? ""
: String(relayTimeoutSeconds)
}
customPrefix={<ClockFadingIcon size={14} />}
placeholder={String(DEFAULT_RELAY_TIMEOUT_SECONDS)}
onChange={(e) => handleRelayTimeoutChange(e.target.value)}
disabled={!permission.settings.update}
aria-label="Relay timeout in seconds"
/>
)}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/modules/settings/ClientSettingsTab.tsx` around lines 439 - 451, The relay
timeout Input rendered under currentMeta.showsRelayTimeout lacks an accessible
label; update the Input component (the instance using value derived from
relayTimeoutSeconds, placeholder DEFAULT_RELAY_TIMEOUT_SECONDS, onChange
handleRelayTimeoutChange, and disabled tied to permission.settings.update) to
include an accessible label—e.g., add an aria-label or aria-labelledby that
clearly describes the field (such as "Relay timeout in seconds") so screen
readers can identify the input while keeping the existing customPrefix icon.

</div>
{currentMeta.showsRelayTimeout && (
<HelpText className={"mt-2"}>
Relay timeout in seconds. Empty = use built-in default
({DEFAULT_RELAY_TIMEOUT_SECONDS}s = 5 min). Set to 0 to keep
the relay alive indefinitely.
</HelpText>
)}
</div>
</div>
</div>
Expand Down
78 changes: 78 additions & 0 deletions src/modules/settings/connectionmode/modeOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Phase 1 (issue #5989) connection-mode options for the dashboard.
// Two modes are visible in Phase 1; relay-forced and p2p-dynamic remain
// admin-only (settable via API/CLI/env) and are kept out of the dropdown
// to avoid surprising the typical admin.

import { SelectOption } from "@components/select/SelectDropdown";

export type ConnectionModeValue =
| "p2p"
| "p2p-lazy"
| "p2p-dynamic"
| "relay-forced";

export interface ModeMeta {
value: ConnectionModeValue;
label: string;
visible: boolean;
showsRelayTimeout: boolean;
showsP2pTimeout: boolean;
}

export const MODE_META: Record<ConnectionModeValue, ModeMeta> = {
"p2p": {
value: "p2p",
label: "P2P (recommended)",
visible: true,
showsRelayTimeout: false,
showsP2pTimeout: false,
},
"p2p-lazy": {
value: "p2p-lazy",
label: "P2P Lazy",
visible: true,
showsRelayTimeout: true,
showsP2pTimeout: false,
},
"p2p-dynamic": {
value: "p2p-dynamic",
label: "P2P Dynamic",
visible: false, // Phase-1 hides; backend still accepts via API
showsRelayTimeout: true,
showsP2pTimeout: true,
},
"relay-forced": {
value: "relay-forced",
label: "Relay Forced",
visible: false, // Phase-1 admin-only
showsRelayTimeout: false,
showsP2pTimeout: false,
},
};

export const VISIBLE_MODE_OPTIONS: SelectOption[] = Object.values(MODE_META)
.filter((m) => m.visible)
.map((m) => ({ label: m.label, value: m.value }));

// Defaults shown as placeholders when DB value is NULL.
export const DEFAULT_RELAY_TIMEOUT_SECONDS = 5 * 60; // 5 min
export const DEFAULT_P2P_TIMEOUT_SECONDS = 180 * 60; // 180 min

// resolveLegacyLazyBool mirrors the server-side fallback: if the new
// connection_mode field is null/undefined, derive the effective mode from
// the legacy lazy_connection_enabled boolean. Used to seed the dropdown
// state when a user opens an account that has never set the new field.
export function resolveLegacyLazyBool(
lazyEnabled: boolean | undefined,
): ConnectionModeValue {
return lazyEnabled ? "p2p-lazy" : "p2p";
}

// modeImpliesLegacyLazy is the inverse: when the user picks a mode in the
// dashboard, we ALSO write the legacy lazy_connection_enabled boolean to
// keep older daemon versions (which only understand the boolean) in sync.
// relay-forced and p2p-dynamic both map to false because the legacy boolean
// cannot express their semantics.
export function modeImpliesLegacyLazy(mode: ConnectionModeValue): boolean {
return mode === "p2p-lazy";
}