Localize daemon notifications via stable message keys

This commit is contained in:
Viktor Liu
2026-08-05 17:53:02 +02:00
parent f2318a8fef
commit b3ead5ee7e
37 changed files with 1592 additions and 261 deletions
+8 -8
View File
@@ -11,10 +11,11 @@ import (
// emits.
// Metadata keys attached by the daemon to session-warning SystemEvents.
// The UI tray reads these to build a locale-aware notification without
// relying on the daemon's locale-less UserMessage string, and to
// disambiguate the T-WarningLead notification from the T-FinalWarningLead
// fallback that auto-opens the SessionAboutToExpire dialog.
// The notification text itself travels as a message key (see
// proto.UserMsgSessionExpiresIn); these keys carry the structured deadline
// the UI needs for its own countdown label, and disambiguate the
// T-WarningLead notification from the T-FinalWarningLead fallback that
// auto-opens the SessionAboutToExpire dialog.
const (
// MetaSessionWarning is set to "true" on both warning events (T-10 and
// T-2) so the UI can detect a session-warning SystemEvent without
@@ -36,10 +37,9 @@ const (
// MetaSessionDeadlineRejected is attached to the ERROR/AUTHENTICATION
// SystemEvent the daemon emits when it discards a deadline from the
// management server (pre-epoch, too far in the future, or past the
// clock-skew tolerance). The value is the rejection reason string.
// userMessage is left empty; the UI detects the event via this key
// and builds a localized notification — same pattern as the session
// warnings above.
// clock-skew tolerance). The value is the rejection reason string,
// which is diagnostic only: the user-facing text travels as
// proto.UserMsgSessionDeadlineReject.
MetaSessionDeadlineRejected = "session_deadline_rejected"
)
+18 -2
View File
@@ -21,6 +21,7 @@ import (
log "github.com/sirupsen/logrus"
cProto "github.com/netbirdio/netbird/client/proto"
nbstatus "github.com/netbirdio/netbird/client/status"
)
const (
@@ -80,7 +81,7 @@ type StatusRecorder interface {
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
userMessage string,
userMessage *cProto.UserMessage,
metadata map[string]string,
)
}
@@ -376,7 +377,22 @@ func publishWarning(recorder StatusRecorder, deadline time.Time, final bool) {
cProto.SystemEvent_CRITICAL,
cProto.SystemEvent_AUTHENTICATION,
message,
"",
warningUserMessage(deadline),
meta,
)
}
// warningUserMessage builds the localizable body for a session warning. The
// remaining time is rendered here rather than in the UI so every consumer of the
// event agrees on it; a deadline that is already gone (a warning delivered late)
// drops to the variant without a countdown.
func warningUserMessage(deadline time.Time) *cProto.UserMessage {
remaining := time.Until(deadline)
if remaining <= 0 {
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresSoon).
WithTitle(cProto.TitleSessionWarning)
}
return cProto.NewUserMessage(cProto.UserMsgSessionExpiresIn,
cProto.ArgRemaining, nbstatus.HumaniseDuration(remaining)).
WithTitle(cProto.TitleSessionWarning)
}
@@ -34,6 +34,9 @@ type event struct {
severity cProto.SystemEvent_Severity
category cProto.SystemEvent_Category
message string
msgKey cProto.UserMessageKey
titleKey cProto.UserMessageKey
msgArgs map[string]string
meta map[string]string
}
@@ -62,7 +65,7 @@ func (r *fakeRecorder) PublishEvent(
severity cProto.SystemEvent_Severity,
category cProto.SystemEvent_Category,
message string,
_ string,
userMessage *cProto.UserMessage,
metadata map[string]string,
) {
r.mu.Lock()
@@ -72,6 +75,9 @@ func (r *fakeRecorder) PublishEvent(
severity: severity,
category: category,
message: message,
msgKey: userMessage.Key(),
titleKey: userMessage.TitleKey(),
msgArgs: userMessage.Args(),
meta: metadata,
})
}
@@ -186,6 +192,33 @@ func TestWarningFiresOnceWithinLeadWindow(t *testing.T) {
}
}
// The UI localizes the warning body from the key rather than from the daemon's
// English text, so a warning that ships no key would silently regress to English.
func TestWarningCarriesLocalizableMessage(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
_ = w.Update(time.Now().Add(80 * time.Millisecond))
events := waitForEvents(t, r, 2)
warning := events[1]
if !warning.isWarning() {
t.Fatalf("event[1] should be a warning publish, got %+v", warning)
}
if warning.msgKey != cProto.UserMsgSessionExpiresIn {
t.Errorf("warning message key = %q, want %q", warning.msgKey, cProto.UserMsgSessionExpiresIn)
}
if warning.titleKey != cProto.TitleSessionWarning {
t.Errorf("warning title key = %q, want %q", warning.titleKey, cProto.TitleSessionWarning)
}
// The remaining time is rendered at publish time so every consumer of the
// event agrees on it; the exact value depends on timer slack.
if remaining := warning.msgArgs[cProto.ArgRemaining]; remaining == "" {
t.Errorf("warning is missing the %q argument, args=%v", cProto.ArgRemaining, warning.msgArgs)
}
}
func TestWarningFiresImmediatelyWhenAlreadyInsideWindow(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r) // lead > delta => fire immediately