From b3ead5ee7e3677fd25c3c44a6f84a07e98164c34 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 5 Aug 2026 17:53:02 +0200 Subject: [PATCH] Localize daemon notifications via stable message keys --- client/internal/auth/sessionwatch/event.go | 16 +- client/internal/auth/sessionwatch/watcher.go | 20 +- .../auth/sessionwatch/watcher_test.go | 35 +- client/internal/connect.go | 2 +- client/internal/dns/server.go | 4 +- client/internal/engine.go | 2 +- client/internal/engine_authsession.go | 3 +- client/internal/peer/status.go | 12 +- client/internal/routemanager/client/client.go | 12 +- client/internal/updater/manager.go | 18 +- client/proto/daemon.pb.go | 300 ++++++++++-------- client/proto/daemon.proto | 16 + client/proto/metadata.go | 8 +- client/proto/usermsg.go | 183 +++++++++++ client/proto/usermsg_test.go | 91 ++++++ client/server/mdm.go | 6 +- client/server/network.go | 5 +- client/server/server.go | 4 +- client/ui/i18n/TRANSLATING.md | 6 + client/ui/i18n/bundle.go | 17 +- client/ui/i18n/eventkeys_test.go | 126 ++++++++ client/ui/i18n/locales/de/common.json | 63 ++++ client/ui/i18n/locales/en/common.json | 84 +++++ client/ui/i18n/locales/es/common.json | 63 ++++ client/ui/i18n/locales/fr/common.json | 63 ++++ client/ui/i18n/locales/hu/common.json | 63 ++++ client/ui/i18n/locales/it/common.json | 63 ++++ client/ui/i18n/locales/ja/common.json | 63 ++++ client/ui/i18n/locales/pt/common.json | 63 ++++ client/ui/i18n/locales/ru/common.json | 63 ++++ client/ui/i18n/locales/zh-CN/common.json | 63 ++++ client/ui/localizer.go | 28 ++ client/ui/services/daemon_feed.go | 25 +- client/ui/tray.go | 1 - client/ui/tray_events.go | 91 ++---- client/ui/tray_events_test.go | 150 +++++++++ client/ui/tray_session.go | 21 -- 37 files changed, 1592 insertions(+), 261 deletions(-) create mode 100644 client/proto/usermsg.go create mode 100644 client/proto/usermsg_test.go create mode 100644 client/ui/i18n/eventkeys_test.go create mode 100644 client/ui/tray_events_test.go diff --git a/client/internal/auth/sessionwatch/event.go b/client/internal/auth/sessionwatch/event.go index 3e55b26dd..4d81e7289 100644 --- a/client/internal/auth/sessionwatch/event.go +++ b/client/internal/auth/sessionwatch/event.go @@ -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" ) diff --git a/client/internal/auth/sessionwatch/watcher.go b/client/internal/auth/sessionwatch/watcher.go index e685c28d0..0d9a768cc 100644 --- a/client/internal/auth/sessionwatch/watcher.go +++ b/client/internal/auth/sessionwatch/watcher.go @@ -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) +} diff --git a/client/internal/auth/sessionwatch/watcher_test.go b/client/internal/auth/sessionwatch/watcher_test.go index 4b49a94b6..8cf329018 100644 --- a/client/internal/auth/sessionwatch/watcher_test.go +++ b/client/internal/auth/sessionwatch/watcher_test.go @@ -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 diff --git a/client/internal/connect.go b/client/internal/connect.go index 87126b222..9aef68bd6 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -163,7 +163,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan rec.PublishEvent( cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM, "panic occurred", - "The Netbird service panicked. Please restart the service and submit a bug report with the client logs.", + cProto.NewUserMessage(cProto.UserMsgPanic), nil, ) } diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index f79454457..2f801f374 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -1134,7 +1134,7 @@ func (s *DefaultServer) projectHealthy(p *nsGroupProj, servers []netip.AddrPort) proto.SystemEvent_INFO, proto.SystemEvent_DNS, "Nameserver group recovered", - "DNS servers are reachable again.", + proto.NewUserMessage(proto.UserMsgDNSRecovered), map[string]string{"upstreams": joinAddrPorts(servers)}, ) p.warningActive = false @@ -1157,7 +1157,7 @@ func (s *DefaultServer) projectUnhealthy(p *nsGroupProj, servers []netip.AddrPor proto.SystemEvent_WARNING, proto.SystemEvent_DNS, "Nameserver group unreachable", - "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.", + proto.NewUserMessage(proto.UserMsgDNSUnreachable), map[string]string{"upstreams": joinAddrPorts(servers)}, ) p.warningActive = true diff --git a/client/internal/engine.go b/client/internal/engine.go index 617892e43..49db5d33e 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -1074,7 +1074,7 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return err } - e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", "", nil) + e.statusRecorder.PublishEvent(cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Network map updated", nil, nil) return nil } diff --git a/client/internal/engine_authsession.go b/client/internal/engine_authsession.go index 725c0903f..b8887b76a 100644 --- a/client/internal/engine_authsession.go +++ b/client/internal/engine_authsession.go @@ -57,7 +57,8 @@ func (e *Engine) ApplySessionDeadline(ts *timestamppb.Timestamp) { cProto.SystemEvent_ERROR, cProto.SystemEvent_AUTHENTICATION, "session deadline rejected", - "", + cProto.NewUserMessage(cProto.UserMsgSessionDeadlineReject). + WithTitle(cProto.TitleSessionDeadlineReject), map[string]string{sessionwatch.MetaSessionDeadlineRejected: err.Error()}, ) } diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index 423ce9b23..7175abeb1 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -1281,12 +1281,15 @@ func (d *Status) numOfPeers() int { return len(d.peers) + len(d.offlinePeers) } -// PublishEvent adds an event to the queue and distributes it to all subscribers +// PublishEvent adds an event to the queue and distributes it to all subscribers. +// msg is the English log-facing description; userMsg is the localizable +// user-facing message, or nil for an internal control event that must not +// surface as a notification. func (d *Status) PublishEvent( severity proto.SystemEvent_Severity, category proto.SystemEvent_Category, msg string, - userMsg string, + userMsg *proto.UserMessage, metadata map[string]string, ) { event := &proto.SystemEvent{ @@ -1294,7 +1297,10 @@ func (d *Status) PublishEvent( Severity: severity, Category: category, Message: msg, - UserMessage: userMsg, + UserMessage: userMsg.Text(), + MessageKey: string(userMsg.Key()), + MessageArgs: userMsg.Args(), + TitleKey: string(userMsg.TitleKey()), Metadata: metadata, Timestamp: timestamppb.Now(), } diff --git a/client/internal/routemanager/client/client.go b/client/internal/routemanager/client/client.go index c691c54f8..0af38d5de 100644 --- a/client/internal/routemanager/client/client.go +++ b/client/internal/routemanager/client/client.go @@ -403,7 +403,7 @@ func (w *Watcher) connectEvent(route *route.Route) { proto.SystemEvent_INFO, proto.SystemEvent_NETWORK, "Default route added", - "Exit node connected.", + proto.NewUserMessage(proto.UserMsgExitNodeConnected), meta, ) } @@ -423,7 +423,7 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) { var severity proto.SystemEvent_Severity var message string - var userMessage string + var userMessage *proto.UserMessage meta := make(map[string]string) if route != nil { @@ -435,22 +435,22 @@ func (w *Watcher) disconnectEvent(route *route.Route, rsn reason) { case reasonShutdown: severity = proto.SystemEvent_INFO message = "Default route removed" - userMessage = "Exit node disconnected." + userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnected) case reasonRouteUpdate: severity = proto.SystemEvent_INFO message = "Default route updated due to configuration change" case reasonPeerUpdate: severity = proto.SystemEvent_WARNING message = "Default route disconnected due to peer unreachability" - userMessage = "Exit node connection lost. Your internet access might be affected." + userMessage = proto.NewUserMessage(proto.UserMsgExitNodeConnectionLost) case reasonHA: severity = proto.SystemEvent_INFO message = "Default route disconnected due to high availability change" - userMessage = "Exit node disconnected due to high availability change." + userMessage = proto.NewUserMessage(proto.UserMsgExitNodeHAChange) default: severity = proto.SystemEvent_ERROR message = "Default route disconnected for unknown reasons" - userMessage = "Exit node disconnected for unknown reasons." + userMessage = proto.NewUserMessage(proto.UserMsgExitNodeDisconnectedUnknown) } w.statusRecorder.PublishEvent( diff --git a/client/internal/updater/manager.go b/client/internal/updater/manager.go index 7fc300739..b8c3394a2 100644 --- a/client/internal/updater/manager.go +++ b/client/internal/updater/manager.go @@ -94,7 +94,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) { cProto.SystemEvent_ERROR, cProto.SystemEvent_SYSTEM, "Auto-update failed", - fmt.Sprintf("Auto-update failed: %s", reason), + cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, reason), nil, ) } @@ -115,7 +115,7 @@ func (m *Manager) CheckUpdateSuccess(ctx context.Context) { cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "Auto-update completed", - fmt.Sprintf("Your NetBird Client was auto-updated to version %s", m.currentVersion), + cProto.NewUserMessage(cProto.UserMsgUpdateCompleted, cProto.ArgVersion, m.currentVersion), nil, ) return @@ -272,7 +272,7 @@ func (m *Manager) NotifyUI() { cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "New version available", - "", + nil, map[string]string{"new_version_available": latestVersion.String()}, ) return @@ -283,7 +283,7 @@ func (m *Manager) NotifyUI() { cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "New version available", - "", + nil, map[string]string{"new_version_available": pendingVersion.String(), "enforced": "true"}, ) } @@ -384,7 +384,7 @@ func (m *Manager) handleUpdate(ctx context.Context) { cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "New version available", - "", + nil, map[string]string{"new_version_available": updateVersion.String()}, ) return @@ -401,7 +401,7 @@ func (m *Manager) handleUpdate(ctx context.Context) { cProto.SystemEvent_INFO, cProto.SystemEvent_SYSTEM, "New version available", - "", + nil, map[string]string{"new_version_available": updateVersion.String(), "enforced": "true"}, ) } @@ -411,14 +411,14 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM, "Updating client", - "Installing update now.", + cProto.NewUserMessage(cProto.UserMsgUpdateInstalling), nil, ) m.statusRecorder.PublishEvent( cProto.SystemEvent_CRITICAL, cProto.SystemEvent_SYSTEM, "", - "", + nil, map[string]string{"progress_window": "show", "version": pendingVersion.String()}, ) @@ -441,7 +441,7 @@ func (m *Manager) install(ctx context.Context, pendingVersion *v.Version) error cProto.SystemEvent_ERROR, cProto.SystemEvent_SYSTEM, "Auto-update failed", - fmt.Sprintf("Auto-update failed: %v", err), + cProto.NewUserMessage(cProto.UserMsgUpdateFailed, cProto.ArgReason, err.Error()), nil, ) return err diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index d4deeb8ec..69bed898f 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -3909,14 +3909,30 @@ func (*SubscribeRequest) Descriptor() ([]byte, []int) { } type SystemEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"` - Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"` - Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` - UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"` - Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Severity SystemEvent_Severity `protobuf:"varint,2,opt,name=severity,proto3,enum=daemon.SystemEvent_Severity" json:"severity,omitempty"` + Category SystemEvent_Category `protobuf:"varint,3,opt,name=category,proto3,enum=daemon.SystemEvent_Category" json:"category,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + // userMessage is the daemon's English rendering of messageKey, kept for the + // CLI and for UIs that predate messageKey. UIs that localise read messageKey + // and treat this as the fallback. + UserMessage string `protobuf:"bytes,5,opt,name=userMessage,proto3" json:"userMessage,omitempty"` + Timestamp *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + Metadata map[string]string `protobuf:"bytes,7,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // messageKey names a user-facing message in a stable, locale-independent + // form. A UI resolves it against its own translation bundle and substitutes + // messageArgs; an unrecognised key falls back to userMessage. Empty on events + // that carry no user-facing text. + MessageKey string `protobuf:"bytes,8,opt,name=messageKey,proto3" json:"messageKey,omitempty"` + // messageArgs holds the placeholder name/value pairs for messageKey, e.g. + // {"version": "0.60.1"} for a "{version}" template. Values are data the + // daemon cannot localise (versions, addresses, error text). + MessageArgs map[string]string `protobuf:"bytes,9,rep,name=messageArgs,proto3" json:"messageArgs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + // titleKey names the notification title in the same way as messageKey. Empty + // when the event has no dedicated title, in which case a UI composes one from + // severity and category. + TitleKey string `protobuf:"bytes,10,opt,name=titleKey,proto3" json:"titleKey,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -4000,6 +4016,27 @@ func (x *SystemEvent) GetMetadata() map[string]string { return nil } +func (x *SystemEvent) GetMessageKey() string { + if x != nil { + return x.MessageKey + } + return "" +} + +func (x *SystemEvent) GetMessageArgs() map[string]string { + if x != nil { + return x.MessageArgs + } + return nil +} + +func (x *SystemEvent) GetTitleKey() string { + if x != nil { + return x.TitleKey + } + return "" +} + type GetEventsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -7332,7 +7369,7 @@ const file_daemon_proto_rawDesc = "" + "\x13TracePacketResponse\x12*\n" + "\x06stages\x18\x01 \x03(\v2\x12.daemon.TraceStageR\x06stages\x12+\n" + "\x11final_disposition\x18\x02 \x01(\bR\x10finalDisposition\"\x12\n" + - "\x10SubscribeRequest\"\x93\x04\n" + + "\x10SubscribeRequest\"\xd7\x05\n" + "\vSystemEvent\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x128\n" + "\bseverity\x18\x02 \x01(\x0e2\x1c.daemon.SystemEvent.SeverityR\bseverity\x128\n" + @@ -7340,9 +7377,18 @@ const file_daemon_proto_rawDesc = "" + "\amessage\x18\x04 \x01(\tR\amessage\x12 \n" + "\vuserMessage\x18\x05 \x01(\tR\vuserMessage\x128\n" + "\ttimestamp\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12=\n" + - "\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x1a;\n" + + "\bmetadata\x18\a \x03(\v2!.daemon.SystemEvent.MetadataEntryR\bmetadata\x12\x1e\n" + + "\n" + + "messageKey\x18\b \x01(\tR\n" + + "messageKey\x12F\n" + + "\vmessageArgs\x18\t \x03(\v2$.daemon.SystemEvent.MessageArgsEntryR\vmessageArgs\x12\x1a\n" + + "\btitleKey\x18\n" + + " \x01(\tR\btitleKey\x1a;\n" + "\rMetadataEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1a>\n" + + "\x10MessageArgsEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\":\n" + "\bSeverity\x12\b\n" + "\x04INFO\x10\x00\x12\v\n" + @@ -7660,7 +7706,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 110) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 111) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -7776,16 +7822,17 @@ var file_daemon_proto_goTypes = []any{ nil, // 111: daemon.Network.ResolvedIPsEntry (*PortInfo_Range)(nil), // 112: daemon.PortInfo.Range nil, // 113: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 114: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 115: google.protobuf.Timestamp + nil, // 114: daemon.SystemEvent.MessageArgsEntry + (*durationpb.Duration)(nil), // 115: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 116: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 114, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 115, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 115, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 115, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 115, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 114, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 116, // 2: daemon.StatusResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 116, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 116, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 115, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -7808,114 +7855,115 @@ var file_daemon_proto_depIdxs = []int32{ 54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 115, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 116, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp 113, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry - 57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 114, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration - 72, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile - 115, // 33: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp - 1, // 34: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 104, // 35: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 114, // 36: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 114, // 37: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration - 30, // 38: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList - 5, // 39: daemon.DaemonService.Login:input_type -> daemon.LoginRequest - 7, // 40: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest - 9, // 41: daemon.DaemonService.Up:input_type -> daemon.UpRequest - 11, // 42: daemon.DaemonService.Status:input_type -> daemon.StatusRequest - 11, // 43: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest - 13, // 44: daemon.DaemonService.Down:input_type -> daemon.DownRequest - 15, // 45: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest - 26, // 46: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest - 28, // 47: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest - 28, // 48: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest - 4, // 49: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest - 35, // 50: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest - 37, // 51: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest - 39, // 52: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest - 44, // 53: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest - 46, // 54: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest - 48, // 55: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest - 50, // 56: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest - 53, // 57: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 105, // 58: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 107, // 59: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 109, // 60: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest - 56, // 61: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest - 58, // 62: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest - 41, // 63: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest - 60, // 64: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest - 62, // 65: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest - 64, // 66: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest - 66, // 67: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest - 68, // 68: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest - 70, // 69: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest - 73, // 70: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest - 75, // 71: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest - 79, // 72: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 82, // 73: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 84, // 74: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 86, // 75: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 88, // 76: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 90, // 77: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest - 92, // 78: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest - 94, // 79: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest - 96, // 80: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 98, // 81: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 100, // 82: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 102, // 83: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest - 77, // 84: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest - 6, // 85: daemon.DaemonService.Login:output_type -> daemon.LoginResponse - 8, // 86: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse - 10, // 87: daemon.DaemonService.Up:output_type -> daemon.UpResponse - 12, // 88: daemon.DaemonService.Status:output_type -> daemon.StatusResponse - 12, // 89: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse - 14, // 90: daemon.DaemonService.Down:output_type -> daemon.DownResponse - 16, // 91: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse - 27, // 92: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse - 29, // 93: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse - 29, // 94: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse - 34, // 95: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse - 36, // 96: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse - 38, // 97: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse - 40, // 98: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse - 45, // 99: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse - 47, // 100: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse - 49, // 101: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse - 51, // 102: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse - 55, // 103: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 106, // 104: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 108, // 105: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 110, // 106: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse - 57, // 107: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent - 59, // 108: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse - 42, // 109: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse - 61, // 110: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse - 63, // 111: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse - 65, // 112: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse - 67, // 113: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse - 69, // 114: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse - 71, // 115: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse - 74, // 116: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse - 76, // 117: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse - 80, // 118: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 83, // 119: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 85, // 120: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 87, // 121: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 89, // 122: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 91, // 123: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse - 93, // 124: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse - 95, // 125: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse - 97, // 126: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 99, // 127: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 101, // 128: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 103, // 129: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent - 78, // 130: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse - 85, // [85:131] is the sub-list for method output_type - 39, // [39:85] is the sub-list for method input_type - 39, // [39:39] is the sub-list for extension type_name - 39, // [39:39] is the sub-list for extension extendee - 0, // [0:39] is the sub-list for field type_name + 114, // 30: daemon.SystemEvent.messageArgs:type_name -> daemon.SystemEvent.MessageArgsEntry + 57, // 31: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent + 115, // 32: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 72, // 33: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile + 116, // 34: daemon.WaitExtendAuthSessionResponse.sessionExpiresAt:type_name -> google.protobuf.Timestamp + 1, // 35: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol + 104, // 36: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 115, // 37: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 115, // 38: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 30, // 39: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList + 5, // 40: daemon.DaemonService.Login:input_type -> daemon.LoginRequest + 7, // 41: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest + 9, // 42: daemon.DaemonService.Up:input_type -> daemon.UpRequest + 11, // 43: daemon.DaemonService.Status:input_type -> daemon.StatusRequest + 11, // 44: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest + 13, // 45: daemon.DaemonService.Down:input_type -> daemon.DownRequest + 15, // 46: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest + 26, // 47: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest + 28, // 48: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest + 28, // 49: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest + 4, // 50: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest + 35, // 51: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest + 37, // 52: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest + 39, // 53: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest + 44, // 54: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest + 46, // 55: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest + 48, // 56: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest + 50, // 57: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest + 53, // 58: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest + 105, // 59: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 107, // 60: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 109, // 61: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 56, // 62: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest + 58, // 63: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest + 41, // 64: daemon.DaemonService.RegisterUILog:input_type -> daemon.RegisterUILogRequest + 60, // 65: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest + 62, // 66: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest + 64, // 67: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest + 66, // 68: daemon.DaemonService.RenameProfile:input_type -> daemon.RenameProfileRequest + 68, // 69: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest + 70, // 70: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest + 73, // 71: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest + 75, // 72: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest + 79, // 73: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest + 82, // 74: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 84, // 75: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 86, // 76: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 88, // 77: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 90, // 78: daemon.DaemonService.RequestExtendAuthSession:input_type -> daemon.RequestExtendAuthSessionRequest + 92, // 79: daemon.DaemonService.WaitExtendAuthSession:input_type -> daemon.WaitExtendAuthSessionRequest + 94, // 80: daemon.DaemonService.DismissSessionWarning:input_type -> daemon.DismissSessionWarningRequest + 96, // 81: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 98, // 82: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 100, // 83: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 102, // 84: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 77, // 85: daemon.DaemonService.WailsUIReady:input_type -> daemon.WailsUIReadyRequest + 6, // 86: daemon.DaemonService.Login:output_type -> daemon.LoginResponse + 8, // 87: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse + 10, // 88: daemon.DaemonService.Up:output_type -> daemon.UpResponse + 12, // 89: daemon.DaemonService.Status:output_type -> daemon.StatusResponse + 12, // 90: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse + 14, // 91: daemon.DaemonService.Down:output_type -> daemon.DownResponse + 16, // 92: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse + 27, // 93: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse + 29, // 94: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse + 29, // 95: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse + 34, // 96: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse + 36, // 97: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse + 38, // 98: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse + 40, // 99: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse + 45, // 100: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse + 47, // 101: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse + 49, // 102: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse + 51, // 103: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse + 55, // 104: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse + 106, // 105: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 108, // 106: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 110, // 107: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 57, // 108: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent + 59, // 109: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse + 42, // 110: daemon.DaemonService.RegisterUILog:output_type -> daemon.RegisterUILogResponse + 61, // 111: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse + 63, // 112: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse + 65, // 113: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse + 67, // 114: daemon.DaemonService.RenameProfile:output_type -> daemon.RenameProfileResponse + 69, // 115: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse + 71, // 116: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse + 74, // 117: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse + 76, // 118: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse + 80, // 119: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse + 83, // 120: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 85, // 121: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 87, // 122: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 89, // 123: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 91, // 124: daemon.DaemonService.RequestExtendAuthSession:output_type -> daemon.RequestExtendAuthSessionResponse + 93, // 125: daemon.DaemonService.WaitExtendAuthSession:output_type -> daemon.WaitExtendAuthSessionResponse + 95, // 126: daemon.DaemonService.DismissSessionWarning:output_type -> daemon.DismissSessionWarningResponse + 97, // 127: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 99, // 128: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 101, // 129: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 103, // 130: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 78, // 131: daemon.DaemonService.WailsUIReady:output_type -> daemon.WailsUIReadyResponse + 86, // [86:132] is the sub-list for method output_type + 40, // [40:86] is the sub-list for method input_type + 40, // [40:40] is the sub-list for extension type_name + 40, // [40:40] is the sub-list for extension extendee + 0, // [0:40] is the sub-list for field type_name } func init() { file_daemon_proto_init() } @@ -7947,7 +7995,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 110, + NumMessages: 111, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 3c31156ec..eb92b7a65 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -677,9 +677,25 @@ message SystemEvent { Severity severity = 2; Category category = 3; string message = 4; + // userMessage is the daemon's English rendering of messageKey, kept for the + // CLI and for UIs that predate messageKey. UIs that localise read messageKey + // and treat this as the fallback. string userMessage = 5; google.protobuf.Timestamp timestamp = 6; map metadata = 7; + // messageKey names a user-facing message in a stable, locale-independent + // form. A UI resolves it against its own translation bundle and substitutes + // messageArgs; an unrecognised key falls back to userMessage. Empty on events + // that carry no user-facing text. + string messageKey = 8; + // messageArgs holds the placeholder name/value pairs for messageKey, e.g. + // {"version": "0.60.1"} for a "{version}" template. Values are data the + // daemon cannot localise (versions, addresses, error text). + map messageArgs = 9; + // titleKey names the notification title in the same way as messageKey. Empty + // when the event has no dedicated title, in which case a UI composes one from + // severity and category. + string titleKey = 10; } message GetEventsRequest {} diff --git a/client/proto/metadata.go b/client/proto/metadata.go index 9b1dbd16e..6b3ae2c41 100644 --- a/client/proto/metadata.go +++ b/client/proto/metadata.go @@ -43,10 +43,10 @@ const ( // UIs to re-fetch their cached config + features. UserMessage is empty so // the change is silent; the source is carried in MetadataSourceKey. MetadataTypeConfigChanged = "config_changed" - // MetadataTypePolicyApplied marks an MDM-policy-driven config change. The - // daemon stamps it with a (non-localised) UserMessage; the UI suppresses - // that and builds its own localised toast off the paired config_changed - // event instead. + // MetadataTypePolicyApplied marks an MDM-policy-driven config change. It is + // the user-facing half of the pair: the daemon stamps it with the message + // and title keys a UI localises, while the paired config_changed event stays + // silent and only drives the cache refresh. MetadataTypePolicyApplied = "policy_applied" // MetadataSourceKey is the SystemEvent.metadata key carrying what diff --git a/client/proto/usermsg.go b/client/proto/usermsg.go new file mode 100644 index 000000000..3f29a0100 --- /dev/null +++ b/client/proto/usermsg.go @@ -0,0 +1,183 @@ +package proto + +import ( + "strings" + + log "github.com/sirupsen/logrus" +) + +// UserMessageKey names a piece of user-facing SystemEvent text in a +// locale-independent form. The daemon has no notion of the user's language, so it +// publishes the key and lets each UI resolve it. +// +// The value doubles as the lookup key in the desktop UI's translation bundles +// (client/ui/i18n/locales//common.json), so renaming a constant here means +// renaming the key in every bundle. The tests in client/ui/i18n lock the two +// sides together. Keys that predate this mechanism keep their original bundle +// names so the shipped translations still apply. +type UserMessageKey string + +// Message-body keys published by the daemon. Every key needs an entry in +// UserMessageTexts below and a translation in the UI bundles. +const ( + // UserMsgPanic is the CRITICAL event published from the recover() guard in + // the connect loop. + UserMsgPanic UserMessageKey = "event.panic" + + // UserMsgDNSRecovered and UserMsgDNSUnreachable bracket a nameserver + // group's health transitions. + UserMsgDNSRecovered UserMessageKey = "event.dns.recovered" + UserMsgDNSUnreachable UserMessageKey = "event.dns.unreachable" + + // Exit-node (default route) transitions. + UserMsgExitNodeConnected UserMessageKey = "event.exitNode.connected" + UserMsgExitNodeDisconnected UserMessageKey = "event.exitNode.disconnected" + UserMsgExitNodeConnectionLost UserMessageKey = "event.exitNode.connectionLost" + UserMsgExitNodeHAChange UserMessageKey = "event.exitNode.haChange" + UserMsgExitNodeDisconnectedUnknown UserMessageKey = "event.exitNode.disconnectedUnknown" + + // Auto-update lifecycle. UserMsgUpdateFailed takes a {reason} argument and + // UserMsgUpdateCompleted a {version}. + UserMsgUpdateInstalling UserMessageKey = "event.update.installing" + UserMsgUpdateCompleted UserMessageKey = "event.update.completed" + UserMsgUpdateFailed UserMessageKey = "event.update.failed" + + // UserMsgMDMPolicyApplied reports that an MDM policy replaced the config. + UserMsgMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.body" + + // Session-expiry events. UserMsgSessionExpiresIn takes a {remaining} + // argument; the "soon" variant is published when the deadline has already + // passed by the time the warning fires. + UserMsgSessionExpiresIn UserMessageKey = "notify.sessionWarning.body" + UserMsgSessionExpiresSoon UserMessageKey = "notify.sessionWarning.bodyGeneric" + UserMsgSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.body" +) + +// Notification-title keys. An event without one leaves titleKey empty and the UI +// composes a title from severity and category, which is what every event did +// before message keys existed. These carry no English fallback: a UI old enough +// to ignore titleKey already builds its own title. +const ( + TitleMDMPolicyApplied UserMessageKey = "notify.mdm.policyApplied.title" + TitleSessionWarning UserMessageKey = "notify.sessionWarning.title" + TitleSessionDeadlineReject UserMessageKey = "notify.sessionDeadlineRejected.title" +) + +// UserMessageTitleKeys lists every title key a daemon can publish. It sits next +// to the constants above because the two must grow together: a title key missing +// from this slice ships untranslated, and the i18n test that would have caught it +// reads this list. +var UserMessageTitleKeys = []UserMessageKey{ + TitleMDMPolicyApplied, + TitleSessionWarning, + TitleSessionDeadlineReject, +} + +// ArgReason, ArgVersion and ArgRemaining are the placeholder names used by the +// templates below. Producers pass them to NewUserMessage; the UI bundles use the +// same names inside {}. +const ( + ArgReason = "reason" + ArgVersion = "version" + ArgRemaining = "remaining" +) + +// UserMessageTexts is the English rendering of every body key, and the source of +// the SystemEvent.userMessage fallback that the CLI and pre-messageKey UIs read. +// It must match the en bundle, which the i18n test enforces. +var UserMessageTexts = map[UserMessageKey]string{ + UserMsgPanic: "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.", + UserMsgDNSRecovered: "DNS servers are reachable again.", + UserMsgDNSUnreachable: "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.", + UserMsgExitNodeConnected: "Exit node connected.", + UserMsgExitNodeDisconnected: "Exit node disconnected.", + UserMsgExitNodeConnectionLost: "Exit node connection lost. Your internet access might be affected.", + UserMsgExitNodeHAChange: "Exit node disconnected due to high availability change.", + UserMsgExitNodeDisconnectedUnknown: "Exit node disconnected for unknown reasons.", + UserMsgUpdateInstalling: "Installing update now.", + UserMsgUpdateCompleted: "Your NetBird client was auto-updated to version {version}.", + UserMsgUpdateFailed: "Auto-update failed: {reason}", + UserMsgMDMPolicyApplied: "Your NetBird configuration was updated by your IT policy.", + UserMsgSessionExpiresIn: "Your NetBird session expires in {remaining}. Click Extend now to renew.", + UserMsgSessionExpiresSoon: "Your NetBird session is about to expire. Click Extend now to renew.", + UserMsgSessionDeadlineReject: "The server sent an invalid session deadline. Please sign in again.", +} + +// UserMessage is a localizable user-facing event message: a stable body key, an +// optional title key, and the placeholder values to substitute. A nil +// *UserMessage carries no user-facing text, which is how internal control events +// are published. +type UserMessage struct { + key UserMessageKey + title UserMessageKey + args map[string]string +} + +// NewUserMessage builds a UserMessage from key and flat placeholder name/value +// pairs, e.g. NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"). An +// unpaired trailing argument is dropped. +func NewUserMessage(key UserMessageKey, args ...string) *UserMessage { + m := &UserMessage{key: key} + if len(args)%2 != 0 { + log.Debugf("user message %q: placeholder args not paired: %d items, last dropped", key, len(args)) + args = args[:len(args)-1] + } + if len(args) > 0 { + m.args = make(map[string]string, len(args)/2) + for i := 0; i < len(args); i += 2 { + m.args[args[i]] = args[i+1] + } + } + return m +} + +// WithTitle attaches a notification title key and returns m, so it chains onto +// NewUserMessage. +func (m *UserMessage) WithTitle(key UserMessageKey) *UserMessage { + m.title = key + return m +} + +// Key returns the body key, or the empty key for a nil message. +func (m *UserMessage) Key() UserMessageKey { + if m == nil { + return "" + } + return m.key +} + +// TitleKey returns the title key, which is empty unless WithTitle was called. +func (m *UserMessage) TitleKey() UserMessageKey { + if m == nil { + return "" + } + return m.title +} + +// Args returns the placeholder values, or nil for a nil message. The map is the +// message's own and must not be mutated by the caller; it is handed straight to +// SystemEvent.messageArgs. +func (m *UserMessage) Args() map[string]string { + if m == nil { + return nil + } + return m.args +} + +// Text renders the English fallback for m, with placeholders substituted. A nil +// message, or a key with no registered template, renders empty so a UI treats +// the event as carrying no user-facing text. +func (m *UserMessage) Text() string { + if m == nil { + return "" + } + tmpl, ok := UserMessageTexts[m.key] + if !ok { + log.Warnf("no English template for user message key %q", m.key) + return "" + } + for name, value := range m.args { + tmpl = strings.ReplaceAll(tmpl, "{"+name+"}", value) + } + return tmpl +} diff --git a/client/proto/usermsg_test.go b/client/proto/usermsg_test.go new file mode 100644 index 000000000..e6e692ef9 --- /dev/null +++ b/client/proto/usermsg_test.go @@ -0,0 +1,91 @@ +package proto + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestUserMessageText(t *testing.T) { + tests := []struct { + name string + msg *UserMessage + want string + }{ + { + name: "no placeholders", + msg: NewUserMessage(UserMsgExitNodeConnected), + want: "Exit node connected.", + }, + { + name: "placeholder substituted", + msg: NewUserMessage(UserMsgUpdateCompleted, ArgVersion, "0.60.1"), + want: "Your NetBird client was auto-updated to version 0.60.1.", + }, + { + // A dangling arg is a caller mistake; dropping it must still yield a + // readable sentence rather than panicking on the wire path. + name: "unpaired trailing arg dropped", + msg: NewUserMessage(UserMsgUpdateFailed, ArgReason, "disk full", "extra"), + want: "Auto-update failed: disk full", + }, + { + name: "unknown key renders empty so the UI treats the event as silent", + msg: NewUserMessage("event.notRegistered"), + want: "", + }, + { + name: "nil message carries no text", + msg: nil, + want: "", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, tc.msg.Text()) + }) + } +} + +// A nil *UserMessage is how every internal control event is published, so the +// accessors must stay usable without a nil check at each of the ~25 call sites. +func TestNilUserMessageAccessors(t *testing.T) { + var msg *UserMessage + + assert.Empty(t, msg.Key(), "nil message must have no body key") + assert.Empty(t, msg.TitleKey(), "nil message must have no title key") + assert.Nil(t, msg.Args(), "nil message must have no args") + assert.Empty(t, msg.Text(), "nil message must render empty") +} + +func TestUserMessageArgsAndTitle(t *testing.T) { + msg := NewUserMessage(UserMsgSessionExpiresIn, ArgRemaining, "10m"). + WithTitle(TitleSessionWarning) + + assert.Equal(t, UserMsgSessionExpiresIn, msg.Key()) + assert.Equal(t, TitleSessionWarning, msg.TitleKey()) + assert.Equal(t, map[string]string{ArgRemaining: "10m"}, msg.Args()) + assert.Equal(t, "Your NetBird session expires in 10m. Click Extend now to renew.", msg.Text()) +} + +// Every registered template must be reachable: a key whose text is empty would +// publish an event with a key but no fallback for the CLI and older UIs. +func TestUserMessageTextsAreNonEmpty(t *testing.T) { + texts := UserMessageTexts + assert.NotEmpty(t, texts, "the catalog must not be empty") + + for key, text := range texts { + assert.NotEmpty(t, text, "message key %q has an empty template", key) + assert.NotEmpty(t, key, "the catalog must not contain an empty key") + } +} + +func TestUserMessageTitleKeysAreUnique(t *testing.T) { + seen := make(map[UserMessageKey]struct{}) + for _, key := range UserMessageTitleKeys { + assert.NotEmpty(t, key, "title keys must not be empty") + _, dup := seen[key] + assert.False(t, dup, "title key %q listed twice", key) + seen[key] = struct{}{} + } +} diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..f8e89c0b2 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -94,12 +94,12 @@ func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error { // publishConfigChangedEvent has already fired inside // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific // user-visible toast so the operator knows their IT policy was - // applied (UserMessage != "" triggers the GUI notifier). + // applied; the message and title keys let the GUI localise it. s.statusRecorder.PublishEvent( proto.SystemEvent_INFO, proto.SystemEvent_SYSTEM, "MDM policy applied", - "NetBird configuration was updated by your IT policy.", + proto.NewUserMessage(proto.UserMsgMDMPolicyApplied).WithTitle(proto.TitleMDMPolicyApplied), map[string]string{ proto.MetadataSourceKey: proto.MetadataSourceMDM, proto.MetadataTypeKey: proto.MetadataTypePolicyApplied, @@ -126,7 +126,7 @@ func (s *Server) publishConfigChangedEvent(source string) { proto.SystemEvent_INFO, proto.SystemEvent_SYSTEM, fmt.Sprintf("daemon config changed (source=%s)", source), - "", + nil, map[string]string{ proto.MetadataSourceKey: source, proto.MetadataTypeKey: proto.MetadataTypeConfigChanged, diff --git a/client/server/network.go b/client/server/network.go index c390b8180..e5a211cdb 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -170,7 +170,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ proto.SystemEvent_INFO, proto.SystemEvent_SYSTEM, "Network selection changed", - "", + nil, map[string]string{ "networks": strings.Join(req.GetNetworkIDs(), ", "), "append": fmt.Sprint(req.GetAppend()), @@ -214,7 +214,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe proto.SystemEvent_INFO, proto.SystemEvent_SYSTEM, "Network deselection changed", - "", + nil, map[string]string{ "networks": strings.Join(req.GetNetworkIDs(), ", "), "append": fmt.Sprint(req.GetAppend()), @@ -232,4 +232,3 @@ func toNetIDs(routes []string) []route.NetID { } return netIDs } - diff --git a/client/server/server.go b/client/server/server.go index aaab5cc02..13bf06192 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -2181,7 +2181,7 @@ func (s *Server) publishProfileListChanged(profileName string) { proto.SystemEvent_INFO, proto.SystemEvent_SYSTEM, "Profile list changed", - "", + nil, map[string]string{proto.MetadataKindKey: proto.MetadataKindProfileListChanged, proto.MetadataProfileKey: profileName}, ) } @@ -2200,7 +2200,7 @@ func (s *Server) publishLogLevelChanged(level string) { proto.SystemEvent_INFO, proto.SystemEvent_SYSTEM, "Log level changed", - "", + nil, map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level}, ) } diff --git a/client/ui/i18n/TRANSLATING.md b/client/ui/i18n/TRANSLATING.md index 88cbb8b11..6b3873597 100644 --- a/client/ui/i18n/TRANSLATING.md +++ b/client/ui/i18n/TRANSLATING.md @@ -40,6 +40,12 @@ i18n/locales//common.json a target — message only Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**. +The `event.*` keys are a special group: the background service names them when it +publishes a notification, and the app looks them up here. Their names are part of +a Go↔JSON contract (`client/proto/usermsg.go`), so they are even less renameable +than the rest — and a missing one shows the user English. Tests fail the build if +any locale drops one. + | ✅ Do | ❌ Don't | |---|---| | Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) | diff --git a/client/ui/i18n/bundle.go b/client/ui/i18n/bundle.go index 892916999..b6fe1db95 100644 --- a/client/ui/i18n/bundle.go +++ b/client/ui/i18n/bundle.go @@ -126,18 +126,29 @@ func (b *Bundle) BundleFor(code LanguageCode) (map[string]string, error) { // pairs ("version", "1.2.3" replaces "{version}"). Unknown keys fall back to // the default language, then to the key itself so a miss is visible in the UI. func (b *Bundle) Translate(lang LanguageCode, key string, args ...string) string { + if v, ok := b.Lookup(lang, key, args...); ok { + return v + } + return key +} + +// Lookup resolves key like Translate but reports whether it was found in the +// requested or the default bundle. Callers holding a better fallback than the +// raw key — a daemon-supplied English string for a key this build predates — +// use this to tell a miss from a hit. +func (b *Bundle) Lookup(lang LanguageCode, key string, args ...string) (string, bool) { b.mu.RLock() defer b.mu.RUnlock() if v, ok := b.bundles[lang][key]; ok { - return applyPlaceholders(v, args) + return applyPlaceholders(v, args), true } if lang != DefaultLanguage { if v, ok := b.bundles[DefaultLanguage][key]; ok { - return applyPlaceholders(v, args) + return applyPlaceholders(v, args), true } } - return key + return "", false } // applyPlaceholders substitutes {name} in s using args as flat name/value diff --git a/client/ui/i18n/eventkeys_test.go b/client/ui/i18n/eventkeys_test.go new file mode 100644 index 000000000..c42aefb68 --- /dev/null +++ b/client/ui/i18n/eventkeys_test.go @@ -0,0 +1,126 @@ +//go:build !android && !ios && !freebsd && !js + +package i18n + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +// shippedBundle loads the real locale tree rather than the fstest fixture the +// other tests use: these checks exist to catch a key the daemon publishes but no +// bundle translates, which only the shipped files can prove. +func shippedBundle(t *testing.T) *Bundle { + t.Helper() + b, err := NewBundle(os.DirFS("locales")) + require.NoError(t, err, "the shipped locale tree must load") + return b +} + +// The daemon publishes a message key and each UI resolves it locally, so a key +// with no en entry degrades to the daemon's English fallback and silently stops +// being translatable. Fail the build instead. +func TestUserMessageKeysExistInEnglishBundle(t *testing.T) { + b := shippedBundle(t) + + for key, text := range proto.UserMessageTexts { + got, ok := b.Lookup(DefaultLanguage, string(key)) + if !assert.True(t, ok, "message key %q has no en translation", key) { + continue + } + assert.Equal(t, text, got, + "en translation of %q must match the daemon's English fallback", key) + } +} + +func TestUserMessageTitleKeysExistInEnglishBundle(t *testing.T) { + b := shippedBundle(t) + + for _, key := range proto.UserMessageTitleKeys { + _, ok := b.Lookup(DefaultLanguage, string(key)) + assert.True(t, ok, "title key %q has no en translation", key) + } +} + +// Every shipped locale must translate the daemon's keys, not just en. A missing +// one still renders (Lookup falls back to en) but the notification would show up +// in English for that user, which is the bug this whole mechanism exists to fix. +func TestUserMessageKeysTranslatedInEveryLanguage(t *testing.T) { + b := shippedBundle(t) + + keys := make([]proto.UserMessageKey, 0, len(proto.UserMessageTexts)) + for key := range proto.UserMessageTexts { + keys = append(keys, key) + } + keys = append(keys, proto.UserMessageTitleKeys...) + + for _, lang := range b.Languages() { + bundle, err := b.BundleFor(lang.Code) + require.NoError(t, err, "BundleFor(%q)", lang.Code) + + for _, key := range keys { + text, ok := bundle[string(key)] + if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) { + continue + } + assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key) + } + } +} + +// The tray composes a title from these when an event carries no title key, so a +// gap here would render "event.severity.warning: DNS" to the user. +func TestEventTitleKeysTranslatedInEveryLanguage(t *testing.T) { + b := shippedBundle(t) + + keys := []string{ + "event.title", + "event.severity.info", "event.severity.warning", + "event.severity.error", "event.severity.critical", + "event.category.network", "event.category.dns", + "event.category.authentication", "event.category.connectivity", + "event.category.system", + } + + for _, lang := range b.Languages() { + bundle, err := b.BundleFor(lang.Code) + require.NoError(t, err, "BundleFor(%q)", lang.Code) + + for _, key := range keys { + text, ok := bundle[key] + if !assert.True(t, ok, "locale %q is missing key %q", lang.Code, key) { + continue + } + assert.NotEmpty(t, text, "locale %q has an empty message for %q", lang.Code, key) + } + } + + // The composed title is useless without both slots. + title, ok := b.Lookup(DefaultLanguage, "event.title", "severity", "Warning", "category", "DNS") + require.True(t, ok) + assert.Equal(t, "Warning: DNS", title, "event.title must substitute both placeholders") +} + +func TestBundleLookupReportsMisses(t *testing.T) { + b, err := NewBundle(fakeLocales()) + require.NoError(t, err) + + got, ok := b.Lookup("en", "tray.menu.connect") + assert.True(t, ok) + assert.Equal(t, "Connect", got) + + // An absent key must report a miss rather than echo the key, so callers can + // substitute their own fallback. + got, ok = b.Lookup("en", "tray.missing") + assert.False(t, ok, "unknown key must report a miss") + assert.Empty(t, got, "a miss must not return the key") + + // Empty keys reach Lookup from events that carry no title key at all. + _, ok = b.Lookup("en", "") + assert.False(t, ok, "empty key must report a miss") +} diff --git a/client/ui/i18n/locales/de/common.json b/client/ui/i18n/locales/de/common.json index 5e91e8d88..969e02792 100644 --- a/client/ui/i18n/locales/de/common.json +++ b/client/ui/i18n/locales/de/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "Ihre NetBird-Konfiguration wurde durch Ihre IT-Richtlinie aktualisiert." }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "Info" + }, + "event.severity.warning": { + "message": "Warnung" + }, + "event.severity.error": { + "message": "Fehler" + }, + "event.severity.critical": { + "message": "Kritisch" + }, + "event.category.network": { + "message": "Netzwerk" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Authentifizierung" + }, + "event.category.connectivity": { + "message": "Konnektivität" + }, + "event.category.system": { + "message": "System" + }, + "event.panic": { + "message": "Der NetBird-Dienst ist abgestürzt. Bitte starten Sie den Dienst neu und senden Sie einen Fehlerbericht mit den Client-Protokollen." + }, + "event.dns.recovered": { + "message": "DNS-Server sind wieder erreichbar." + }, + "event.dns.unreachable": { + "message": "Ein oder mehrere DNS-Server sind nicht erreichbar. Das kann die Verbindung zu einigen Diensten beeinträchtigen." + }, + "event.exitNode.connected": { + "message": "Exit Node verbunden." + }, + "event.exitNode.disconnected": { + "message": "Exit Node getrennt." + }, + "event.exitNode.connectionLost": { + "message": "Verbindung zum Exit Node verloren. Ihr Internetzugang kann beeinträchtigt sein." + }, + "event.exitNode.haChange": { + "message": "Exit Node aufgrund einer Änderung der Hochverfügbarkeit getrennt." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Exit Node aus unbekannten Gründen getrennt." + }, + "event.update.installing": { + "message": "Update wird jetzt installiert." + }, + "event.update.completed": { + "message": "Ihr NetBird-Client wurde automatisch auf Version {version} aktualisiert." + }, + "event.update.failed": { + "message": "Automatisches Update fehlgeschlagen: {reason}" + }, "common.cancel": { "message": "Abbrechen" }, diff --git a/client/ui/i18n/locales/en/common.json b/client/ui/i18n/locales/en/common.json index b668146e8..8ab3d98b1 100644 --- a/client/ui/i18n/locales/en/common.json +++ b/client/ui/i18n/locales/en/common.json @@ -239,6 +239,90 @@ "message": "Your NetBird configuration was updated by your IT policy.", "description": "Body of the MDM policy-applied notification, telling the user their settings were changed by their organization's device-management policy." }, + "event.title": { + "message": "{severity}: {category}", + "description": "Notification title for a daemon event, composed from severity and category, e.g. \"Warning: DNS\". Keep both placeholders; use your locale's colon spacing." + }, + "event.severity.info": { + "message": "Info", + "description": "Severity label used in the {severity} slot of event.title. Keep it short." + }, + "event.severity.warning": { + "message": "Warning", + "description": "Severity label used in the {severity} slot of event.title. Keep it short." + }, + "event.severity.error": { + "message": "Error", + "description": "Severity label used in the {severity} slot of event.title. Keep it short." + }, + "event.severity.critical": { + "message": "Critical", + "description": "Severity label used in the {severity} slot of event.title. Keep it short." + }, + "event.category.network": { + "message": "Network", + "description": "Event category label used in the {category} slot of event.title. Refers to the overlay network." + }, + "event.category.dns": { + "message": "DNS", + "description": "Event category label used in the {category} slot of event.title. Acronym, do not translate." + }, + "event.category.authentication": { + "message": "Authentication", + "description": "Event category label used in the {category} slot of event.title. Refers to signing in to the management server." + }, + "event.category.connectivity": { + "message": "Connectivity", + "description": "Event category label used in the {category} slot of event.title. Refers to reaching peers." + }, + "event.category.system": { + "message": "System", + "description": "Event category label used in the {category} slot of event.title. Refers to the local machine and the NetBird service." + }, + "event.panic": { + "message": "The NetBird service panicked. Please restart the service and submit a bug report with the client logs.", + "description": "Notification body after the NetBird background service crashed. \"Service\" is the daemon, not a remote service." + }, + "event.dns.recovered": { + "message": "DNS servers are reachable again.", + "description": "Notification body when previously unreachable upstream DNS servers respond again." + }, + "event.dns.unreachable": { + "message": "Unable to reach one or more DNS servers. This might affect your ability to connect to some services.", + "description": "Notification body when one or more upstream DNS servers stop responding." + }, + "event.exitNode.connected": { + "message": "Exit node connected.", + "description": "Notification body when a full-tunnel exit node becomes active." + }, + "event.exitNode.disconnected": { + "message": "Exit node disconnected.", + "description": "Notification body when the user or the client shuts the exit node down deliberately." + }, + "event.exitNode.connectionLost": { + "message": "Exit node connection lost. Your internet access might be affected.", + "description": "Notification body when the exit node peer became unreachable. \"Internet access\" means the user's own browsing." + }, + "event.exitNode.haChange": { + "message": "Exit node disconnected due to high availability change.", + "description": "Notification body when a high-availability group switched away from this exit node. High availability is the standard IT term." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Exit node disconnected for unknown reasons.", + "description": "Notification body when the exit node dropped for a reason the client could not classify." + }, + "event.update.installing": { + "message": "Installing update now.", + "description": "Notification body shown as an automatic client update starts installing." + }, + "event.update.completed": { + "message": "Your NetBird client was auto-updated to version {version}.", + "description": "Notification body after an automatic client update succeeded. {version} is a version number, keep verbatim." + }, + "event.update.failed": { + "message": "Auto-update failed: {reason}", + "description": "Notification body when an automatic client update failed. {reason} is an untranslated technical error string; keep your locale's colon spacing." + }, "common.cancel": { "message": "Cancel", "description": "Generic Cancel button label, reused across dialogs. Keep short." diff --git a/client/ui/i18n/locales/es/common.json b/client/ui/i18n/locales/es/common.json index c036e4f75..c54df46fa 100644 --- a/client/ui/i18n/locales/es/common.json +++ b/client/ui/i18n/locales/es/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "Su configuración de NetBird fue actualizada por su política de TI." }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "Información" + }, + "event.severity.warning": { + "message": "Advertencia" + }, + "event.severity.error": { + "message": "Error" + }, + "event.severity.critical": { + "message": "Crítico" + }, + "event.category.network": { + "message": "Red" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Autenticación" + }, + "event.category.connectivity": { + "message": "Conectividad" + }, + "event.category.system": { + "message": "Sistema" + }, + "event.panic": { + "message": "El servicio de NetBird falló de forma inesperada. Reinicie el servicio y envíe un informe de error con los registros del cliente." + }, + "event.dns.recovered": { + "message": "Los servidores DNS vuelven a estar accesibles." + }, + "event.dns.unreachable": { + "message": "No se puede acceder a uno o más servidores DNS. Esto puede afectar la conexión a algunos servicios." + }, + "event.exitNode.connected": { + "message": "Nodo de salida conectado." + }, + "event.exitNode.disconnected": { + "message": "Nodo de salida desconectado." + }, + "event.exitNode.connectionLost": { + "message": "Se perdió la conexión con el nodo de salida. Su acceso a Internet puede verse afectado." + }, + "event.exitNode.haChange": { + "message": "Nodo de salida desconectado por un cambio de alta disponibilidad." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Nodo de salida desconectado por motivos desconocidos." + }, + "event.update.installing": { + "message": "Instalando la actualización ahora." + }, + "event.update.completed": { + "message": "Su cliente de NetBird se actualizó automáticamente a la versión {version}." + }, + "event.update.failed": { + "message": "La actualización automática falló: {reason}" + }, "common.cancel": { "message": "Cancelar" }, diff --git a/client/ui/i18n/locales/fr/common.json b/client/ui/i18n/locales/fr/common.json index c6b91fb25..478a90873 100644 --- a/client/ui/i18n/locales/fr/common.json +++ b/client/ui/i18n/locales/fr/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "Votre configuration NetBird a été mise à jour par votre politique informatique." }, + "event.title": { + "message": "{severity} : {category}" + }, + "event.severity.info": { + "message": "Info" + }, + "event.severity.warning": { + "message": "Avertissement" + }, + "event.severity.error": { + "message": "Erreur" + }, + "event.severity.critical": { + "message": "Critique" + }, + "event.category.network": { + "message": "Réseau" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Authentification" + }, + "event.category.connectivity": { + "message": "Connectivité" + }, + "event.category.system": { + "message": "Système" + }, + "event.panic": { + "message": "Le service NetBird s'est arrêté brutalement. Veuillez redémarrer le service et envoyer un rapport de bug avec les journaux du client." + }, + "event.dns.recovered": { + "message": "Les serveurs DNS sont de nouveau joignables." + }, + "event.dns.unreachable": { + "message": "Impossible de joindre un ou plusieurs serveurs DNS. Cela peut affecter la connexion à certains services." + }, + "event.exitNode.connected": { + "message": "Nœud de sortie connecté." + }, + "event.exitNode.disconnected": { + "message": "Nœud de sortie déconnecté." + }, + "event.exitNode.connectionLost": { + "message": "Connexion au nœud de sortie perdue. Votre accès à Internet peut être affecté." + }, + "event.exitNode.haChange": { + "message": "Nœud de sortie déconnecté suite à un changement de haute disponibilité." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Nœud de sortie déconnecté pour une raison inconnue." + }, + "event.update.installing": { + "message": "Installation de la mise à jour en cours." + }, + "event.update.completed": { + "message": "Votre client NetBird a été mis à jour automatiquement vers la version {version}." + }, + "event.update.failed": { + "message": "Échec de la mise à jour automatique : {reason}" + }, "common.cancel": { "message": "Annuler" }, diff --git a/client/ui/i18n/locales/hu/common.json b/client/ui/i18n/locales/hu/common.json index dd5a1af6c..3d93156b1 100644 --- a/client/ui/i18n/locales/hu/common.json +++ b/client/ui/i18n/locales/hu/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "A NetBird konfigurációt az IT-szabályzat frissítette." }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "Információ" + }, + "event.severity.warning": { + "message": "Figyelmeztetés" + }, + "event.severity.error": { + "message": "Hiba" + }, + "event.severity.critical": { + "message": "Kritikus" + }, + "event.category.network": { + "message": "Hálózat" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Hitelesítés" + }, + "event.category.connectivity": { + "message": "Kapcsolat" + }, + "event.category.system": { + "message": "Rendszer" + }, + "event.panic": { + "message": "A NetBird szolgáltatás összeomlott. Kérjük, indítsa újra a szolgáltatást, és küldjön hibajelentést a kliens naplóival." + }, + "event.dns.recovered": { + "message": "A DNS-kiszolgálók ismét elérhetők." + }, + "event.dns.unreachable": { + "message": "Egy vagy több DNS-kiszolgáló nem érhető el. Ez befolyásolhatja egyes szolgáltatások elérését." + }, + "event.exitNode.connected": { + "message": "Exit Node csatlakoztatva." + }, + "event.exitNode.disconnected": { + "message": "Exit Node leválasztva." + }, + "event.exitNode.connectionLost": { + "message": "Megszakadt a kapcsolat az Exit Node-dal. Ez érintheti az internetelérést." + }, + "event.exitNode.haChange": { + "message": "Az Exit Node leválasztva a magas rendelkezésre állás változása miatt." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Az Exit Node ismeretlen okból leválasztva." + }, + "event.update.installing": { + "message": "A frissítés telepítése folyamatban." + }, + "event.update.completed": { + "message": "A NetBird kliens automatikusan a {version} verzióra frissült." + }, + "event.update.failed": { + "message": "Az automatikus frissítés sikertelen: {reason}" + }, "common.cancel": { "message": "Mégse" }, diff --git a/client/ui/i18n/locales/it/common.json b/client/ui/i18n/locales/it/common.json index 7a2eb610c..9fc80364d 100644 --- a/client/ui/i18n/locales/it/common.json +++ b/client/ui/i18n/locales/it/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "La configurazione di NetBird è stata aggiornata dalla policy IT." }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "Info" + }, + "event.severity.warning": { + "message": "Avviso" + }, + "event.severity.error": { + "message": "Errore" + }, + "event.severity.critical": { + "message": "Critico" + }, + "event.category.network": { + "message": "Rete" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Autenticazione" + }, + "event.category.connectivity": { + "message": "Connettività" + }, + "event.category.system": { + "message": "Sistema" + }, + "event.panic": { + "message": "Il servizio NetBird si è arrestato in modo anomalo. Riavvii il servizio e invii una segnalazione di bug con i log del client." + }, + "event.dns.recovered": { + "message": "I server DNS sono di nuovo raggiungibili." + }, + "event.dns.unreachable": { + "message": "Impossibile raggiungere uno o più server DNS. Questo potrebbe influire sulla connessione ad alcuni servizi." + }, + "event.exitNode.connected": { + "message": "Nodo di uscita connesso." + }, + "event.exitNode.disconnected": { + "message": "Nodo di uscita disconnesso." + }, + "event.exitNode.connectionLost": { + "message": "Connessione al nodo di uscita perduta. L'accesso a Internet potrebbe essere compromesso." + }, + "event.exitNode.haChange": { + "message": "Nodo di uscita disconnesso a causa di una modifica dell'alta disponibilità." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Nodo di uscita disconnesso per motivi sconosciuti." + }, + "event.update.installing": { + "message": "Installazione dell'aggiornamento in corso." + }, + "event.update.completed": { + "message": "Il client NetBird è stato aggiornato automaticamente alla versione {version}." + }, + "event.update.failed": { + "message": "Aggiornamento automatico non riuscito: {reason}" + }, "common.cancel": { "message": "Annulla" }, diff --git a/client/ui/i18n/locales/ja/common.json b/client/ui/i18n/locales/ja/common.json index 326c825bf..f08b5cbd7 100644 --- a/client/ui/i18n/locales/ja/common.json +++ b/client/ui/i18n/locales/ja/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "NetBird の構成が IT ポリシーによって更新されました。" }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "情報" + }, + "event.severity.warning": { + "message": "警告" + }, + "event.severity.error": { + "message": "エラー" + }, + "event.severity.critical": { + "message": "重大" + }, + "event.category.network": { + "message": "ネットワーク" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "認証" + }, + "event.category.connectivity": { + "message": "接続" + }, + "event.category.system": { + "message": "システム" + }, + "event.panic": { + "message": "NetBird サービスがクラッシュしました。サービスを再起動し、クライアントログを添えてバグを報告してください。" + }, + "event.dns.recovered": { + "message": "DNS サーバーに再び到達できるようになりました。" + }, + "event.dns.unreachable": { + "message": "1 つ以上の DNS サーバーに到達できません。一部のサービスへの接続に影響する可能性があります。" + }, + "event.exitNode.connected": { + "message": "出口ノードに接続しました。" + }, + "event.exitNode.disconnected": { + "message": "出口ノードの接続を解除しました。" + }, + "event.exitNode.connectionLost": { + "message": "出口ノードとの接続が失われました。インターネット接続に影響する可能性があります。" + }, + "event.exitNode.haChange": { + "message": "高可用性の変更により出口ノードの接続が解除されました。" + }, + "event.exitNode.disconnectedUnknown": { + "message": "不明な理由により出口ノードの接続が解除されました。" + }, + "event.update.installing": { + "message": "更新をインストールしています。" + }, + "event.update.completed": { + "message": "NetBird クライアントがバージョン {version} に自動更新されました。" + }, + "event.update.failed": { + "message": "自動更新に失敗しました: {reason}" + }, "common.cancel": { "message": "キャンセル" }, diff --git a/client/ui/i18n/locales/pt/common.json b/client/ui/i18n/locales/pt/common.json index 37b02d5a8..e9e738eca 100644 --- a/client/ui/i18n/locales/pt/common.json +++ b/client/ui/i18n/locales/pt/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "A sua configuração do NetBird foi atualizada pela política de TI." }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "Informação" + }, + "event.severity.warning": { + "message": "Aviso" + }, + "event.severity.error": { + "message": "Erro" + }, + "event.severity.critical": { + "message": "Crítico" + }, + "event.category.network": { + "message": "Rede" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Autenticação" + }, + "event.category.connectivity": { + "message": "Conectividade" + }, + "event.category.system": { + "message": "Sistema" + }, + "event.panic": { + "message": "O serviço NetBird falhou de forma inesperada. Reinicie o serviço e envie um relatório de erro com os registros do cliente." + }, + "event.dns.recovered": { + "message": "Os servidores DNS estão novamente acessíveis." + }, + "event.dns.unreachable": { + "message": "Não é possível acessar um ou mais servidores DNS. Isto pode afetar a conexão a alguns serviços." + }, + "event.exitNode.connected": { + "message": "Nó de saída conectado." + }, + "event.exitNode.disconnected": { + "message": "Nó de saída desconectado." + }, + "event.exitNode.connectionLost": { + "message": "Conexão com o nó de saída perdida. O seu acesso à Internet pode ser afetado." + }, + "event.exitNode.haChange": { + "message": "Nó de saída desconectado devido a uma alteração de alta disponibilidade." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Nó de saída desconectado por motivos desconhecidos." + }, + "event.update.installing": { + "message": "Instalando a atualização agora." + }, + "event.update.completed": { + "message": "O seu cliente NetBird foi atualizado automaticamente para a versão {version}." + }, + "event.update.failed": { + "message": "Falha na atualização automática: {reason}" + }, "common.cancel": { "message": "Cancelar" }, diff --git a/client/ui/i18n/locales/ru/common.json b/client/ui/i18n/locales/ru/common.json index b9ae59df2..b1b8a5f81 100644 --- a/client/ui/i18n/locales/ru/common.json +++ b/client/ui/i18n/locales/ru/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "Конфигурация NetBird была обновлена в соответствии с вашей ИТ-политикой." }, + "event.title": { + "message": "{severity}: {category}" + }, + "event.severity.info": { + "message": "Информация" + }, + "event.severity.warning": { + "message": "Предупреждение" + }, + "event.severity.error": { + "message": "Ошибка" + }, + "event.severity.critical": { + "message": "Критично" + }, + "event.category.network": { + "message": "Сеть" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "Аутентификация" + }, + "event.category.connectivity": { + "message": "Связь" + }, + "event.category.system": { + "message": "Система" + }, + "event.panic": { + "message": "Служба NetBird аварийно завершилась. Перезапустите службу и отправьте отчёт об ошибке с журналами клиента." + }, + "event.dns.recovered": { + "message": "DNS-серверы снова доступны." + }, + "event.dns.unreachable": { + "message": "Не удалось связаться с одним или несколькими DNS-серверами. Это может повлиять на подключение к некоторым сервисам." + }, + "event.exitNode.connected": { + "message": "Выходной узел подключён." + }, + "event.exitNode.disconnected": { + "message": "Выходной узел отключён." + }, + "event.exitNode.connectionLost": { + "message": "Соединение с выходным узлом потеряно. Доступ в интернет может быть нарушен." + }, + "event.exitNode.haChange": { + "message": "Выходной узел отключён из-за изменения конфигурации высокой доступности." + }, + "event.exitNode.disconnectedUnknown": { + "message": "Выходной узел отключён по неизвестной причине." + }, + "event.update.installing": { + "message": "Устанавливается обновление." + }, + "event.update.completed": { + "message": "Клиент NetBird автоматически обновлён до версии {version}." + }, + "event.update.failed": { + "message": "Не удалось выполнить автоматическое обновление: {reason}" + }, "common.cancel": { "message": "Отмена" }, diff --git a/client/ui/i18n/locales/zh-CN/common.json b/client/ui/i18n/locales/zh-CN/common.json index 2141a770d..d0d04b5a1 100644 --- a/client/ui/i18n/locales/zh-CN/common.json +++ b/client/ui/i18n/locales/zh-CN/common.json @@ -179,6 +179,69 @@ "notify.mdm.policyApplied.body": { "message": "您的 NetBird 配置已根据 IT 策略更新。" }, + "event.title": { + "message": "{severity}:{category}" + }, + "event.severity.info": { + "message": "信息" + }, + "event.severity.warning": { + "message": "警告" + }, + "event.severity.error": { + "message": "错误" + }, + "event.severity.critical": { + "message": "严重" + }, + "event.category.network": { + "message": "网络" + }, + "event.category.dns": { + "message": "DNS" + }, + "event.category.authentication": { + "message": "身份验证" + }, + "event.category.connectivity": { + "message": "连接" + }, + "event.category.system": { + "message": "系统" + }, + "event.panic": { + "message": "NetBird 服务发生崩溃。请重启该服务,并附上客户端日志提交错误报告。" + }, + "event.dns.recovered": { + "message": "DNS 服务器已恢复可访问。" + }, + "event.dns.unreachable": { + "message": "无法访问一个或多个 DNS 服务器。这可能影响您连接部分服务。" + }, + "event.exitNode.connected": { + "message": "出口节点已连接。" + }, + "event.exitNode.disconnected": { + "message": "出口节点已断开。" + }, + "event.exitNode.connectionLost": { + "message": "与出口节点的连接已丢失。您的互联网访问可能受到影响。" + }, + "event.exitNode.haChange": { + "message": "由于高可用性变更,出口节点已断开。" + }, + "event.exitNode.disconnectedUnknown": { + "message": "出口节点因未知原因已断开。" + }, + "event.update.installing": { + "message": "正在安装更新。" + }, + "event.update.completed": { + "message": "NetBird 客户端已自动更新到版本 {version}。" + }, + "event.update.failed": { + "message": "自动更新失败:{reason}" + }, "common.cancel": { "message": "取消" }, diff --git a/client/ui/localizer.go b/client/ui/localizer.go index 33c1cf205..626a487c9 100644 --- a/client/ui/localizer.go +++ b/client/ui/localizer.go @@ -63,6 +63,20 @@ func (l *Localizer) T(key string, args ...string) string { return l.bundle.Translate(lang, key, args...) } +// Lookup resolves a key supplied at runtime by the daemon, substituting args as +// {placeholder}/value pairs. It reports false when the key is in no bundle, so +// the caller can fall back to the daemon's own English text instead of showing a +// bare key. An empty key never resolves. +func (l *Localizer) Lookup(key string, args map[string]string) (string, bool) { + if l == nil || l.bundle == nil || key == "" { + return "", false + } + l.mu.RLock() + lang := l.lang + l.mu.RUnlock() + return l.bundle.Lookup(lang, key, flattenArgs(args)...) +} + // Watch invokes cb on each language change, after the cached language is // updated so cb may call l.T with the new locale. Replaces any prior subscription. func (l *Localizer) Watch(cb func(lang i18n.LanguageCode)) { @@ -128,3 +142,17 @@ func (l *Localizer) StatusLabel(status string) string { } return status } + +// flattenArgs turns a placeholder map into the flat name/value slice the bundle +// takes. Iteration order is irrelevant: each pair substitutes an independent +// {name}. +func flattenArgs(args map[string]string) []string { + if len(args) == 0 { + return nil + } + out := make([]string, 0, len(args)*2) + for name, value := range args { + out = append(out, name, value) + } + return out +} diff --git a/client/ui/services/daemon_feed.go b/client/ui/services/daemon_feed.go index 632581fe9..dfe8eea38 100644 --- a/client/ui/services/daemon_feed.go +++ b/client/ui/services/daemon_feed.go @@ -59,13 +59,21 @@ type Emitter interface { // SystemEvent is the frontend-facing shape of a daemon SystemEvent. type SystemEvent struct { - ID string `json:"id"` - Severity string `json:"severity"` - Category string `json:"category"` - Message string `json:"message"` - UserMessage string `json:"userMessage"` - Timestamp int64 `json:"timestamp"` - Metadata map[string]string `json:"metadata"` + ID string `json:"id"` + Severity string `json:"severity"` + Category string `json:"category"` + Message string `json:"message"` + UserMessage string `json:"userMessage"` + // MessageKey names the localizable body for this event; empty on control + // events and on events from a daemon that predates the field. Resolve it + // against the UI bundle and fall back to UserMessage on a miss. + MessageKey string `json:"messageKey"` + MessageArgs map[string]string `json:"messageArgs"` + // TitleKey names the localizable notification title, empty when the event + // has none and the consumer should compose one from severity and category. + TitleKey string `json:"titleKey"` + Timestamp int64 `json:"timestamp"` + Metadata map[string]string `json:"metadata"` } // PeerStatus is the frontend-facing shape of a daemon PeerState. @@ -563,6 +571,9 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent { Category: strings.ToLower(strings.TrimPrefix(e.GetCategory().String(), "SystemEvent_")), Message: e.GetMessage(), UserMessage: e.GetUserMessage(), + MessageKey: e.GetMessageKey(), + MessageArgs: e.GetMessageArgs(), + TitleKey: e.GetTitleKey(), Metadata: map[string]string{}, } if ts := e.GetTimestamp(); ts != nil { diff --git a/client/ui/tray.go b/client/ui/tray.go index 3050d159a..b5fcb2590 100644 --- a/client/ui/tray.go +++ b/client/ui/tray.go @@ -26,7 +26,6 @@ const ( notifyIDUpdatePrefix = "netbird-update-" notifyIDEvent = "netbird-event-" notifyIDTrayError = "netbird-tray-error" - notifyIDMDMPolicy = "netbird-mdm-policy" statusError = "Error" diff --git a/client/ui/tray_events.go b/client/ui/tray_events.go index 12da68a5c..7b61df03a 100644 --- a/client/ui/tray_events.go +++ b/client/ui/tray_events.go @@ -21,32 +21,14 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { if !ok { return } - // config_changed carries no UserMessage, so handle it before the message gate below. + // config_changed carries no user-facing message, so handle it before the gate below. if se.Category == "system" && se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypeConfigChanged { log.Infof("config_changed event received (source=%s); refreshing tray restrictions", se.Metadata[proto.MetadataSourceKey]) go t.refreshRestrictions() go t.loadConfig() - // MDM gets a localised toast here; the daemon's English "policy_applied" - // event is suppressed in shouldSkipSystemEvent. Other sources stay silent. - if se.Metadata[proto.MetadataSourceKey] == proto.MetadataSourceMDM { - t.profileMu.Lock() - enabled := t.notificationsEnabled - t.profileMu.Unlock() - if enabled { - t.notify( - t.loc.T("notify.mdm.policyApplied.title"), - t.loc.T("notify.mdm.policyApplied.body"), - notifyIDMDMPolicy, - ) - } - } return } - // Session-warning and deadline-rejected events build their body locally from - // metadata; every other event needs a UserMessage. - isSessionWarning := se.Metadata[authsession.MetaWarning] == "true" - isDeadlineRejected := se.Metadata[authsession.MetaDeadlineRejected] != "" - if !isSessionWarning && !isDeadlineRejected && se.UserMessage == "" { + if se.MessageKey == "" && se.UserMessage == "" { return } if shouldSkipSystemEvent(se) { @@ -61,56 +43,56 @@ func (t *Tray) onSystemEvent(ev *application.CustomEvent) { return } - // Session-warning events route via stable metadata flags rather than - // category/severity so a daemon-side reword still lands here. Final warning - // auto-opens the SessionExpiration dialog with no notification (the dialog is - // the last-chance reminder; doubling up would be noise). - if isDeadlineRejected { - t.notify( - t.loc.T("notify.sessionDeadlineRejected.title"), - t.loc.T("notify.sessionDeadlineRejected.body"), - notifyIDSessionExpired, - ) - return - } + body := t.localizedEventMessage(se) - if se.Metadata != nil && se.Metadata[authsession.MetaWarning] == "true" { + // The final session warning auto-opens the SessionExpiration dialog instead of + // toasting: the dialog is the last-chance reminder and doubling up would be + // noise. This routes on metadata rather than the message key because it is a + // behavioural distinction, not a wording one. + if se.Metadata[authsession.MetaWarning] == "true" { if se.Metadata[authsession.MetaFinal] == "true" { t.openSessionExpiration() return } - t.notifySessionWarning( - t.loc.T("notify.sessionWarning.title"), - t.buildSessionWarningBody(se.Metadata), - ) + t.notifySessionWarning(t.eventTitle(se), body) return } - body := se.UserMessage if id := se.Metadata["id"]; id != "" { body += fmt.Sprintf(" ID: %s", id) } - t.notify(eventTitle(se), body, notifyIDEvent+se.ID) + t.notify(t.eventTitle(se), body, notifyIDEvent+se.ID) } -// eventTitle composes a notification title, e.g. "Critical: DNS", "Warning: Authentication". -func eventTitle(e services.SystemEvent) string { - prefix := titleCase(e.Severity) - if prefix == "" { - prefix = "Info" +// localizedEventMessage resolves the daemon's message key against the active +// locale. A key this build does not ship — a daemon newer than the UI — falls +// back to the daemon's own English rendering rather than showing a bare key. +func (t *Tray) localizedEventMessage(se services.SystemEvent) string { + if body, ok := t.loc.Lookup(se.MessageKey, se.MessageArgs); ok { + return body } - category := titleCase(e.Category) - if category == "" { - category = "System" + if se.MessageKey != "" { + log.Debugf("no translation for event message key %q, using the daemon's text", se.MessageKey) } - return prefix + ": " + category + return se.UserMessage } -func titleCase(s string) string { - if s == "" { - return "" +// eventTitle resolves the event's own title key, falling back to a title +// composed from severity and category, e.g. "Critical: DNS" in English. An enum +// value this build does not know falls back to the Info and System labels. +func (t *Tray) eventTitle(se services.SystemEvent) string { + if title, ok := t.loc.Lookup(se.TitleKey, nil); ok { + return title } - return strings.ToUpper(s[:1]) + strings.ToLower(s[1:]) + severity, ok := t.loc.Lookup("event.severity."+strings.ToLower(se.Severity), nil) + if !ok { + severity = t.loc.T("event.severity.info") + } + category, ok := t.loc.Lookup("event.category."+strings.ToLower(se.Category), nil) + if !ok { + category = t.loc.T("event.category.system") + } + return t.loc.T("event.title", "severity", severity, "category", category) } // shouldSkipSystemEvent reports whether a daemon SystemEvent must not surface as @@ -119,11 +101,6 @@ func titleCase(s string) string { // - install-progress signals (consumed by the install-progress window) // - the ::/0 partner of an exit-node default route (0.0.0.0/0 already toasted) func shouldSkipSystemEvent(se services.SystemEvent) bool { - // "policy_applied" carries a hardcoded English message; the localised toast - // fires on the paired config_changed (source=mdm) event instead. - if se.Metadata[proto.MetadataTypeKey] == proto.MetadataTypePolicyApplied { - return true - } if _, isUpdate := se.Metadata["new_version_available"]; isUpdate { return true } diff --git a/client/ui/tray_events_test.go b/client/ui/tray_events_test.go new file mode 100644 index 000000000..d67313f52 --- /dev/null +++ b/client/ui/tray_events_test.go @@ -0,0 +1,150 @@ +//go:build !android && !ios && !freebsd && !js + +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/client/ui/i18n" + "github.com/netbirdio/netbird/client/ui/services" +) + +// trayWithLocalizer builds the minimum Tray the message/title resolvers touch: +// they read t.loc and nothing else, so no app, window or daemon connection is +// needed. The shipped locale tree is used so the assertions below exercise the +// real bundles rather than a fixture. +func trayWithLocalizer(t *testing.T) *Tray { + t.Helper() + bundle, err := i18n.NewBundle(os.DirFS("i18n/locales")) + require.NoError(t, err, "the shipped locale tree must load") + return &Tray{loc: NewLocalizer(bundle, nil)} +} + +func TestLocalizedEventMessageResolvesKey(t *testing.T) { + tray := trayWithLocalizer(t) + + got := tray.localizedEventMessage(services.SystemEvent{ + MessageKey: string(proto.UserMsgExitNodeConnected), + // A daemon always ships its English rendering too; the key must win. + UserMessage: "should not be used", + }) + assert.Equal(t, "Exit node connected.", got) +} + +func TestLocalizedEventMessageSubstitutesArgs(t *testing.T) { + tray := trayWithLocalizer(t) + + got := tray.localizedEventMessage(services.SystemEvent{ + MessageKey: string(proto.UserMsgUpdateCompleted), + MessageArgs: map[string]string{proto.ArgVersion: "0.60.1"}, + }) + assert.Equal(t, "Your NetBird client was auto-updated to version 0.60.1.", got) +} + +// A daemon newer than the UI can publish a key this build has never heard of. +// Showing the raw key would be a visible regression, so the daemon's own English +// text has to win instead. +func TestLocalizedEventMessageFallsBackToDaemonText(t *testing.T) { + tray := trayWithLocalizer(t) + + got := tray.localizedEventMessage(services.SystemEvent{ + MessageKey: "event.somethingThisBuildNeverHeardOf", + UserMessage: "A message from a newer daemon.", + }) + assert.Equal(t, "A message from a newer daemon.", got) +} + +// An old daemon sends no key at all, only userMessage. +func TestLocalizedEventMessageWithoutKey(t *testing.T) { + tray := trayWithLocalizer(t) + + got := tray.localizedEventMessage(services.SystemEvent{UserMessage: "Legacy English text."}) + assert.Equal(t, "Legacy English text.", got) +} + +func TestEventTitlePrefersTitleKey(t *testing.T) { + tray := trayWithLocalizer(t) + + got := tray.eventTitle(services.SystemEvent{ + Severity: "critical", + Category: "authentication", + TitleKey: string(proto.TitleSessionWarning), + }) + assert.Equal(t, "Session expires soon", got, "a title key must beat the composed title") +} + +func TestEventTitleComposesFromSeverityAndCategory(t *testing.T) { + tray := trayWithLocalizer(t) + + tests := []struct { + name string + severity string + category string + want string + }{ + {"warning dns", "warning", "dns", "Warning: DNS"}, + {"critical system", "critical", "system", "Critical: System"}, + {"info network", "info", "network", "Info: Network"}, + {"error authentication", "error", "authentication", "Error: Authentication"}, + // Enum values this build does not know, and the empty severity/category + // an event carries before the daemon fills them in. + {"unknown severity", "apocalyptic", "dns", "Info: DNS"}, + {"unknown category", "warning", "quantum", "Warning: System"}, + {"empty", "", "", "Info: System"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := tray.eventTitle(services.SystemEvent{Severity: tc.severity, Category: tc.category}) + assert.Equal(t, tc.want, got) + }) + } +} + +func TestShouldSkipSystemEvent(t *testing.T) { + tests := []struct { + name string + ev services.SystemEvent + want bool + }{ + { + name: "update announcement handled by the tray updater", + ev: services.SystemEvent{Metadata: map[string]string{"new_version_available": "0.60.1"}}, + want: true, + }, + { + name: "install progress belongs to the progress window", + ev: services.SystemEvent{Metadata: map[string]string{"progress_window": "show"}}, + want: true, + }, + { + name: "the v6 half of a dual-stack default route is already toasted as v4", + ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "::/0"}}, + want: true, + }, + { + name: "the v4 default route is the one that toasts", + ev: services.SystemEvent{Category: "network", Metadata: map[string]string{"network": "0.0.0.0/0"}}, + want: false, + }, + { + // policy_applied used to be suppressed here while the tray toasted + // off the paired config_changed event; it now carries its own keys. + name: "mdm policy applied surfaces normally", + ev: services.SystemEvent{ + MessageKey: string(proto.UserMsgMDMPolicyApplied), + Metadata: map[string]string{proto.MetadataTypeKey: proto.MetadataTypePolicyApplied}, + }, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, shouldSkipSystemEvent(tc.ev)) + }) + } +} diff --git a/client/ui/tray_session.go b/client/ui/tray_session.go index 885fdb348..0ef700dbc 100644 --- a/client/ui/tray_session.go +++ b/client/ui/tray_session.go @@ -10,8 +10,6 @@ import ( log "github.com/sirupsen/logrus" "github.com/wailsapp/wails/v3/pkg/services/notifications" - nbstatus "github.com/netbirdio/netbird/client/status" - "github.com/netbirdio/netbird/client/ui/authsession" "github.com/netbirdio/netbird/client/ui/services" ) @@ -196,25 +194,6 @@ func (t *Tray) registerSessionWarningCategory() { }) } -// buildSessionWarningBody composes the localised notification body from the daemon's metadata. -// The daemon has no locale, so it ships an RFC3339 deadline the tray turns into a user-language sentence. -// Falls back to a generic string when metadata is missing or unparsable. -func (t *Tray) buildSessionWarningBody(meta map[string]string) string { - if meta == nil { - return t.loc.T("notify.sessionWarning.bodyGeneric") - } - raw := meta[authsession.MetaExpiresAt] - if raw == "" { - return t.loc.T("notify.sessionWarning.bodyGeneric") - } - deadline, err := authsession.ParseExpiresAt(raw) - if err != nil { - return t.loc.T("notify.sessionWarning.bodyGeneric") - } - remaining := nbstatus.FormatRemainingDuration(time.Until(deadline)) - return t.loc.T("notify.sessionWarning.body", "remaining", remaining) -} - // notifySessionWarning sends the interactive expiry notification, falling back to plain notify when the // with-actions variant is unavailable (older platform impls, or a bare Notifier in tests). func (t *Tray) notifySessionWarning(title, body string) {