mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-31 12:01:29 +02:00
feat(ui): GUI debug logging follows daemon log level + debug bundle
When the daemon is set to debug/trace, the GUI now automatically writes a rotated gui-client.log in the user's config dir and the daemon's debug bundle collects it. The UI learns the level both at startup (daemon already in debug) and live, by piggybacking the existing SubscribeEvents stream: the daemon publishes a marked log-level-changed SystemEvent (and a per-subscription snapshot), which DaemonFeed routes to guilog.DebugLog instead of an OS toast. The UI registers its log path via a new RegisterUILog RPC so the root daemon, which can't resolve the user's config dir, knows where to find the file. Manual --log-file (any value) disables the daemon-driven file logging. Fix: client/ui SetLogLevel looked up proto.LogLevel_value with the lowercase logrus name, which never matched the uppercase enum keys and silently fell back to INFO — so trace/debug requests from the bundle flow had no effect.
This commit is contained in:
@@ -7,7 +7,7 @@ This is the Wails v3 desktop UI for NetBird. Go services live in `services/`; th
|
||||
## Layout
|
||||
|
||||
### Go (top-level package `main`)
|
||||
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; first user-provided value drops the seeded `console` default), `--log-level` (`trace|debug|info|warn|error`, default `info`).
|
||||
- `main.go` — app entry. Builds the shared gRPC `Conn`, constructs services, registers them with Wails, creates the main webview window, then starts (in order) the Linux SNI watcher → tray → `peers.Watch` → `app.Run`. CLI flags: `--daemon-addr`, `--log-file` (repeatable; default is **empty** so "no flag" is distinguishable from explicit `--log-file console` — when empty `parseFlagsAndInitLog` falls back to `console` for `InitLog` and returns `userSetLogFile=false`), `--log-level` (`trace|debug|info|warn|error`, default `info`). See "GUI debug logging" below for what `userSetLogFile` gates.
|
||||
- `tray.go` — `Tray` struct + menu. Subscribes to `EventStatus`, `EventSystem`, `EventUpdateAvailable`, `EventUpdateProgress`. Owns per-status icon/dot, Profiles submenu, Connect/Disconnect swap, About → Update, session-expired toast.
|
||||
- **Tray menu updates go through `relayoutMenu` (whole-tree rebuild), never in-place submenu mutation.** Any dynamic menu change — Profiles submenu (`tray_profiles.go loadProfiles` → caches rows under `profilesMu`, then `fillProfileSubmenu`), Exit Node submenu (`tray_exitnodes.go refreshExitNodes` → `fillExitNodeSubmenu`), daemon-version row (`tray_status.go`), and the About → Update row (`tray_update.go applyState` → `onMenuChange` callback) — rebuilds the entire menu via `Tray.relayoutMenu` (`buildMenu()` + repaint cached state + single `t.tray.SetMenu`). Serialised by `menuMu`. **Why:** on KDE/Plasma the StatusNotifierItem host caches a submenu's layout the first time it's opened (`GetLayout` for that submenu id) and never re-fetches it on a `LayoutUpdated(parent=0)` signal — so the old `submenu.Clear()`+`Add()` left both the visible rows AND the click→id mapping frozen on the first snapshot. Because `Clear()`+`Add()` allocates fresh monotonic item ids each time (Wails `menuitem.go`), clicks then sent ids the rebuilt `itemMap` no longer knew, and silently no-op'd ("Manage Profiles" stopped responding after the first switch). `buildMenu()` allocates a brand-new submenu container id each relayout, which Plasma treats as unseen and re-queries on next open — fixing both the stale paint and the dead clicks. Confirmed via `dbus-monitor`: a re-opened submenu issued no `GetLayout` until its container id changed. The whole-tree `SetMenu` also subsumes the older darwin detached-NSMenu workaround. `fill*Submenu` helpers are pure UI (read caches, no daemon fetch, no `SetMenu`) so `relayoutMenu` never recurses back into the fetchers.
|
||||
- `tray_linux.go` — `init()` sets `WEBKIT_DISABLE_DMABUF_RENDERER=1` (blank-white window on VMs / minimal WMs) and `WEBKIT_DISABLE_COMPOSITING_MODE=1` (Intel/Mesa SIGSEGV in `g_application_run` via unimplemented DRM-format-modifier paths — DMABUF-disable alone doesn't cover the GL compositor). Both are skipped if the user already set the var. Also `WEBKIT_DISABLE_SANDBOX_THIS_IS_DANGEROUS=1` when unprivileged userns are blocked.
|
||||
@@ -35,7 +35,7 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
|
||||
| `Peers` | `peers.go` | Daemon status snapshot + two long-running streams (`SubscribeStatus` → `EventStatus`, `SubscribeEvents` → `EventSystem`). Emits synthetic `StatusDaemonUnavailable` when the socket is unreachable. Owns the profile-switch suppression filter (`BeginProfileSwitch` / `CancelProfileSwitch` / `shouldSuppress`). Fan-outs update metadata into dedicated `EventUpdateAvailable` / `EventUpdateProgress` events. |
|
||||
| `Networks` | `network.go` | `List` / `Select` / `Deselect` of routed networks. |
|
||||
| `Forwarding` | `forwarding.go` | `List` exposed/forwarded services from the daemon's reverse-proxy table. |
|
||||
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RevealFile` (cross-platform "show in file manager"). |
|
||||
| `Debug` | `debug.go` | `Bundle` (debug bundle creation + optional upload) / `Get|SetLogLevel` / `RegisterUILog` (report the GUI log path to the daemon for bundle collection) / `RevealFile` (cross-platform "show in file manager"). |
|
||||
| `Update` | `update.go` | `GetState` / `Trigger` (enforced installer) / `GetInstallerResult` / `Quit`. The install-progress UI lives in its own auxiliary window (`/#/dialog/install-progress`), opened by `WindowManager.OpenInstallProgress` — the daemon goes unreachable mid-install so it can't be inside the main window. |
|
||||
| `WindowManager` | `windowmanager.go` | `OpenSettings(tab)` / `OpenBrowserLogin(uri)` / `CloseBrowserLogin` / `OpenSessionExpiration(seconds)` / `CloseSessionExpiration` / `OpenInstallProgress(version)` / `CloseInstallProgress` / `OpenWelcome` / `CloseWelcome` / `OpenError(title, message)` / `CloseError` / `OpenMain`. `OpenSettings("")` opens the General tab; pass a tab id (e.g. `"profiles"`) to deep-link, encoded as `?tab=…` in the start URL. `OpenInstallProgress` is `AlwaysOnTop` and hides every other visible window for the duration of the install (restored on close). `OpenMain` is the handoff path from the welcome window to the main UI (avoids depending on the tray). Auxiliary windows are created on first open and **destroyed** on close (Wails-recommended singleton pattern; prevents the macOS dock-reopen from resurrecting hidden windows). |
|
||||
| `I18n` | `i18n.go` | Thin facade over `i18n.Bundle`. `Languages()` returns the shipped locales (`_index.json`); `Bundle(code)` returns the full key→text map for one language so the React layer can drive its own translation library. |
|
||||
@@ -50,6 +50,17 @@ All services live in `services/` and assume a build tag `!android && !ios && !fr
|
||||
- Pinned versions (see `daemon.pb.go` header): `protoc v7.34.1`, `protoc-gen-go v1.36.6`. CI's `proto-version-check` workflow fails on mismatch.
|
||||
- After proto regen, also regen Wails bindings so the TS layer picks up new fields.
|
||||
|
||||
## GUI debug logging
|
||||
|
||||
When the daemon is put into **debug**/**trace** level, the GUI automatically writes a rotated `gui-client.log` in `os.UserConfigDir()/netbird/` and the daemon's **debug bundle** collects it. Pieces:
|
||||
|
||||
- **`uilogpath.go`** (package `main`) — `uiLogPath()` resolves the path; `newDebugLog(userSetLogFile)` builds the `guilog.DebugLog` (disabled when the user passed `--log-file`, or when the config dir can't be resolved).
|
||||
- **`guilog/debuglog.go` — `guilog.DebugLog`** (own package, **not** a Wails service — no React binding) — owns the file-logging side effects. `Apply(level)`: on debug/trace attaches `gui-client.log` alongside the console (via `util.SetLogOutputs`, MultiWriter, rotated by timberjack like the daemon log) and raises the logrus level; on a higher level detaches the file and restores `info`. Idempotent (`fileOn` guard) so the startup replay + a racing change-event are harmless. The `gui-client.log` is left on disk on quit (rotated by timberjack) for the debug bundle — there's no shutdown cleanup. `Path()` returns "" when disabled so the daemon won't try to collect a file the GUI never writes.
|
||||
- **Activation rule:** any `--log-file` (even `console`) is a manual override → the controller is disabled and never touches logging. Only the *absence* of `--log-file` enables the daemon-driven `gui-client.log`. This is why `parseFlagsAndInitLog` seeds the flag default empty (Layout note above).
|
||||
- **Level signalling — no new stream.** The daemon publishes a **marked `SystemEvent`** (`metadata[proto.MetadataKindKey]==proto.MetadataKindLogLevelChanged`, `metadata[proto.MetadataLevelKey]==<logrus name>`) on the existing `SubscribeEvents` stream. `DaemonFeed.dispatchSystemEvent` recognises the marker and routes it to the controller's `Apply` instead of an OS toast (same pattern as `proto.MetadataKindProfileListChanged`). The controller is injected into `NewDaemonFeed` (a `services.LogController` interface — no exported setter, so the Wails-bound `DaemonFeed` gains no binding), and `DaemonFeed` **re-registers the UI log path** (`RegisterUILog` RPC) on every event-stream (re)connect, so a daemon restart re-learns it. Startup case (daemon already in debug) is covered daemon-side: `Server.SubscribeEvents` (`client/server/event.go`) sends the current level once to each new subscriber; `SetLogLevel` publishes on change (`publishLogLevelChanged` in `client/server/server.go`). The metadata key/value markers live in `client/proto/metadata.go` so producer (daemon) and consumer (UI) share one definition.
|
||||
- **Daemon side of the bundle:** `Server.RegisterUILog` stores the path in `Server.uiLogPath`; `DebugBundle` passes it to `debug.GeneratorDependencies.UILogPath`; `BundleGenerator.addUILog` adds `gui-client.log` + rotated siblings (`addRotatedLogFiles(dir, "gui-client")` — the glob is prefix-parametrised so it doesn't collide with the daemon's own `client*.log.*`). Missing file is non-fatal (the GUI only writes it while in debug).
|
||||
- **JS-side logs/errors** already reach the same logrus pipeline via `frontend/src/lib/logs.ts` → `services.UILog.Log`, so once the file is attached, frontend console/`unhandledrejection`/`error` output is captured too. Go-side uncaught-goroutine panics go to stderr only (out of scope).
|
||||
|
||||
## Events bus
|
||||
|
||||
`main.go` registers five typed events for the frontend: `netbird:status` (`Status`), `netbird:event` (`SystemEvent`), `netbird:profile:changed` (`ProfileRef`), `netbird:update:available` (`UpdateAvailable`), `netbird:update:progress` (`UpdateProgress`). `netbird:profile:changed` fires from `ProfileSwitcher.SwitchActive` after a successful daemon-side switch — both the React `ProfileContext` and the tray subscribe so a flip driven from one surface paints in the others (the daemon itself does not emit a profile event). Plus three plain-string events:
|
||||
|
||||
@@ -8,6 +8,11 @@ import { useProfile } from "@/contexts/ProfileContext.tsx";
|
||||
const NETBIRD_UPLOAD_URL = "https://upload.debug.netbird.io/upload-url";
|
||||
const TRACE_LOG_FILE_COUNT = 5;
|
||||
const PLAIN_LOG_FILE_COUNT = 1;
|
||||
// Lowercase logrus level name sent to Debug.SetLogLevel (the Go binding
|
||||
// upper-cases before the proto enum lookup). Raising to trace is what drives
|
||||
// the daemon's verbose logging and the GUI's gui-client.log during a bundle.
|
||||
const TRACE_LOG_LEVEL = "trace";
|
||||
const DEFAULT_LOG_LEVEL = "info";
|
||||
|
||||
export type DebugStage =
|
||||
| { kind: "idle" }
|
||||
@@ -68,7 +73,7 @@ const runTracePhase = async (
|
||||
// empty
|
||||
}
|
||||
throwIfAborted(signal);
|
||||
await DebugSvc.SetLogLevel({ level: "trace" });
|
||||
await DebugSvc.SetLogLevel({ level: TRACE_LOG_LEVEL });
|
||||
level.raised = true;
|
||||
|
||||
throwIfAborted(signal);
|
||||
@@ -123,7 +128,7 @@ const useDebugBundle = () => {
|
||||
const signal = ctrl.signal;
|
||||
|
||||
const uploadUrl = upload ? NETBIRD_UPLOAD_URL : "";
|
||||
const level: LevelState = { original: "info", raised: false };
|
||||
const level: LevelState = { original: DEFAULT_LOG_LEVEL, raised: false };
|
||||
|
||||
try {
|
||||
if (trace) {
|
||||
|
||||
92
client/ui/guilog/debuglog.go
Normal file
92
client/ui/guilog/debuglog.go
Normal file
@@ -0,0 +1,92 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
// Package guilog manages the desktop UI's own file log (gui-client.log), which
|
||||
// follows the daemon's log level: when the daemon is in debug/trace the GUI
|
||||
// attaches a rotated file alongside the console so its (and the React frontend's
|
||||
// forwarded) output is captured for the debug bundle. It is intentionally not a
|
||||
// Wails service — it has no frontend-facing methods and generates no TS
|
||||
// bindings — so it lives outside client/ui/services.
|
||||
package guilog
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// DebugLog is the daemon-debug-driven GUI file log. The daemon publishes a
|
||||
// marked "log-level-changed" SystemEvent over SubscribeEvents (both on change
|
||||
// and once per new subscription, so a daemon already in debug is picked up at
|
||||
// startup); services.DaemonFeed routes it here via Apply.
|
||||
//
|
||||
// When the daemon is in debug/trace and the GUI owns its log (no manual
|
||||
// --log-file), it attaches a rotated gui-client.log alongside the console and
|
||||
// raises the logrus level; back to a higher level it detaches the file and
|
||||
// restores info. The file is left on disk (rotated by timberjack) for the debug
|
||||
// bundle to collect. When the user set --log-file explicitly, it is disabled and
|
||||
// never touches logging.
|
||||
type DebugLog struct {
|
||||
uiPath string
|
||||
enabled bool
|
||||
|
||||
mu sync.Mutex
|
||||
fileOn bool
|
||||
}
|
||||
|
||||
// NewDebugLog builds the GUI debug log. uiPath is the absolute gui-client.log
|
||||
// path; enabled is false when the user passed --log-file (manual override), in
|
||||
// which case it leaves logging untouched.
|
||||
func NewDebugLog(uiPath string, enabled bool) *DebugLog {
|
||||
return &DebugLog{uiPath: uiPath, enabled: enabled}
|
||||
}
|
||||
|
||||
// Path returns the GUI log path to register with the daemon, or "" when the GUI
|
||||
// doesn't own its log (manual --log-file) — in that case the daemon shouldn't
|
||||
// try to collect a gui-client.log the GUI never writes.
|
||||
func (d *DebugLog) Path() string {
|
||||
if !d.enabled {
|
||||
return ""
|
||||
}
|
||||
return d.uiPath
|
||||
}
|
||||
|
||||
// Apply reacts to a daemon log level (the lowercase logrus name, e.g. "debug").
|
||||
// Idempotent: repeated identical levels are no-ops, so the startup replay plus a
|
||||
// racing change-event do no harm.
|
||||
func (d *DebugLog) Apply(level string) {
|
||||
if !d.enabled {
|
||||
return
|
||||
}
|
||||
|
||||
// "debug or more verbose" (debug/trace) turns the file log on; anything less
|
||||
// verbose turns it off. Compare numerically against logrus' own levels so
|
||||
// there are no hard-coded level-name literals.
|
||||
lvl, err := log.ParseLevel(level)
|
||||
if err != nil {
|
||||
lvl = log.InfoLevel
|
||||
}
|
||||
debug := lvl >= log.DebugLevel
|
||||
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
switch {
|
||||
case debug && !d.fileOn:
|
||||
if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole, d.uiPath); err != nil {
|
||||
log.Errorf("attach GUI file log %s: %v", d.uiPath, err)
|
||||
return
|
||||
}
|
||||
log.SetLevel(lvl)
|
||||
d.fileOn = true
|
||||
log.Infof("GUI file logging enabled (daemon level %s), writing to %s", level, d.uiPath)
|
||||
case !debug && d.fileOn:
|
||||
if err := util.SetLogOutputs(log.StandardLogger(), util.LogConsole); err != nil {
|
||||
log.Errorf("detach GUI file log: %v", err)
|
||||
}
|
||||
log.SetLevel(log.InfoLevel)
|
||||
d.fileOn = false
|
||||
log.Infof("GUI file logging disabled (daemon level: %s)", level)
|
||||
}
|
||||
}
|
||||
@@ -84,9 +84,16 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
daemonAddr := parseFlagsAndInitLog()
|
||||
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
|
||||
conn := NewConn(daemonAddr)
|
||||
|
||||
// GUI file logging: when the user didn't pass --log-file, the GUI manages a
|
||||
// gui-client.log that follows the daemon's debug level (attached when the
|
||||
// daemon is in debug/trace, detached otherwise, rotated by timberjack) and is
|
||||
// included in the debug bundle. It rides DaemonFeed's SubscribeEvents stream
|
||||
// (passed into NewDaemonFeed below; see guilog.DebugLog).
|
||||
debugLog := newDebugLog(userSetLogFile)
|
||||
|
||||
// tray is captured in the SingleInstance callback below; the var is
|
||||
// declared before app.New so the closure has a stable reference.
|
||||
var tray *Tray
|
||||
@@ -103,7 +110,7 @@ func main() {
|
||||
// Wails-bound facade over the holder plus the install RPCs.
|
||||
updaterHolder := updater.NewHolder(app.Event)
|
||||
update := services.NewUpdate(conn, updaterHolder)
|
||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder)
|
||||
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
|
||||
notifier := notifications.New()
|
||||
// macOS won't surface any toast until the app has requested permission;
|
||||
// the request runs after ApplicationStarted so the notifier's Startup has
|
||||
@@ -230,18 +237,30 @@ func requestNotificationAuthorization(notifier *notifications.NotificationServic
|
||||
}
|
||||
|
||||
// parseFlagsAndInitLog parses the CLI flags, initialises the logger, and
|
||||
// returns the resolved daemon gRPC address.
|
||||
func parseFlagsAndInitLog() string {
|
||||
// returns the resolved daemon gRPC address plus userSetLogFile — true when the
|
||||
// user passed --log-file explicitly. userSetLogFile is the manual-override
|
||||
// signal: when true the GUI leaves logging alone (the daemon's debug level
|
||||
// won't attach gui-client.log); when false the GUI manages a per-session
|
||||
// gui-client.log driven by the daemon level. The default seed is empty (not
|
||||
// "console") so "no flag" and an explicit "--log-file console" are
|
||||
// distinguishable; an empty result falls back to console for InitLog.
|
||||
func parseFlagsAndInitLog() (string, bool) {
|
||||
daemonAddr := flag.String("daemon-addr", DaemonAddr(), "Daemon gRPC address: unix:///path or tcp://host:port")
|
||||
logFiles := &stringList{values: []string{"console"}}
|
||||
flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console.")
|
||||
logFiles := &stringList{}
|
||||
flag.Var(logFiles, "log-file", "Log destination. Repeat to log to multiple targets at once, e.g. `--log-file console --log-file Y:/netbird-ui.log`. Each value is one of: console, syslog, or a file path. File destinations are rotated by lumberjack (same as the daemon). Defaults to console. Passing any value disables the daemon-debug-driven gui-client.log.")
|
||||
logLevel := flag.String("log-level", "info", "Log level: trace|debug|info|warn|error.")
|
||||
flag.Parse()
|
||||
|
||||
if err := util.InitLog(*logLevel, logFiles.values...); err != nil {
|
||||
userSetLogFile := len(logFiles.values) > 0
|
||||
targets := logFiles.values
|
||||
if !userSetLogFile {
|
||||
targets = []string{"console"}
|
||||
}
|
||||
|
||||
if err := util.InitLog(*logLevel, targets...); err != nil {
|
||||
log.Fatalf("init log: %v", err)
|
||||
}
|
||||
return *daemonAddr
|
||||
return *daemonAddr, userSetLogFile
|
||||
}
|
||||
|
||||
// newApplication constructs the Wails application. onSecondInstance is invoked
|
||||
|
||||
@@ -46,20 +46,11 @@ const (
|
||||
// tray (Go side) so the frontend stays passive on this flow.
|
||||
EventSessionWarning = "netbird:session:warning"
|
||||
|
||||
// MetadataKindProfileListChanged is the SystemEvent.metadata["kind"]
|
||||
// marker the daemon stamps on the INFO/SYSTEM event it publishes after a
|
||||
// CLI-driven AddProfile / RemoveProfile (the daemon emits no dedicated
|
||||
// profile RPC event). dispatchSystemEvent recognises it and re-emits the
|
||||
// existing EventProfileChanged so the tray and React profile views refresh
|
||||
// — closing the gap the SubscribeStatus path can't, since a profile
|
||||
// add/remove doesn't change the daemon's status string (the tray's
|
||||
// iconChanged guard would swallow it). The daemon side hard-codes the same
|
||||
// string literal in client/server/server.go (client/server cannot import
|
||||
// this UI package).
|
||||
MetadataKindProfileListChanged = "profile-list-changed"
|
||||
// metadataKindKey is the SystemEvent.metadata key the "kind" marker lives
|
||||
// under. Kept in sync with the daemon-side literal in client/server.
|
||||
metadataKindKey = "kind"
|
||||
// The SystemEvent.metadata markers the daemon stamps on its internal
|
||||
// control events live in the shared proto package
|
||||
// (proto.MetadataKind*/MetadataKindKey/MetadataLevelKey) so producer
|
||||
// (client/server) and consumer (here) reference the same constants. See
|
||||
// dispatchSystemEvent for how they're recognised.
|
||||
|
||||
// StatusDaemonUnavailable is the synthetic Status the UI emits when the
|
||||
// daemon's gRPC socket is unreachable (daemon not running, socket
|
||||
@@ -198,6 +189,13 @@ type DaemonFeed struct {
|
||||
conn DaemonConn
|
||||
emitter Emitter
|
||||
updater *updater.Holder
|
||||
// logCtl reacts to the daemon's log level (delivered as a marked
|
||||
// SystemEvent over the same SubscribeEvents stream) by attaching/detaching
|
||||
// the GUI file log. nil when the GUI doesn't manage its log (server build /
|
||||
// not wired), in which case the marker is ignored. Held as a narrow
|
||||
// interface so this package doesn't depend on client/ui/guilog (the concrete
|
||||
// type lives there; main passes it into NewDaemonFeed).
|
||||
logCtl LogController
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
@@ -217,8 +215,24 @@ type DaemonFeed struct {
|
||||
switchLoginWatchUntil time.Time
|
||||
}
|
||||
|
||||
func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder) *DaemonFeed {
|
||||
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder}
|
||||
// LogController is the subset of client/ui/guilog.DebugLog that DaemonFeed
|
||||
// drives: Apply turns the GUI file log on/off for a daemon level, Path is the
|
||||
// gui-client.log path to register with the daemon (empty when the GUI doesn't
|
||||
// own its log). Kept as an interface so services doesn't import guilog. The
|
||||
// daemon delivers log-level changes as marked SystemEvents on the same
|
||||
// SubscribeEvents stream this feed consumes, so it rides along here rather than
|
||||
// opening a second daemon subscription.
|
||||
type LogController interface {
|
||||
Apply(level string)
|
||||
Path() string
|
||||
}
|
||||
|
||||
// NewDaemonFeed builds the feed. logCtl may be nil (server build / GUI log not
|
||||
// managed), in which case log-level markers on the event stream are ignored.
|
||||
// Injected at construction rather than via a setter so DaemonFeed (a Wails
|
||||
// service) exposes no extra method to the binding generator.
|
||||
func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Holder, logCtl LogController) *DaemonFeed {
|
||||
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl}
|
||||
}
|
||||
|
||||
// BeginProfileSwitch is called by ProfileSwitcher at the start of a switch
|
||||
@@ -525,6 +539,17 @@ func (s *DaemonFeed) subscribeAndStreamEvents(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("subscribe: %w", err)
|
||||
}
|
||||
|
||||
// Re-register the GUI log path on every (re)connect so a daemon restart
|
||||
// re-learns it and a later debug bundle still finds the file. Best-effort —
|
||||
// a failure here must not abort the event stream. Done even when file
|
||||
// logging is off (enabled but not in debug), so the path is known ahead of
|
||||
// any debug toggle.
|
||||
if s.logCtl != nil && s.logCtl.Path() != "" {
|
||||
if _, err := cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: s.logCtl.Path()}); err != nil {
|
||||
log.Warnf("register UI log path: %v", err)
|
||||
}
|
||||
}
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
@@ -549,10 +574,19 @@ func (s *DaemonFeed) dispatchSystemEvent(ev *proto.SystemEvent) {
|
||||
// ProfileContext.refresh already subscribe to) and stop — it's an internal
|
||||
// refresh signal, not a user-facing notification, so it must not reach the
|
||||
// Recent Events list or fire an OS toast.
|
||||
if se.Metadata[metadataKindKey] == MetadataKindProfileListChanged {
|
||||
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindProfileListChanged {
|
||||
s.emitter.Emit(EventProfileChanged, ProfileRef{})
|
||||
return
|
||||
}
|
||||
// A marked log-level-changed event drives the GUI file log on/off. It's an
|
||||
// internal control signal, not a user-facing notification — handle and stop
|
||||
// so it never reaches the Recent Events list or fires an OS toast.
|
||||
if se.Metadata[proto.MetadataKindKey] == proto.MetadataKindLogLevelChanged {
|
||||
if s.logCtl != nil {
|
||||
s.logCtl.Apply(se.Metadata[proto.MetadataLevelKey])
|
||||
}
|
||||
return
|
||||
}
|
||||
s.emitter.Emit(EventDaemonNotification, se)
|
||||
if warn, ok := authsession.WarningFromMetadata(se.Metadata); ok {
|
||||
s.emitter.Emit(EventSessionWarning, warn)
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/version"
|
||||
@@ -99,12 +100,29 @@ func (s *Debug) RevealFile(_ context.Context, path string) error {
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// RegisterUILog tells the daemon the absolute path of the GUI's log file so
|
||||
// the daemon's debug bundle can collect it (the daemon runs as root and can't
|
||||
// resolve the user's config dir). Called by LogLevelWatcher on each daemon
|
||||
// (re)connect.
|
||||
func (s *Debug) RegisterUILog(ctx context.Context, path string) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = cli.RegisterUILog(ctx, &proto.RegisterUILogRequest{Path: path})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Debug) SetLogLevel(ctx context.Context, lvl LogLevel) error {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
level, ok := proto.LogLevel_value[lvl.Level]
|
||||
// proto.LogLevel_value keys are the enum names (TRACE/DEBUG/INFO/...), but
|
||||
// callers (the React side, GetLogLevel) use the lowercase logrus names
|
||||
// ("trace"/"debug"/...). Upper-case before the lookup so a lowercase level
|
||||
// doesn't silently fall back to INFO.
|
||||
level, ok := proto.LogLevel_value[strings.ToUpper(lvl.Level)]
|
||||
if !ok {
|
||||
level = int32(proto.LogLevel_INFO)
|
||||
}
|
||||
|
||||
41
client/ui/uilogpath.go
Normal file
41
client/ui/uilogpath.go
Normal file
@@ -0,0 +1,41 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/guilog"
|
||||
)
|
||||
|
||||
// uiLogFileName is the base name of the GUI's log. Rotated siblings
|
||||
// (gui-client.log.*, *.gz) share the prefix; the daemon's debug bundle globs
|
||||
// "gui-client*.log.*" to collect them (see addUILog in client/internal/debug).
|
||||
const uiLogFileName = "gui-client.log"
|
||||
|
||||
// uiLogPath resolves os.UserConfigDir()/netbird/gui-client.log — the per-OS-user
|
||||
// path the GUI writes its log to while the daemon is in debug, and the path it
|
||||
// registers with the daemon for debug-bundle collection. Native separators are
|
||||
// preserved (the daemon os.Open()s this path).
|
||||
func uiLogPath() (string, error) {
|
||||
dir, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "netbird", uiLogFileName), nil
|
||||
}
|
||||
|
||||
// newDebugLog builds the GUI debug log. userSetLogFile disables it (manual
|
||||
// --log-file override). If the config dir can't be resolved it's created
|
||||
// disabled, so the GUI keeps working without file logging.
|
||||
func newDebugLog(userSetLogFile bool) *guilog.DebugLog {
|
||||
path, err := uiLogPath()
|
||||
if err != nil {
|
||||
log.Warnf("resolve GUI log path: %v; GUI file logging disabled", err)
|
||||
return guilog.NewDebugLog("", false)
|
||||
}
|
||||
return guilog.NewDebugLog(path, !userSetLogFile)
|
||||
}
|
||||
Reference in New Issue
Block a user