[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.
This commit is contained in:
Zoltan Papp
2026-05-11 12:22:47 +02:00
parent 7f560df9be
commit 595dfbb6f1
2 changed files with 55 additions and 8 deletions

View File

@@ -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)

View File

@@ -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 {