mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 04:21:29 +02:00
Merge remote-tracking branch 'origin/ui-refactor' into ui-refactor
# Conflicts: # client/ui/frontend/src/screens/Update.tsx
This commit is contained in:
@@ -4,15 +4,143 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/i18n"
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// ErrorTranslator is the subset of i18n.Bundle Connection needs to localise
|
||||
// daemon errors. Defined as an interface so tests can stub it; the runtime
|
||||
// implementation is *i18n.Bundle.
|
||||
type ErrorTranslator interface {
|
||||
Translate(lang i18n.LanguageCode, key string, args ...string) string
|
||||
}
|
||||
|
||||
// LanguagePreference is the subset of preferences.Store Connection needs
|
||||
// to discover the current UI language at error-classification time. The
|
||||
// runtime implementation is *preferences.Store.
|
||||
type LanguagePreference interface {
|
||||
Get() preferences.UIPreferences
|
||||
}
|
||||
|
||||
// ClientError is a structured error returned to the frontend.
|
||||
//
|
||||
// The daemon hands us gRPC errors whose Message is a stack of wrapped strings
|
||||
// from the management server and the underlying JWT library, for example:
|
||||
//
|
||||
// "invalid jwt token, err: token could not be parsed: token has invalid
|
||||
// claims: token used before issued"
|
||||
//
|
||||
// Showing that raw message in a native dialog is unreadable, so we map the
|
||||
// substrings we recognise to a {code, short, long} triple. The frontend
|
||||
// translates Code through i18n (preferred); Short is an English fallback so
|
||||
// the dialog still reads cleanly if a code is missing from the locale; Long
|
||||
// always carries the unwrapped daemon message for the operator.
|
||||
type ClientError struct {
|
||||
Code string `json:"code"`
|
||||
Short string `json:"short"`
|
||||
Long string `json:"long"`
|
||||
}
|
||||
|
||||
// Error returns the user-facing short message so plain Go callers and the
|
||||
// Wails default error path still get a readable string.
|
||||
func (e *ClientError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return e.Short
|
||||
}
|
||||
|
||||
// MarshalJSON encodes the full {code, short, long} triple so the Wails
|
||||
// binding emits a structured object instead of the default "error: ..."
|
||||
// string. The TS layer accesses these fields via try/catch.
|
||||
func (e *ClientError) MarshalJSON() ([]byte, error) {
|
||||
if e == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
type alias ClientError
|
||||
return json.Marshal((*alias)(e))
|
||||
}
|
||||
|
||||
// classifyDaemonError turns a raw gRPC error from the daemon into a
|
||||
// ClientError with a stable code and a short localised summary. The Long
|
||||
// field always carries the unwrapped daemon message so the operator can
|
||||
// inspect the root cause when the short text is too generic. Short is
|
||||
// looked up via i18n under "error.<code>": i18n.Bundle.Translate already
|
||||
// handles current-language → English → key passthrough, so any missing
|
||||
// locale entry surfaces as a visible "error.<code>" string in the dialog —
|
||||
// a deliberate fail-loud signal that the bundle needs updating.
|
||||
func (s *Connection) classifyDaemonError(err error) *ClientError {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
msg := err.Error()
|
||||
if st, ok := gstatus.FromError(err); ok {
|
||||
msg = st.Message()
|
||||
}
|
||||
lower := strings.ToLower(msg)
|
||||
|
||||
code := "unknown"
|
||||
switch {
|
||||
case strings.Contains(lower, "token used before issued"),
|
||||
strings.Contains(lower, "token is not valid yet"):
|
||||
code = "jwt_clock_skew"
|
||||
case strings.Contains(lower, "token is expired"),
|
||||
strings.Contains(lower, "token has expired"):
|
||||
code = "jwt_expired"
|
||||
case strings.Contains(lower, "token signature is invalid"):
|
||||
code = "jwt_signature_invalid"
|
||||
case strings.Contains(lower, "peer login has expired"):
|
||||
code = "session_expired"
|
||||
case strings.Contains(lower, "invalid setup-key"),
|
||||
strings.Contains(lower, "invalid setup key"):
|
||||
code = "invalid_setup_key"
|
||||
case strings.Contains(lower, "permission denied"):
|
||||
code = "permission_denied"
|
||||
case strings.Contains(lower, "no connection could be made"),
|
||||
strings.Contains(lower, "connection refused"),
|
||||
strings.Contains(lower, "context deadline exceeded"):
|
||||
code = "daemon_unreachable"
|
||||
}
|
||||
|
||||
return &ClientError{
|
||||
Code: code,
|
||||
Short: s.translateShort(code),
|
||||
Long: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// translateShort resolves the localised short message for code. The i18n
|
||||
// Bundle's own Translate already falls back current-language → English →
|
||||
// key passthrough, so callers either see the localised string or the bare
|
||||
// "error.<code>" key (which makes the missing translation obvious). If
|
||||
// the translator is nil — e.g. a Connection constructed in a unit test —
|
||||
// we return the key for the same reason.
|
||||
func (s *Connection) translateShort(code string) string {
|
||||
key := "error." + code
|
||||
if s.translator == nil {
|
||||
return key
|
||||
}
|
||||
lang := i18n.DefaultLanguage
|
||||
if s.prefs != nil {
|
||||
if pref := s.prefs.Get().Language; pref != "" {
|
||||
lang = pref
|
||||
}
|
||||
}
|
||||
return s.translator.Translate(lang, key)
|
||||
}
|
||||
|
||||
// LoginParams carries the fields the UI sets when starting a login.
|
||||
type LoginParams struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
@@ -52,11 +180,17 @@ type LogoutParams struct {
|
||||
|
||||
// Connection groups the daemon RPCs that drive login / connect / disconnect.
|
||||
type Connection struct {
|
||||
conn DaemonConn
|
||||
conn DaemonConn
|
||||
translator ErrorTranslator
|
||||
prefs LanguagePreference
|
||||
}
|
||||
|
||||
func NewConnection(conn DaemonConn) *Connection {
|
||||
return &Connection{conn: conn}
|
||||
// NewConnection wires Connection with its translation dependencies. Either
|
||||
// translator or prefs may be nil; in that case classifyDaemonError falls
|
||||
// back to the English Short text baked into the error map. main.go always
|
||||
// supplies both at startup.
|
||||
func NewConnection(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference) *Connection {
|
||||
return &Connection{conn: conn, translator: translator, prefs: prefs}
|
||||
}
|
||||
|
||||
func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, error) {
|
||||
@@ -117,7 +251,7 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
return LoginResult{}, s.classifyDaemonError(err)
|
||||
}
|
||||
return LoginResult{
|
||||
NeedsSSOLogin: resp.GetNeedsSSOLogin(),
|
||||
@@ -137,7 +271,7 @@ func (s *Connection) WaitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
Hostname: p.Hostname,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
return "", s.classifyDaemonError(err)
|
||||
}
|
||||
return resp.GetEmail(), nil
|
||||
}
|
||||
@@ -155,8 +289,10 @@ func (s *Connection) Up(ctx context.Context, p UpParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Up(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Up(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Connection) Down(ctx context.Context) error {
|
||||
@@ -164,8 +300,10 @@ func (s *Connection) Down(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.Down(ctx, &proto.DownRequest{})
|
||||
return err
|
||||
if _, err = cli.Down(ctx, &proto.DownRequest{}); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenURL launches the user's preferred browser to display url. Mirrors the
|
||||
@@ -201,6 +339,8 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
if p.Username != "" {
|
||||
req.Username = ptrStr(p.Username)
|
||||
}
|
||||
_, err = cli.Logout(ctx, req)
|
||||
return err
|
||||
if _, err = cli.Logout(ctx, req); err != nil {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
"github.com/netbirdio/netbird/client/ui/updater"
|
||||
)
|
||||
|
||||
@@ -34,6 +35,14 @@ const (
|
||||
// others without polling. The daemon itself does not emit a profile
|
||||
// event, so this is the only signal that closes the gap.
|
||||
EventProfileChanged = "netbird:profile:changed"
|
||||
// EventSessionWarning is emitted on every session-warning watcher
|
||||
// fire (T-WarningLead and T-FinalWarningLead) as a strongly-typed
|
||||
// sibling of EventSystem so React / tray subscribers don't have to
|
||||
// filter the firehose of EventSystem. Consumers branch on the
|
||||
// SessionWarning.Final flag to tell the interactive T-10 event apart
|
||||
// from the fallback T-2 event; the dialog auto-open lives in the
|
||||
// tray (Go side) so the frontend stays passive on this flow.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
|
||||
// daemon's gRPC socket is unreachable (daemon not running, socket
|
||||
@@ -43,11 +52,11 @@ const (
|
||||
|
||||
// Daemon connection status strings — mirror internal.Status* in
|
||||
// client/internal/state.go.
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusConnected = "Connected"
|
||||
StatusConnecting = "Connecting"
|
||||
StatusIdle = "Idle"
|
||||
StatusNeedsLogin = "NeedsLogin"
|
||||
StatusLoginFailed = "LoginFailed"
|
||||
StatusSessionExpired = "SessionExpired"
|
||||
)
|
||||
|
||||
@@ -110,13 +119,19 @@ type LocalPeer struct {
|
||||
|
||||
// Status is the snapshot the frontend renders on the dashboard.
|
||||
type Status struct {
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Status string `json:"status"`
|
||||
DaemonVersion string `json:"daemonVersion"`
|
||||
Management PeerLink `json:"management"`
|
||||
Signal PeerLink `json:"signal"`
|
||||
Local LocalPeer `json:"local"`
|
||||
Peers []PeerStatus `json:"peers"`
|
||||
Events []SystemEvent `json:"events"`
|
||||
// SessionExpiresAt is the absolute UTC instant at which the peer's
|
||||
// SSO session expires. nil when the peer is not SSO-tracked or login
|
||||
// expiration is disabled (either server-side off, or peer not
|
||||
// SSO-registered). The UI derives "warning active" from this value
|
||||
// plus its own clock.
|
||||
SessionExpiresAt *time.Time `json:"sessionExpiresAt,omitempty"`
|
||||
}
|
||||
|
||||
// Peers serves the dashboard data: one polled Status RPC and a long-running
|
||||
@@ -277,23 +292,6 @@ func (s *Peers) Get(ctx context.Context) (Status, error) {
|
||||
return statusFromProto(resp), nil
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
// statusStreamLoop subscribes to the daemon's SubscribeStatus stream and
|
||||
// re-emits each FullStatus snapshot on the Wails event bus. The first
|
||||
// message is the current snapshot; subsequent messages fire on
|
||||
@@ -406,6 +404,9 @@ func (s *Peers) toastStreamLoop(ctx context.Context) {
|
||||
se := systemEventFromProto(ev)
|
||||
log.Infof("backend event: system severity=%s category=%s msg=%q", se.Severity, se.Category, se.UserMessage)
|
||||
s.emitter.Emit(EventSystem, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
}
|
||||
if s.updater != nil {
|
||||
s.updater.OnSystemEvent(ev)
|
||||
}
|
||||
@@ -468,6 +469,10 @@ func statusFromProto(resp *proto.StatusResponse) Status {
|
||||
for _, e := range full.GetEvents() {
|
||||
st.Events = append(st.Events, systemEventFromProto(e))
|
||||
}
|
||||
if ts := resp.GetSessionExpiresAt(); ts.IsValid() && !ts.AsTime().IsZero() {
|
||||
t := ts.AsTime().UTC()
|
||||
st.SessionExpiresAt = &t
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
@@ -488,3 +493,20 @@ func systemEventFromProto(e *proto.SystemEvent) SystemEvent {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// isDaemonUnreachable reports whether a gRPC stream error indicates the
|
||||
// daemon socket itself is not answering (process down, socket missing,
|
||||
// permission denied) versus the daemon responding with an application-level
|
||||
// error code. Only the former should flip the tray to "Not running" — a
|
||||
// daemon that returns FailedPrecondition (e.g. while it's retrying the
|
||||
// management connection) is alive and shouldn't be reported as down.
|
||||
func isDaemonUnreachable(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return st.Code() == codes.Unavailable
|
||||
}
|
||||
|
||||
48
client/ui/services/session.go
Normal file
48
client/ui/services/session.go
Normal file
@@ -0,0 +1,48 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/authsession"
|
||||
)
|
||||
|
||||
// Re-exports so frontend bindings stay on services.ExtendStart* /
|
||||
// services.ExtendWait* / services.ExtendResult without each call site
|
||||
// importing authsession.
|
||||
type (
|
||||
ExtendStartParams = authsession.ExtendStartParams
|
||||
ExtendStartResult = authsession.ExtendStartResult
|
||||
ExtendWaitParams = authsession.ExtendWaitParams
|
||||
ExtendResult = authsession.ExtendResult
|
||||
)
|
||||
|
||||
// Session is the Wails-bound wrapper around authsession.Session. It only
|
||||
// re-exposes the subset the React frontend actually calls
|
||||
// (SessionAboutToExpireDialog.tsx: RequestExtend + WaitExtend). The tray
|
||||
// uses authsession.Session directly, so methods that only the tray needs
|
||||
// (DismissWarning) are deliberately absent here — keeping the generated
|
||||
// TS surface minimal.
|
||||
type Session struct {
|
||||
inner *authsession.Session
|
||||
}
|
||||
|
||||
// NewSession returns the Wails-bound wrapper. The caller owns the inner
|
||||
// authsession.Session and may use it directly (e.g. the tray).
|
||||
func NewSession(inner *authsession.Session) *Session {
|
||||
return &Session{inner: inner}
|
||||
}
|
||||
|
||||
// RequestExtend starts the SSO session-extension flow on the daemon and
|
||||
// returns the verification URI for the UI to open.
|
||||
func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (ExtendStartResult, error) {
|
||||
return s.inner.RequestExtend(ctx, p)
|
||||
}
|
||||
|
||||
// WaitExtend blocks until the user completes the SSO flow started by
|
||||
// RequestExtend, then returns the new session deadline (or nil when the
|
||||
// management server reports the peer ineligible).
|
||||
func (s *Session) WaitExtend(ctx context.Context, p ExtendWaitParams) (ExtendResult, error) {
|
||||
return s.inner.WaitExtend(ctx, p)
|
||||
}
|
||||
Reference in New Issue
Block a user