diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts index f70d8cc7c..f33589c81 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/peers.ts @@ -4,6 +4,26 @@ /** * Peers serves the dashboard data: one polled Status RPC and a long-running * SubscribeEvents stream that re-emits every event over the Wails event bus. + * + * Profile-switch suppression: ProfileSwitcher calls BeginProfileSwitch + * before tearing down the old profile when it would otherwise be followed + * by an Up on the new profile (i.e. previous status was Connected or + * Connecting). statusStreamLoop then swallows the transient stale + * Connected and Idle pushes the daemon emits during Down so the tray + * and the React Status page both see Connecting → new-profile-state + * instead of Connected → Connected → Idle → Connecting → new-state. + * + * Suppression transition (applied by shouldSuppress before each emit): + * + * ┌────────────────────────────────────────────┬──────────────────────────────────┐ + * │ Incoming daemon status │ Action │ + * ├────────────────────────────────────────────┼──────────────────────────────────┤ + * │ Connected, Idle │ Suppress (the blink we hide) │ + * │ Connecting │ Emit, clear flag (new Up began) │ + * │ NeedsLogin, LoginFailed, SessionExpired, │ Emit, clear flag (new profile's │ + * │ DaemonUnavailable │ "Up won't run" terminal state) │ + * │ (timeout elapsed) │ Clear flag, emit normally │ + * └────────────────────────────────────────────┴──────────────────────────────────┘ * @module */ @@ -15,6 +35,30 @@ import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Cr // @ts-ignore: Unused imports import * as $models from "./models.js"; +/** + * BeginProfileSwitch is called by ProfileSwitcher at the start of a switch + * when the previous status was Connected/Connecting — i.e. the daemon is + * about to emit Connected updates during Down's peer-count teardown and + * then an Idle before the new profile's Up resumes the stream. The flag + * makes statusStreamLoop drop those transient events. A synthetic + * Connecting snapshot is emitted right away so both consumers (tray and + * React) paint the optimistic state immediately. A 30s safety timeout + * clears the flag if the daemon never emits a follow-up status. + */ +export function BeginProfileSwitch(): $CancellablePromise { + return $Call.ByID(3532998514); +} + +/** + * CancelProfileSwitch is called by callers that abort the switch midway + * (the tray's Disconnect click while Connecting). Clears the suppression + * flag so the next daemon Idle paints through immediately instead of + * being swallowed. + */ +export function CancelProfileSwitch(): $CancellablePromise { + return $Call.ByID(4190545179); +} + /** * Get returns the current daemon status snapshot. */ diff --git a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts index 0f42d6f49..7148338f8 100644 --- a/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts +++ b/client/ui/frontend/bindings/github.com/netbirdio/netbird/client/ui/services/profileswitcher.ts @@ -2,21 +2,44 @@ // This file is automatically generated. DO NOT EDIT /** - * ProfileSwitcher encapsulates the full profile-switching reconnect policy so - * both the tray and the React frontend use identical logic. + * ProfileSwitcher encapsulates the full profile-switching reconnect policy + * so both the tray and the React frontend use identical logic. * - * Reconnect policy: + * Reconnect policy + optimistic-feedback table (driven by prevStatus + * captured from Peers.Get at SwitchActive entry): * - * ┌─────────────────┬──────────────────────┬────────────────────────────────────┐ - * │ Previous status │ Action │ Rationale │ - * ├─────────────────┼──────────────────────┼────────────────────────────────────┤ - * │ Connected │ Switch + Down + Up │ Reconnect with the new profile. │ - * │ Connecting │ Switch + Down + Up │ Stop old retry loop, restart. │ - * │ NeedsLogin │ Switch + Down │ Clear stale error; user logs in. │ - * │ LoginFailed │ Switch + Down │ Clear stale error; user logs in. │ - * │ SessionExpired │ Switch + Down │ Clear stale error; user logs in. │ - * │ Idle │ Switch only │ User chose offline; don't connect. │ - * └─────────────────┴──────────────────────┴────────────────────────────────────┘ + * ┌─────────────────┬──────────────────────┬──────────────────────────┬────────────────────┐ + * │ Previous status │ Action │ Optimistic UI label │ Suppressed events │ + * │ │ │ shown immediately │ until new flow │ + * ├─────────────────┼──────────────────────┼──────────────────────────┼────────────────────┤ + * │ Connected │ Switch + Down + Up │ Connecting (synthetic) │ Connected, Idle │ + * │ Connecting │ Switch + Down + Up │ Connecting (unchanged) │ Connected, Idle │ + * │ NeedsLogin │ Switch + Down │ (no change) │ — │ + * │ LoginFailed │ Switch + Down │ (no change) │ — │ + * │ SessionExpired │ Switch + Down │ (no change) │ — │ + * │ Idle │ Switch only │ (no change) │ — │ + * └─────────────────┴──────────────────────┴──────────────────────────┴────────────────────┘ + * + * Only Connected/Connecting trigger the optimistic Connecting paint + * (via Peers.BeginProfileSwitch): they're the only prevStatuses where + * the daemon emits stale Connected updates (peer count drops as the + * engine tears down) and then Idle, before the new profile's Up + * resumes the stream. Both are swallowed by Peers.shouldSuppress + * until a status that signals the new flow has begun (Connecting, or + * any of the "Up won't run" terminal states: NeedsLogin / LoginFailed / + * SessionExpired / DaemonUnavailable). The other prevStatuses either + * don't drive Down/Up at all (Idle) or stop after Down (NeedsLogin / + * LoginFailed / SessionExpired) — the resulting Idle is the correct + * terminal state, so no suppression is needed. + * + * Rationale for each Action choice: + * + * Connected → Reconnect with the new profile. + * Connecting → Stop old retry loop, restart. + * NeedsLogin → Clear stale error; user logs in. + * LoginFailed → Clear stale error; user logs in. + * SessionExpired → Clear stale error; user logs in. + * Idle → User chose offline; don't connect. * @module */ diff --git a/client/ui/frontend/pnpm-lock.yaml b/client/ui/frontend/pnpm-lock.yaml index e0ba0c3c0..a531765ab 100644 --- a/client/ui/frontend/pnpm-lock.yaml +++ b/client/ui/frontend/pnpm-lock.yaml @@ -81,7 +81,7 @@ devDependencies: version: 3.1.2 '@types/node': specifier: ^25.6.0 - version: 25.6.0 + version: 25.8.0 '@types/react': specifier: ^18.3.18 version: 18.3.28 @@ -111,7 +111,7 @@ devDependencies: version: 5.9.3 vite: specifier: ^6.0.7 - version: 6.4.2(@types/node@25.6.0) + version: 6.4.2(@types/node@25.8.0) packages: @@ -1625,10 +1625,10 @@ packages: resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} dev: true - /@types/node@25.6.0: - resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + /@types/node@25.8.0: + resolution: {integrity: sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==} dependencies: - undici-types: 7.19.2 + undici-types: 7.24.6 dev: true /@types/prop-types@15.7.15: @@ -1659,7 +1659,7 @@ packages: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.2(@types/node@25.6.0) + vite: 6.4.2(@types/node@25.8.0) transitivePeerDependencies: - supports-color dev: true @@ -1788,7 +1788,7 @@ packages: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1) '@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-id': 1.1.1(@types/react@18.3.28)(react@18.3.1) - '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.7)(@types/react@18.3.28)(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 react-dom: 18.3.1(react@18.3.1) transitivePeerDependencies: @@ -2554,8 +2554,8 @@ packages: hasBin: true dev: true - /undici-types@7.19.2: - resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + /undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} dev: true /update-browserslist-db@1.2.3(browserslist@4.28.2): @@ -2604,7 +2604,7 @@ packages: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} dev: true - /vite@6.4.2(@types/node@25.6.0): + /vite@6.4.2(@types/node@25.8.0): resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true @@ -2644,7 +2644,7 @@ packages: yaml: optional: true dependencies: - '@types/node': 25.6.0 + '@types/node': 25.8.0 esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4