mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-27 09:09:07 +02:00
Localize daemon notifications via stable message keys
This commit is contained in:
@@ -40,6 +40,12 @@ i18n/locales/<code>/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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "キャンセル"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": "Отмена"
|
||||
},
|
||||
|
||||
@@ -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": "取消"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,7 +26,6 @@ const (
|
||||
notifyIDUpdatePrefix = "netbird-update-"
|
||||
notifyIDEvent = "netbird-event-"
|
||||
notifyIDTrayError = "netbird-tray-error"
|
||||
notifyIDMDMPolicy = "netbird-mdm-policy"
|
||||
|
||||
statusError = "Error"
|
||||
|
||||
|
||||
+34
-57
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user