From 595dfbb6f190e9fda434486c477ff6d6d3e00819 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Mon, 11 May 2026 12:22:47 +0200 Subject: [PATCH] [client/ui] Distinguish "daemon not running" tray state The status stream emits a synthetic StatusDaemonUnavailable when the gRPC client or stream cannot be established, fired once per outage and cleared on the next real snapshot. The tray maps it to a "Not running" status label, switches the icon to the error variant, hides Connect/Disconnect (neither would work without the daemon), and disables Settings, Networks and Create Debug Bundle so the user is not routed to pages that would just fail to load. --- client/ui/services/peers.go | 23 +++++++++++++++++++++ client/ui/tray.go | 40 +++++++++++++++++++++++++++++-------- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/client/ui/services/peers.go b/client/ui/services/peers.go index 8559f3bf1..2fa2a1b12 100644 --- a/client/ui/services/peers.go +++ b/client/ui/services/peers.go @@ -29,6 +29,12 @@ const ( // started) installing an update — Mode 2 enforced flow. The UI opens the // progress window in response. EventUpdateProgress = "netbird:update:progress" + + // StatusDaemonUnavailable is the synthetic Status the UI emits when the + // daemon's gRPC socket is unreachable (daemon not running, socket + // permission, etc.). Real daemon statuses come straight from + // internal.Status* — none of those collide with this label. + StatusDaemonUnavailable = "DaemonUnavailable" ) // Emitter is what peers.Watch needs from the host application: a simple @@ -198,13 +204,28 @@ func (s *Peers) statusStreamLoop(ctx context.Context) { Clock: backoff.SystemClock, }, ctx) + // unavailable tracks whether we've already signalled the daemon as + // unreachable. The synthetic event is emitted once per outage so the + // tray flips to the "Daemon not running" state, but the exponential + // backoff retries don't re-fire it on every attempt. + unavailable := false + emitUnavailable := func() { + if unavailable { + return + } + unavailable = true + s.emitter.Emit(EventStatus, Status{Status: StatusDaemonUnavailable}) + } + op := func() error { cli, err := s.conn.Client() if err != nil { + emitUnavailable() return fmt.Errorf("get client: %w", err) } stream, err := cli.SubscribeStatus(ctx, &proto.StatusRequest{GetFullPeerStatus: true}) if err != nil { + emitUnavailable() return fmt.Errorf("subscribe status: %w", err) } for { @@ -213,8 +234,10 @@ func (s *Peers) statusStreamLoop(ctx context.Context) { if ctx.Err() != nil { return ctx.Err() } + emitUnavailable() return fmt.Errorf("status stream recv: %w", err) } + unavailable = false st := statusFromProto(resp) log.Infof("backend event: status status=%q peers=%d", st.Status, len(st.Peers)) s.emitter.Emit(EventStatus, st) diff --git a/client/ui/tray.go b/client/ui/tray.go index f88149d37..fd8ab351f 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -26,8 +26,9 @@ const ( trayTooltip = "NetBird" // Top-level menu entries. - menuStatusDisconnected = "Disconnected" - menuOpenNetBird = "Open NetBird" + menuStatusDisconnected = "Disconnected" + menuStatusDaemonUnavailable = "Not running" + menuOpenNetBird = "Open NetBird" menuConnect = "Connect" menuDisconnect = "Disconnect" menuExitNode = "Exit Node" @@ -112,6 +113,8 @@ type Tray struct { downItem *application.MenuItem exitNodeItem *application.MenuItem networksItem *application.MenuItem + settingsItem *application.MenuItem + debugItem *application.MenuItem updateItem *application.MenuItem daemonVersionItem *application.MenuItem @@ -201,8 +204,8 @@ func (t *Tray) buildMenu() *application.Menu { // block-inbound, auto-connect, notifications) and profile switching // all live in the in-window Settings page now. The tray menu only // surfaces the day-to-day actions. - menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) - menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) + t.settingsItem = menu.Add(menuSettings).OnClick(func(*application.Context) { t.openRoute("/settings") }) + t.debugItem = menu.Add(menuCreateDebugBundle).OnClick(func(*application.Context) { t.openRoute("/debug") }) menu.AddSeparator() @@ -432,20 +435,40 @@ func (t *Tray) applyStatus(st services.Status) { t.applyIcon() needsLogin := strings.EqualFold(st.Status, statusNeedsLogin) || strings.EqualFold(st.Status, statusSessionExpired) + daemonUnavailable := strings.EqualFold(st.Status, services.StatusDaemonUnavailable) if t.statusItem != nil { // When the daemon needs re-authentication the status row turns // into the actionable Login entry — Connect would only fail. - t.statusItem.SetLabel(st.Status) + // When the daemon socket is unreachable, swap the label to make + // the cause obvious; Connect/Disconnect would just fail. + label := st.Status + if daemonUnavailable { + label = menuStatusDaemonUnavailable + } + t.statusItem.SetLabel(label) t.statusItem.SetEnabled(needsLogin) } if t.upItem != nil { - t.upItem.SetHidden(connected || needsLogin) - t.upItem.SetEnabled(!connected && !needsLogin) + t.upItem.SetHidden(connected || needsLogin || daemonUnavailable) + t.upItem.SetEnabled(!connected && !needsLogin && !daemonUnavailable) } if t.downItem != nil { t.downItem.SetHidden(!connected) t.downItem.SetEnabled(connected) } + // Settings, Networks and Debug Bundle all drive daemon RPCs from + // their respective frontend routes — disable them while the daemon + // socket is unreachable so the user doesn't land on a page that + // would only fail to load. + if t.networksItem != nil { + t.networksItem.SetEnabled(!daemonUnavailable) + } + if t.settingsItem != nil { + t.settingsItem.SetEnabled(!daemonUnavailable) + } + if t.debugItem != nil { + t.debugItem.SetEnabled(!daemonUnavailable) + } } if exitNodesChanged { t.rebuildExitNodes(exitNodes) @@ -517,7 +540,8 @@ func (t *Tray) iconForState() (icon, dark []byte) { t.mu.Unlock() connecting := strings.EqualFold(statusLabel, "Connecting") - errored := strings.EqualFold(statusLabel, "Error") + errored := strings.EqualFold(statusLabel, "Error") || + strings.EqualFold(statusLabel, services.StatusDaemonUnavailable) if runtime.GOOS == "darwin" { switch {