diff --git a/src/interfaces/Account.ts b/src/interfaces/Account.ts index c87c55e5b..6453fd479 100644 --- a/src/interfaces/Account.ts +++ b/src/interfaces/Account.ts @@ -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; diff --git a/src/modules/settings/ClientSettingsTab.tsx b/src/modules/settings/ClientSettingsTab.tsx index 56bcc90e9..d45877b4c 100644 --- a/src/modules/settings/ClientSettingsTab.tsx +++ b/src/modules/settings/ClientSettingsTab.tsx @@ -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; @@ -70,8 +78,16 @@ function ClientSettingsTabContent({ account }: Readonly) { const { mutate } = useSWRConfig(); const saveRequest = useApiCall("/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( + (account.settings?.connection_mode as ConnectionModeValue | null | undefined) ?? + resolveLegacyLazyBool(account.settings?.lazy_connection_enabled), + ); + const [relayTimeoutSeconds, setRelayTimeoutSeconds] = useState( + account.settings?.relay_timeout_seconds ?? null, ); const autoUpdateSetting = account.settings?.auto_update_version; @@ -181,28 +197,55 @@ function ClientSettingsTabContent({ account }: Readonly) { }); }; - 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...", }); }; + 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 (
@@ -368,13 +411,17 @@ function ClientSettingsTabContent({ account }: Readonly) {
- 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.{" "} + P2P keeps + connections always on (best latency, more bandwidth).{" "} + P2P Lazy{" "} + 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.{" "} ) { - - - 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} - /> +
+ + {currentMeta.showsRelayTimeout && ( + } + placeholder={String(DEFAULT_RELAY_TIMEOUT_SECONDS)} + onChange={(e) => handleRelayTimeoutChange(e.target.value)} + disabled={!permission.settings.update} + /> + )} +
+ {currentMeta.showsRelayTimeout && ( + + 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. + + )}
diff --git a/src/modules/settings/connectionmode/modeOptions.ts b/src/modules/settings/connectionmode/modeOptions.ts new file mode 100644 index 000000000..f484cd793 --- /dev/null +++ b/src/modules/settings/connectionmode/modeOptions.ts @@ -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 = { + "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"; +}