control/controlclient,health,tailcfg: refactor control health messages

Updates tailscale/corp#27759

Signed-off-by: James Sanderson <jsanderson@tailscale.com>
This commit is contained in:
James Sanderson 2025-04-30 14:59:45 +01:00
parent 165b99278b
commit 2f9907d4ea
18 changed files with 390 additions and 94 deletions

View File

@ -133,6 +133,7 @@ tailscale.com/cmd/derper dependencies: (generated by github.com/tailscale/depawa
tailscale.com/tsweb from tailscale.com/cmd/derper+
tailscale.com/tsweb/promvarz from tailscale.com/cmd/derper
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/bools from tailscale.com/tailcfg
tailscale.com/types/dnstype from tailscale.com/tailcfg+
tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/tailcfg+

View File

@ -894,7 +894,7 @@ tailscale.com/cmd/k8s-operator dependencies: (generated by github.com/tailscale/
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/bools from tailscale.com/tsnet
tailscale.com/types/bools from tailscale.com/tsnet+
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/ipproto from tailscale.com/net/flowtrack+

View File

@ -61,6 +61,7 @@ tailscale.com/cmd/stund dependencies: (generated by github.com/tailscale/depawar
tailscale.com/tsweb from tailscale.com/cmd/stund+
tailscale.com/tsweb/promvarz from tailscale.com/cmd/stund
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/bools from tailscale.com/tailcfg
tailscale.com/types/dnstype from tailscale.com/tailcfg
tailscale.com/types/ipproto from tailscale.com/tailcfg
tailscale.com/types/key from tailscale.com/tailcfg

View File

@ -139,6 +139,7 @@ tailscale.com/cmd/tailscale dependencies: (generated by github.com/tailscale/dep
tailscale.com/tstime/rate from tailscale.com/cmd/tailscale/cli+
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/util/usermetric+
tailscale.com/types/bools from tailscale.com/tailcfg
tailscale.com/types/dnstype from tailscale.com/tailcfg+
tailscale.com/types/empty from tailscale.com/ipn
tailscale.com/types/ipproto from tailscale.com/ipn+

View File

@ -373,6 +373,7 @@ tailscale.com/cmd/tailscaled dependencies: (generated by github.com/tailscale/de
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/cmd/tailscaled+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/bools from tailscale.com/tailcfg
tailscale.com/types/dnstype from tailscale.com/ipn/ipnlocal+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/flagtype from tailscale.com/cmd/tailscaled

View File

@ -12,6 +12,7 @@ import (
"sync/atomic"
"time"
"tailscale.com/health"
"tailscale.com/logtail/backoff"
"tailscale.com/net/sockstats"
"tailscale.com/tailcfg"
@ -198,7 +199,11 @@ func NewNoStart(opts Options) (_ *Auto, err error) {
c.mapCtx, c.mapCancel = context.WithCancel(context.Background())
c.mapCtx = sockstats.WithSockStats(c.mapCtx, sockstats.LabelControlClientAuto, opts.Logf)
c.unregisterHealthWatch = opts.HealthTracker.RegisterWatcher(direct.ReportHealthChange)
c.unregisterHealthWatch = opts.HealthTracker.RegisterWatcher(func(c health.Change) {
if c.WarnableChanged {
direct.ReportWarnableChange(c.Warnable, c.UnhealthyState)
}
})
return c, nil
}

View File

@ -1625,7 +1625,7 @@ func postPingResult(start time.Time, logf logger.Logf, c *http.Client, pr *tailc
// ReportHealthChange reports to the control plane a change to this node's
// health. w must be non-nil. us can be nil to indicate a healthy state for w.
func (c *Direct) ReportHealthChange(w *health.Warnable, us *health.UnhealthyState) {
func (c *Direct) ReportWarnableChange(w *health.Warnable, us *health.UnhealthyState) {
if w == health.NetworkStatusWarnable || w == health.IPNStateWarnable || w == health.LoginStateWarnable {
// We don't report these. These include things like the network is down
// (in which case we can't report anyway) or the user wanted things

View File

@ -7,6 +7,7 @@ import (
"cmp"
"context"
"encoding/json"
"fmt"
"maps"
"net"
"reflect"
@ -828,6 +829,23 @@ func (ms *mapSession) sortedPeers() []tailcfg.NodeView {
func (ms *mapSession) netmap() *netmap.NetworkMap {
peerViews := ms.sortedPeers()
var displayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
if len(ms.lastHealth) > 0 {
// As they all resolve to the same ID, we can only pass on one message
// from ControlHealth. In practice we generally send 0 or 1, but
// if there are multiple, the last one wins.
h := ms.lastHealth[len(ms.lastHealth)-1]
displayMessages = map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"control-health": {
Title: "Coordination server reports an issue",
Severity: tailcfg.SeverityMedium,
Text: fmt.Sprintf("The coordination server is reporting a health issue: %s", h),
},
}
}
nm := &netmap.NetworkMap{
NodeKey: ms.publicNodeKey,
PrivateKey: ms.privateNodeKey,
@ -842,7 +860,7 @@ func (ms *mapSession) netmap() *netmap.NetworkMap {
SSHPolicy: ms.lastSSHPolicy,
CollectServices: ms.collectServices,
DERPMap: ms.lastDERPMap,
ControlHealth: ms.lastHealth,
DisplayMessages: displayMessages,
TKAEnabled: ms.lastTKAInfo != nil && !ms.lastTKAInfo.Disabled,
}

View File

@ -1150,7 +1150,7 @@ func TestNetmapHealthIntegration(t *testing.T) {
nm := ms.netmapForResponse(&tailcfg.MapResponse{
Health: []string{"Test message"},
})
ht.SetControlHealth(nm.ControlHealth)
ht.SetControlHealth(nm.DisplayMessages)
state := ht.CurrentState()
warning, ok := state.Warnings["control-health"]
@ -1164,7 +1164,7 @@ func TestNetmapHealthIntegration(t *testing.T) {
if got, want := warning.Severity, health.SeverityMedium; got != want {
t.Errorf("warning.Severity = %s, want %s", got, want)
}
if got, want := warning.Text, "The coordination server is reporting an health issue: Test message"; got != want {
if got, want := warning.Text, "The coordination server is reporting a health issue: Test message"; got != want {
t.Errorf("warning.Text = %q, want %q", got, want)
}
}

View File

@ -88,34 +88,35 @@ type Tracker struct {
// sysErr maps subsystems to their current error (or nil if the subsystem is healthy)
// Deprecated: using Warnables should be preferred
sysErr map[Subsystem]error
watchers set.HandleSet[func(*Warnable, *UnhealthyState)] // opt func to run if error state changes
watchers set.HandleSet[func(Change)] // opt func to run if error state changes
timer tstime.TimerController
latestVersion *tailcfg.ClientVersion // or nil
checkForUpdates bool
applyUpdates opt.Bool
inMapPoll bool
inMapPollSince time.Time
lastMapPollEndedAt time.Time
lastStreamedMapResponse time.Time
lastNoiseDial time.Time
derpHomeRegion int
derpHomeless bool
derpRegionConnected map[int]bool
derpRegionHealthProblem map[int]string
derpRegionLastFrame map[int]time.Time
derpMap *tailcfg.DERPMap // last DERP map from control, could be nil if never received one
lastMapRequestHeard time.Time // time we got a 200 from control for a MapRequest
ipnState string
ipnWantRunning bool
ipnWantRunningLastTrue time.Time // when ipnWantRunning last changed false -> true
anyInterfaceUp opt.Bool // empty means unknown (assume true)
controlHealth []string
lastLoginErr error
localLogConfigErr error
tlsConnectionErrors map[string]error // map[ServerName]error
metricHealthMessage *metrics.MultiLabelMap[metricHealthMessageLabel]
inMapPoll bool
inMapPollSince time.Time
lastMapPollEndedAt time.Time
lastStreamedMapResponse time.Time
lastNoiseDial time.Time
derpHomeRegion int
derpHomeless bool
derpRegionConnected map[int]bool
derpRegionHealthProblem map[int]string
derpRegionLastFrame map[int]time.Time
derpMap *tailcfg.DERPMap // last DERP map from control, could be nil if never received one
lastMapRequestHeard time.Time // time we got a 200 from control for a MapRequest
ipnState string
ipnWantRunning bool
ipnWantRunningLastTrue time.Time // when ipnWantRunning last changed false -> true
anyInterfaceUp opt.Bool // empty means unknown (assume true)
lastNotifiedControlMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage // latest control messages processed, kept for change detection
controlMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage // latest control messages received
lastLoginErr error
localLogConfigErr error
tlsConnectionErrors map[string]error // map[ServerName]error
metricHealthMessage *metrics.MultiLabelMap[metricHealthMessageLabel]
}
func (t *Tracker) now() time.Time {
@ -397,12 +398,18 @@ func (t *Tracker) setUnhealthyLocked(w *Warnable, args Args) {
prevWs := t.warnableVal[w]
mak.Set(&t.warnableVal, w, ws)
if !ws.Equal(prevWs) {
change := Change{
WarnableChanged: true,
Warnable: w,
UnhealthyState: w.unhealthyState(ws),
}
for _, cb := range t.watchers {
// If the Warnable has been unhealthy for more than its TimeToVisible, the callback should be
// executed immediately. Otherwise, the callback should be enqueued to run once the Warnable
// becomes visible.
if w.IsVisible(ws, t.now) {
cb(w, w.unhealthyState(ws))
cb(change)
continue
}
@ -415,7 +422,7 @@ func (t *Tracker) setUnhealthyLocked(w *Warnable, args Args) {
// Check if the Warnable is still unhealthy, as it could have become healthy between the time
// the timer was set for and the time it was executed.
if t.warnableVal[w] != nil {
cb(w, w.unhealthyState(ws))
cb(change)
delete(t.pendingVisibleTimers, w)
}
})
@ -448,8 +455,23 @@ func (t *Tracker) setHealthyLocked(w *Warnable) {
delete(t.pendingVisibleTimers, w)
}
change := Change{
WarnableChanged: true,
Warnable: w,
}
for _, cb := range t.watchers {
cb(w, nil)
cb(change)
}
}
// notifyWatchersControlChangedLocked calls each watcher to signal that control
// health messages have changed (and should be fetched via CurrentState).
func (t *Tracker) notifyWatchersControlChangedLocked() {
change := Change{
ControlHealthChanged: true,
}
for _, cb := range t.watchers {
cb(change)
}
}
@ -476,23 +498,52 @@ func (t *Tracker) AppendWarnableDebugFlags(base []string) []string {
return ret
}
// RegisterWatcher adds a function that will be called whenever the health state of any Warnable changes.
// If a Warnable becomes unhealthy or its unhealthy state is updated, the callback will be called with its
// current Representation.
// If a Warnable becomes healthy, the callback will be called with ws set to nil.
// The provided callback function will be executed in its own goroutine. The returned function can be used
// to unregister the callback.
func (t *Tracker) RegisterWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) {
return t.registerSyncWatcher(func(w *Warnable, r *UnhealthyState) {
go cb(w, r)
// Change is used to communicate a change to health. This could either be due to
// a Warnable changing from health to unhealthy (or vice-versa), or because the
// health messages received from the control-plane have changed.
type Change struct {
// Health messages from the POV of the control-plane server changed. If set, WarnableChanged, Warnable, and UnhealthyState are not set.
ControlHealthChanged bool
// A client Warnable changed state. Mutually exclusive with ControlHealthChanged. If set, Warnable is set and ControlHealthChanged is not set.
WarnableChanged bool
// Warnable that changed state. If it is now unhealthy, UnhealthyState will
// be set.
Warnable *Warnable
// UnhealthyState is set if the Warnable is unhealthy, and unset if the
// Warnable is now healthy.
UnhealthyState *UnhealthyState
}
// RegisterWatcher adds a function that will be called whenever the health state
// of any Warnable changes or the health messages from the control-plane change.
//
// If a Warnable becomes unhealthy or its unhealthy state is updated, the
// callback will be called with WarnableChanged set to true and the Warnable and
// its UnhealthyState.
//
// If a Warnable becomes healthy, the callback will be called with
// WarnableChanged set to true, the Warnable set, and UnhealthyState set to nil
//
// If the health messages from the control-plane change, the callback will be
// called with ControlHealthChanged set to true. Clients can fetch the set of
// control-plane health messages by calling [Tracker.CurrentState].
//
// The provided callback function will be executed in its own goroutine. The
// returned function can be used to unregister the callback.
func (t *Tracker) RegisterWatcher(cb func(Change)) (unregister func()) {
return t.registerSyncWatcher(func(c Change) {
go cb(c)
})
}
// registerSyncWatcher adds a function that will be called whenever the health
// state of any Warnable changes. The provided callback function will be
// executed synchronously. Call RegisterWatcher to register any callbacks that
// won't return from execution immediately.
func (t *Tracker) registerSyncWatcher(cb func(w *Warnable, r *UnhealthyState)) (unregister func()) {
// state changes. The provided callback function will be executed synchronously.
// Call RegisterWatcher to register any callbacks that won't return from
// execution immediately.
func (t *Tracker) registerSyncWatcher(cb func(c Change)) (unregister func()) {
if t.nil() {
return func() {}
}
@ -500,7 +551,7 @@ func (t *Tracker) registerSyncWatcher(cb func(w *Warnable, r *UnhealthyState)) (
t.mu.Lock()
defer t.mu.Unlock()
if t.watchers == nil {
t.watchers = set.HandleSet[func(*Warnable, *UnhealthyState)]{}
t.watchers = set.HandleSet[func(Change)]{}
}
handle := t.watchers.Add(cb)
if t.timer == nil {
@ -647,13 +698,15 @@ func (t *Tracker) updateLegacyErrorWarnableLocked(key Subsystem, err error) {
}
}
func (t *Tracker) SetControlHealth(problems []string) {
func (t *Tracker) SetControlHealth(problems map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage) {
if t.nil() {
return
}
t.mu.Lock()
defer t.mu.Unlock()
t.controlHealth = problems
t.controlMessages = problems
t.selfCheckLocked()
}
@ -1159,14 +1212,10 @@ func (t *Tracker) updateBuiltinWarnablesLocked() {
t.setHealthyLocked(derpRegionErrorWarnable)
}
if len(t.controlHealth) > 0 {
for _, s := range t.controlHealth {
t.setUnhealthyLocked(controlHealthWarnable, Args{
ArgError: s,
})
}
} else {
t.setHealthyLocked(controlHealthWarnable)
// Check if control health messages have changed
if !maps.EqualFunc(t.lastNotifiedControlMessages, t.controlMessages, tailcfg.DisplayMessage.Equal) {
t.lastNotifiedControlMessages = t.controlMessages
t.notifyWatchersControlChangedLocked()
}
if err := envknob.ApplyDiskConfigError(); err != nil {

View File

@ -25,6 +25,7 @@ func TestAppendWarnableDebugFlags(t *testing.T) {
w := Register(&Warnable{
Code: WarnableCode(fmt.Sprintf("warnable-code-%d", i)),
MapDebugFlag: fmt.Sprint(i),
Text: StaticMessage(""),
})
defer unregister(w)
if i%2 == 0 {
@ -114,7 +115,9 @@ func TestWatcher(t *testing.T) {
becameUnhealthy := make(chan struct{})
becameHealthy := make(chan struct{})
watcherFunc := func(w *Warnable, us *UnhealthyState) {
watcherFunc := func(c Change) {
w := c.Warnable
us := c.UnhealthyState
if w != testWarnable {
t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, testWarnable)
}
@ -184,7 +187,9 @@ func TestSetUnhealthyWithTimeToVisible(t *testing.T) {
becameUnhealthy := make(chan struct{})
becameHealthy := make(chan struct{})
watchFunc := func(w *Warnable, us *UnhealthyState) {
watchFunc := func(c Change) {
w := c.Warnable
us := c.UnhealthyState
if w != mw {
t.Fatalf("watcherFunc was called, but with an unexpected Warnable: %v, want: %v", w, w)
}
@ -457,21 +462,36 @@ func TestControlHealth(t *testing.T) {
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
ht.SetControlHealth([]string{"Test message"})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"control-health-test": {},
})
state := ht.CurrentState()
warning, ok := state.Warnings["control-health"]
warning, ok := state.Warnings["control-health-test"]
if !ok {
t.Fatal("no warning found in current state with code 'control-health'")
}
if got, want := warning.Title, "Coordination server reports an issue"; got != want {
t.Errorf("warning.Title = %q, want %q", got, want)
if got, want := warning.WarnableCode, "control-health-test"; string(got) != want {
t.Errorf("warning.WarnableCode = %q, want %q", got, want)
}
if got, want := warning.Severity, SeverityMedium; got != want {
t.Errorf("warning.Severity = %s, want %s", got, want)
}
if got, want := warning.Text, "The coordination server is reporting an health issue: Test message"; got != want {
t.Errorf("warning.Text = %q, want %q", got, want)
}
func TestControlHealthNotifiesOnSet(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
gotNotified := false
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test": {},
})
if !gotNotified {
t.Errorf("watcher did not get called, want it to be called")
}
}
@ -480,12 +500,45 @@ func TestControlHealthNotifiesOnChange(t *testing.T) {
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-1": {},
})
gotNotified := false
ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
ht.SetControlHealth([]string{"Test message"})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-2": {},
})
if !gotNotified {
t.Errorf("watcher did not get called, want it to be called")
}
}
func TestControlHealthNotifiesOnDetailsChange(t *testing.T) {
ht := Tracker{}
ht.SetIPNState("NeedsLogin", true)
ht.GotStreamedMapResponse()
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-1": {
Title: "Title",
},
})
gotNotified := false
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test-1": {
Title: "Updated title",
},
})
if !gotNotified {
t.Errorf("watcher did not get called, want it to be called")
@ -498,16 +551,20 @@ func TestControlHealthNoNotifyOnUnchanged(t *testing.T) {
ht.GotStreamedMapResponse()
// Set up an existing control health issue
ht.SetControlHealth([]string{"Test message"})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test": {},
})
// Now register our watcher
gotNotified := false
ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
// Send the same control health message again - should not notify
ht.SetControlHealth([]string{"Test message"})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"test": {},
})
if gotNotified {
t.Errorf("watcher got called, want it to not be called")
@ -519,11 +576,13 @@ func TestControlHealthIgnoredOutsideMapPoll(t *testing.T) {
ht.SetIPNState("NeedsLogin", true)
gotNotified := false
ht.registerSyncWatcher(func(_ *Warnable, _ *UnhealthyState) {
ht.registerSyncWatcher(func(_ Change) {
gotNotified = true
})
ht.SetControlHealth([]string{"Test message"})
ht.SetControlHealth(map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage{
"control-health": {},
})
state := ht.CurrentState()
_, ok := state.Warnings["control-health"]

View File

@ -5,6 +5,8 @@ package health
import (
"time"
"tailscale.com/tailcfg"
)
// State contains the health status of the backend, and is
@ -21,7 +23,8 @@ type State struct {
}
// UnhealthyState contains information to be shown to the user to inform them
// that a Warnable is currently unhealthy.
// that a Warnable is currently unhealthy or DisplayMessage is being sent from
// the control-plane.
type UnhealthyState struct {
WarnableCode WarnableCode
Severity Severity
@ -98,11 +101,39 @@ func (t *Tracker) CurrentState() *State {
wm[w.Code] = *w.unhealthyState(ws)
}
for id, message := range t.lastNotifiedControlMessages {
s := UnhealthyStateFromDisplayMessage(id, message)
wm[s.WarnableCode] = s
}
return &State{
Warnings: wm,
}
}
func UnhealthyStateFromDisplayMessage(id tailcfg.DisplayMessageID, message tailcfg.DisplayMessage) UnhealthyState {
severity := SeverityMedium
switch message.Severity {
case tailcfg.SeverityHigh:
severity = SeverityHigh
case tailcfg.SeverityMedium:
severity = SeverityMedium
case tailcfg.SeverityLow:
severity = SeverityLow
}
state := UnhealthyState{
WarnableCode: WarnableCode(id),
Severity: severity,
Title: message.Title,
Text: message.Text,
ImpactsConnectivity: message.ImpactsConnectivity,
}
return state
}
// isEffectivelyHealthyLocked reports whether w is effectively healthy.
// That means it's either actually healthy or it has a dependency that
// that's unhealthy, so we should treat w as healthy to not spam users

View File

@ -238,16 +238,6 @@ var applyDiskConfigWarnable = Register(&Warnable{
},
})
// controlHealthWarnable is a Warnable that warns the user that the coordination server is reporting an health issue.
var controlHealthWarnable = Register(&Warnable{
Code: "control-health",
Title: "Coordination server reports an issue",
Severity: SeverityMedium,
Text: func(args Args) string {
return fmt.Sprintf("The coordination server is reporting an health issue: %v", args[ArgError])
},
})
// warmingUpWarnableDuration is the duration for which the warmingUpWarnable is reported by the backend after the user
// has changed ipnWantRunning to true from false.
const warmingUpWarnableDuration = 5 * time.Second

View File

@ -931,11 +931,15 @@ func (b *LocalBackend) linkChange(delta *netmon.ChangeDelta) {
}
}
func (b *LocalBackend) onHealthChange(w *health.Warnable, us *health.UnhealthyState) {
if us == nil {
b.logf("health(warnable=%s): ok", w.Code)
} else {
b.logf("health(warnable=%s): error: %s", w.Code, us.Text)
func (b *LocalBackend) onHealthChange(change health.Change) {
if change.WarnableChanged {
w := change.Warnable
us := change.UnhealthyState
if us == nil {
b.logf("health(warnable=%s): ok", w.Code)
} else {
b.logf("health(warnable=%s): error: %s", w.Code, us.Text)
}
}
// Whenever health changes, send the current health state to the frontend.
@ -6132,7 +6136,7 @@ func (b *LocalBackend) setNetMapLocked(nm *netmap.NetworkMap) {
b.pauseOrResumeControlClientLocked()
if nm != nil {
b.health.SetControlHealth(nm.ControlHealth)
b.health.SetControlHealth(nm.DisplayMessages)
} else {
b.health.SetControlHealth(nil)
}

View File

@ -20,6 +20,7 @@ import (
"strings"
"time"
"tailscale.com/types/bools"
"tailscale.com/types/dnstype"
"tailscale.com/types/key"
"tailscale.com/types/opt"
@ -2028,7 +2029,7 @@ type MapResponse struct {
// plane's perspective. A nil value means no change from the previous
// MapResponse. A non-nil 0-length slice restores the health to good (no
// known problems). A non-zero length slice are the list of problems that
// the control place sees.
// the control plane sees.
//
// Note that this package's type, due its use of a slice and omitempty, is
// unable to marshal a zero-length non-nil slice. The control server needs
@ -2078,6 +2079,65 @@ type MapResponse struct {
DefaultAutoUpdate opt.Bool `json:",omitempty"`
}
// DisplayMessage represents a health state of the node from the control plane's
// perspective. It is deliberately similar to health.Warnable as both get
// converted into health.UnhealthyState to be sent to the GUI.
type DisplayMessage struct {
// Title is a string that the GUI uses as title for this message. The title
// should be short and fit in a single line.
Title string
// Text is an extended string that the GUI will display to the user.
Text string
// Severity is the severity of the DisplayMessage, which the GUI can use to
// determine how to display it. Maps to health.Severity.
Severity DisplayMessageSeverity
// ImpactsConnectivity is whether the health problem will impact the user's
// ability to connect to the Internet or other nodes on the tailnet, which
// the GUI can use to determine how to display it.
ImpactsConnectivity bool `json:",omitempty"`
}
// DisplayMessageID is a string that uniquely identifies the kind of health
// issue (e.g. "session-expired").
type DisplayMessageID string
// Equal returns true iff all fields are equal.
func (m DisplayMessage) Equal(o DisplayMessage) bool {
if c := cmp.Compare(m.Title, o.Title); c != 0 {
return false
}
if c := cmp.Compare(m.Text, o.Text); c != 0 {
return false
}
if c := cmp.Compare(m.Severity, o.Severity); c != 0 {
return false
}
if c := bools.Compare(m.ImpactsConnectivity, o.ImpactsConnectivity); c != 0 {
return false
}
return true
}
// DisplayMessageSeverity represents how serious a [DisplayMessage] is. Analogous
// to health.Severity.
type DisplayMessageSeverity string
const (
// SeverityHigh is the highest severity level, used for critical errors that need immediate attention.
// On platforms where the client GUI can deliver notifications, a SeverityHigh message will trigger
// a modal notification.
SeverityHigh DisplayMessageSeverity = "high"
// SeverityMedium is used for errors that are important but not critical. This won't trigger a modal
// notification, however it will be displayed in a more visible way than a SeverityLow message.
SeverityMedium DisplayMessageSeverity = "medium"
// SeverityLow is used for less important notices that don't need immediate attention. The user will
// have to go to a Settings window, or another "hidden" GUI location to see these messages.
SeverityLow DisplayMessageSeverity = "low"
)
// ClientVersion is information about the latest client version that's available
// for the client (and whether they're already running it).
//

View File

@ -878,3 +878,79 @@ func TestCheckTag(t *testing.T) {
})
}
}
func TestDisplayMessageEqual(t *testing.T) {
base := DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
}
type test struct {
name string
value DisplayMessage
wantEqual bool
}
for _, test := range []test{
{
name: "same",
value: DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
},
wantEqual: true,
},
{
name: "different-title",
value: DisplayMessage{
Title: "different title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
},
wantEqual: false,
},
{
name: "different-text",
value: DisplayMessage{
Title: "title",
Text: "different text",
Severity: SeverityHigh,
ImpactsConnectivity: false,
},
wantEqual: false,
},
{
name: "different-severity",
value: DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityMedium,
ImpactsConnectivity: false,
},
wantEqual: false,
},
{
name: "different-impactsConnectivity",
value: DisplayMessage{
Title: "title",
Text: "text",
Severity: SeverityHigh,
ImpactsConnectivity: true,
},
wantEqual: false,
},
} {
t.Run(test.name, func(t *testing.T) {
got := base.Equal(test.value)
if got != test.wantEqual {
t.Errorf("Equal: got %t, want %t", got, test.wantEqual)
}
})
}
}

View File

@ -323,7 +323,7 @@ tailscale.com/tsnet dependencies: (generated by github.com/tailscale/depaware)
tailscale.com/tsweb from tailscale.com/util/eventbus
tailscale.com/tsweb/varz from tailscale.com/tsweb+
tailscale.com/types/appctype from tailscale.com/ipn/ipnlocal
tailscale.com/types/bools from tailscale.com/tsnet
tailscale.com/types/bools from tailscale.com/tsnet+
tailscale.com/types/dnstype from tailscale.com/client/local+
tailscale.com/types/empty from tailscale.com/ipn+
tailscale.com/types/ipproto from tailscale.com/ipn+

View File

@ -54,12 +54,12 @@ type NetworkMap struct {
// between updates and should not be modified.
DERPMap *tailcfg.DERPMap
// ControlHealth are the list of health check problems for this
// DisplayMessages are the list of health check problems for this
// node from the perspective of the control plane.
// If empty, there are no known problems from the control plane's
// point of view, but the node might know about its own health
// check problems.
ControlHealth []string
DisplayMessages map[tailcfg.DisplayMessageID]tailcfg.DisplayMessage
// TKAEnabled indicates whether the tailnet key authority should be
// enabled, from the perspective of the control plane.