From 7eb35f82b1b008154c5f2fb54b40fd51f4bf5156 Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sun, 26 Jul 2026 20:44:49 +0800 Subject: [PATCH] fix gui --- cmd/tslink-gui/main.go | 2 +- core/concurrent_test.go | 67 ----- core/lanscan.go | 539 ---------------------------------------- core/peermon.go | 9 + core/supervisor.go | 7 - gui/app.go | 122 ++++++--- gui/chart.go | 103 ++++++-- gui/chart_test.go | 149 +++++++++++ gui/fontload.go | 69 ++++- gui/i18n.go | 121 ++++----- gui/icons.go | 8 - gui/overlay.go | 269 -------------------- gui/page_diag.go | 187 +++++++++++++- gui/page_lan.go | 190 -------------- gui/page_logs.go | 1 + gui/page_overview.go | 51 ++-- gui/page_peers.go | 103 +++++--- gui/render_test.go | 98 ++++++-- gui/reveal.go | 65 +++++ gui/services.go | 257 +++++++++++++++++++ gui/services_test.go | 106 ++++++++ gui/shot_test.go | 310 +++++++++++++++++++++++ gui/splash.go | 278 +++++++++++---------- gui/widgets.go | 67 ++++- netdiag/egress.go | 53 +++- netdiag/egress_test.go | 154 ++++++++++++ netdiag/run.go | 55 ++-- netdiag/stun.go | 4 +- netdiag/types.go | 19 ++ 29 files changed, 2000 insertions(+), 1463 deletions(-) delete mode 100644 core/lanscan.go create mode 100644 gui/chart_test.go delete mode 100644 gui/overlay.go delete mode 100644 gui/page_lan.go create mode 100644 gui/reveal.go create mode 100644 gui/services.go create mode 100644 gui/services_test.go create mode 100644 gui/shot_test.go create mode 100644 netdiag/egress_test.go diff --git a/cmd/tslink-gui/main.go b/cmd/tslink-gui/main.go index 6a62718..7d833e9 100644 --- a/cmd/tslink-gui/main.go +++ b/cmd/tslink-gui/main.go @@ -2,7 +2,7 @@ // // It runs the same service the headless binary does — see the root main.go — // but supervises it in-process so the window can show tailnet peer health, -// latency history, Minecraft servers announced on the LAN, and a full network +// latency history, the services tslink is forwarding, and a full network // diagnostic run, plus a searchable log view that can be shared to a paste // service for support. // diff --git a/core/concurrent_test.go b/core/concurrent_test.go index 24f94ec..d8886ef 100644 --- a/core/concurrent_test.go +++ b/core/concurrent_test.go @@ -3,7 +3,6 @@ package core import ( "context" "log/slog" - "net/netip" "sync" "testing" "time" @@ -152,72 +151,6 @@ func TestLogBufferRedactsOnExport(t *testing.T) { } } -func TestLanScannerConcurrentAccess(t *testing.T) { - s := NewLanScanner(slog.New(slog.DiscardHandler)) - - ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) - defer cancel() - s.Start(ctx) - - var wg sync.WaitGroup - - // Feed announcements the way the read loops do. - wg.Add(1) - go func() { - defer wg.Done() - for i := 0; ctx.Err() == nil; i++ { - src := netip.AddrPortFrom(netip.MustParseAddr("192.168.1.50"), uint16(40000+i%3)) - s.handle(src, "[MOTD]§aTest §bServer[/MOTD][AD]25565[/AD]") - s.handle(src, "malformed packet") - } - }() - - // Read like the GUI does. - for i := 0; i < 3; i++ { - wg.Add(1) - go func() { - defer wg.Done() - for ctx.Err() == nil { - for _, srv := range s.Servers() { - _ = srv.Motd - _ = srv.Addr - } - _ = s.Err() - } - }() - } - - // Reconfigure while it runs. - wg.Add(1) - go func() { - defer wg.Done() - for ctx.Err() == nil { - s.SetSelfEntries([]LanEntry{{Motd: "Test Server", Port: 25565}}) - time.Sleep(time.Millisecond) - s.SetSelfEntries(nil) - } - }() - - wg.Wait() - - servers := s.Servers() - if len(servers) == 0 { - t.Fatal("expected the synthetic announcements to be recorded") - } - for _, srv := range servers { - if srv.Port != 25565 { - t.Errorf("unexpected port %d", srv.Port) - } - // Colour codes must be stripped. - if indexOf(srv.Motd, "§") >= 0 { - t.Errorf("colour codes survived: %q", srv.Motd) - } - if srv.Motd != "Test Server" { - t.Errorf("motd = %q, want %q", srv.Motd, "Test Server") - } - } -} - func TestPeerMonitorSnapshotIsIsolated(t *testing.T) { m := NewPeerMonitor(nil, nil, slog.New(slog.DiscardHandler), PeerMonitorOptions{}) diff --git a/core/lanscan.go b/core/lanscan.go deleted file mode 100644 index 5b7573b..0000000 --- a/core/lanscan.go +++ /dev/null @@ -1,539 +0,0 @@ -package core - -import ( - "context" - "errors" - "log/slog" - "net" - "net/netip" - "sort" - "strconv" - "strings" - "sync" - "time" -) - -// Minecraft's LAN discovery protocol: servers multicast the ASCII payload -// "[MOTD][/MOTD][AD][/AD]" to these groups roughly every 1.5s. -// core/lan.go sends them; this file listens for them. -const ( - lanScanGroupV4 = "224.0.2.60:4445" - lanScanGroupV6 = "[ff75:230::60]:4445" -) - -const ( - // lanScanExpiry drops a server that stopped broadcasting. - lanScanExpiry = 30 * time.Second - // lanScanStale is how long a server may go unheard before the next packet - // from it is treated as a real change worth waking the UI for. Without it - // the GUI would redraw on every duplicate broadcast. - lanScanStale = 10 * time.Second - // lanScanSweep is the expiry tick interval. - lanScanSweep = 5 * time.Second - // lanScanBuf is the per-read buffer size; LAN announcements are tiny. - lanScanBuf = 2048 - // lanScanMotdRunes caps a stored MOTD so a hostile peer cannot bloat the UI. - lanScanMotdRunes = 120 -) - -// LanServer is one Minecraft server seen broadcasting on the local network. -type LanServer struct { - Motd string // MOTD with Minecraft section-sign colour codes stripped - RawMotd string // as received - Port int // - Source netip.AddrPort // who sent the packet - Addr netip.Addr // Source.Addr(), the address to actually connect to - FirstSeen time.Time - LastSeen time.Time - Count int // packets seen - IsSelf bool // matches one of the entries tslink is advertising -} - -// lanScanKey deduplicates by sender address and advertised port. The sender's -// ephemeral source port is deliberately excluded: it changes per socket. -type lanScanKey struct { - addr netip.Addr - port int -} - -// LanScanner watches for Minecraft LAN broadcasts on every multicast-capable -// interface and keeps a deduplicated, self-expiring view of what it heard. -// -// All methods are safe for concurrent use; the GUI calls [LanScanner.Servers] -// from its frame loop while the read goroutines are writing. -type LanScanner struct { - logger *slog.Logger - - mu sync.RWMutex - servers map[lanScanKey]*LanServer - self []LanEntry - lastErr string - subs map[int]chan struct{} - nextSub int - - started bool - // live counts read loops still running. A VPN or virtual adapter going - // down kills its socket's loop; when the last one dies the scanner is - // deaf, and Err() has to say so instead of continuing to report health. - live int -} - -// NewLanScanner returns a scanner that has not started listening yet. A nil -// logger falls back to slog.Default. -func NewLanScanner(logger *slog.Logger) *LanScanner { - if logger == nil { - logger = slog.Default() - } - return &LanScanner{ - logger: logger.With(slog.String("from", "lanscan")), - servers: make(map[lanScanKey]*LanServer), - subs: make(map[int]chan struct{}), - } -} - -// SetSelfEntries tells the scanner which advertisements are our own, so the UI -// can distinguish "the tunnel is working" from "someone else is hosting". It -// may be called after Start and re-evaluates already-known servers. -func (s *LanScanner) SetSelfEntries(entries []LanEntry) { - cp := make([]LanEntry, len(entries)) - copy(cp, entries) - - s.mu.Lock() - s.self = cp - changed := false - for _, srv := range s.servers { - self := matchesSelf(cp, srv.RawMotd, srv.Port) - if self != srv.IsSelf { - srv.IsSelf = self - changed = true - } - } - if changed { - s.notifyLocked() - } - s.mu.Unlock() -} - -// Start begins listening; it returns immediately and stops when ctx is done. -// Calling it twice is a no-op. -func (s *LanScanner) Start(ctx context.Context) { - s.mu.Lock() - if s.started { - s.mu.Unlock() - return - } - s.started = true - s.mu.Unlock() - - conns := s.listen() - if len(conns) == 0 { - s.mu.Lock() - s.lastErr = "no multicast listener could be created" - // Clear the guard so a caller that notices Err() can retry once the - // network stack is up. Binding can fail simply because Start ran - // before the interfaces existed, and a permanently dead scanner is a - // worse outcome than a redundant retry. - s.started = false - s.mu.Unlock() - s.logger.Warn("lan scan disabled, all multicast binds failed") - return - } - s.logger.With(slog.Int("sockets", len(conns))).Debug("lan scan listening") - - // One closer goroutine unblocks every read at once on cancellation. - go func() { - <-ctx.Done() - for _, c := range conns { - _ = c.Close() - } - }() - - s.mu.Lock() - s.live = len(conns) - s.mu.Unlock() - - var wg sync.WaitGroup - for _, c := range conns { - wg.Add(1) - go func(c *net.UDPConn) { - defer wg.Done() - defer s.readerExited(ctx) - s.readLoop(ctx, c) - }(c) - } - go s.sweepLoop(ctx) - go func() { - wg.Wait() - s.logger.Debug("lan scan stopped") - }() -} - -// listen joins the IPv4 group on every up, multicast-capable interface plus a -// nil-interface fallback, then does the same for IPv6. Per-interface failures -// are expected (containers, down VPN adapters) and only logged at debug level. -func (s *LanScanner) listen() []*net.UDPConn { - var conns []*net.UDPConn - - v4, err := net.ResolveUDPAddr("udp4", lanScanGroupV4) - if err != nil { - s.logger.With(slog.String("error", err.Error())).Error("failed to resolve ipv4 multicast group") - } - v6, err := net.ResolveUDPAddr("udp6", lanScanGroupV6) - if err != nil { - s.logger.With(slog.String("error", err.Error())).Debug("failed to resolve ipv6 multicast group") - } - - ifaces, err := net.Interfaces() - if err != nil { - s.logger.With(slog.String("error", err.Error())).Warn("failed to enumerate interfaces, falling back to default") - ifaces = nil - } - - for i := range ifaces { - ifi := ifaces[i] - if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 { - continue - } - if v4 != nil { - if c, err := net.ListenMulticastUDP("udp4", &ifi, v4); err == nil { - conns = append(conns, c) - } else { - s.logger.With( - slog.String("iface", ifi.Name), - slog.String("error", err.Error()), - ).Debug("ipv4 multicast join failed") - } - } - if v6 != nil { - if c, err := net.ListenMulticastUDP("udp6", &ifi, v6); err == nil { - conns = append(conns, c) - } else { - s.logger.With( - slog.String("iface", ifi.Name), - slog.String("error", err.Error()), - ).Debug("ipv6 multicast join failed") - } - } - } - - // Fallback: let the OS pick the interface. On some hosts this is the only - // socket that ever receives anything. - if v4 != nil { - if c, err := net.ListenMulticastUDP("udp4", nil, v4); err == nil { - conns = append(conns, c) - } else { - s.logger.With(slog.String("error", err.Error())).Debug("default ipv4 multicast join failed") - } - } - if v6 != nil { - if c, err := net.ListenMulticastUDP("udp6", nil, v6); err == nil { - conns = append(conns, c) - } else { - s.logger.With(slog.String("error", err.Error())).Debug("default ipv6 multicast join failed") - } - } - - for _, c := range conns { - _ = c.SetReadBuffer(64 * 1024) - } - return conns -} - -// readLoop drains one socket until ctx is done or the socket is closed. A -// malformed packet is logged at debug level and never terminates the loop. -func (s *LanScanner) readLoop(ctx context.Context, c *net.UDPConn) { - buf := make([]byte, lanScanBuf) - for { - if ctx.Err() != nil { - return - } - // A deadline guarantees the loop notices cancellation even if the - // closer goroutine has not run yet. - _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) - n, src, err := c.ReadFromUDP(buf) - if err != nil { - if errors.Is(err, context.Canceled) || ctx.Err() != nil { - return - } - var nerr net.Error - if errors.As(err, &nerr) && nerr.Timeout() { - continue - } - if errors.Is(err, net.ErrClosed) { - return - } - // Anything else (ENETDOWN from an adapter disappearing, for - // instance) means this socket is finished. Release it here rather - // than leaving the fd until the process exits; the ctx closer - // goroutine would otherwise be the only thing that ever closes it. - s.logger.With(slog.String("error", err.Error())).Debug("lan scan read failed") - _ = c.Close() - return - } - if n <= 0 || src == nil { - continue - } - ap, ok := netip.AddrFromSlice(src.IP) - if !ok { - continue - } - s.handle(netip.AddrPortFrom(ap.Unmap(), uint16(src.Port)), string(buf[:n])) - } -} - -// readerExited records that one read loop finished. Once every socket is gone -// while the scanner is still meant to be running, Err() must report it — the -// UI otherwise shows a healthy "listening" chip over a scanner that will never -// hear another packet. -func (s *LanScanner) readerExited(ctx context.Context) { - s.mu.Lock() - if s.live > 0 { - s.live-- - } - dead := s.live == 0 && ctx.Err() == nil - if dead { - s.lastErr = "all multicast listeners stopped, restart to rescan" - s.started = false - s.notifyLocked() - } - s.mu.Unlock() - if dead { - s.logger.Warn("lan scan has no live listeners left") - } -} - -// sweepLoop expires servers that stopped broadcasting. -func (s *LanScanner) sweepLoop(ctx context.Context) { - t := time.NewTicker(lanScanSweep) - defer t.Stop() - for { - select { - case <-ctx.Done(): - return - case <-t.C: - s.expire(time.Now()) - } - } -} - -func (s *LanScanner) expire(now time.Time) { - s.mu.Lock() - changed := false - for k, srv := range s.servers { - if now.Sub(srv.LastSeen) > lanScanExpiry { - delete(s.servers, k) - changed = true - s.logger.With( - slog.String("addr", srv.Addr.String()), - slog.Int("port", srv.Port), - ).Debug("lan server expired") - } - } - if changed { - s.notifyLocked() - } - s.mu.Unlock() -} - -// handle records one parsed announcement. -func (s *LanScanner) handle(src netip.AddrPort, payload string) { - rawMotd, port, ok := parseLanAnnouncement(payload) - if !ok { - s.logger.With( - slog.String("src", src.String()), - slog.Int("len", len(payload)), - ).Debug("ignoring malformed lan announcement") - return - } - - now := time.Now() - key := lanScanKey{addr: src.Addr(), port: port} - - s.mu.Lock() - defer s.mu.Unlock() - - self := matchesSelf(s.self, rawMotd, port) - if srv, ok := s.servers[key]; ok { - // A repeat. Only wake the UI when something it renders actually moved. - changed := srv.IsSelf != self || srv.RawMotd != rawMotd || - now.Sub(srv.LastSeen) > lanScanStale - srv.LastSeen = now - srv.Count++ - srv.RawMotd = rawMotd - srv.Motd = cleanLanMotd(rawMotd) - srv.IsSelf = self - srv.Source = src - if changed { - s.notifyLocked() - } - return - } - - s.servers[key] = &LanServer{ - Motd: cleanLanMotd(rawMotd), - RawMotd: rawMotd, - Port: port, - Source: src, - Addr: src.Addr(), - FirstSeen: now, - LastSeen: now, - Count: 1, - IsSelf: self, - } - s.logger.With( - slog.String("addr", src.Addr().String()), - slog.Int("port", port), - slog.Bool("self", self), - ).Debug("new lan server") - s.notifyLocked() -} - -// Servers returns the currently-known servers, freshest first, safe to call -// from the UI. The result is a copy: LanServer holds no reference types, so -// the caller may read it without holding any lock. -func (s *LanScanner) Servers() []LanServer { - s.mu.RLock() - out := make([]LanServer, 0, len(s.servers)) - for _, srv := range s.servers { - out = append(out, *srv) - } - s.mu.RUnlock() - - // Deterministic ordering keeps the GUI from jittering between refreshes: - // our own advertisements sink to the bottom, then freshest first. - sort.SliceStable(out, func(i, j int) bool { - a, b := out[i], out[j] - if a.IsSelf != b.IsSelf { - return !a.IsSelf - } - if !a.LastSeen.Equal(b.LastSeen) { - return a.LastSeen.After(b.LastSeen) - } - if a.Port != b.Port { - return a.Port < b.Port - } - return a.Source.String() < b.Source.String() - }) - return out -} - -// Err returns the last listener error, if the scanner could not bind at all. -// It is empty while the scanner is healthy. -func (s *LanScanner) Err() string { - s.mu.RLock() - defer s.mu.RUnlock() - return s.lastErr -} - -// Subscribe returns a channel that receives a value whenever the server set -// meaningfully changes, plus a function that cancels the subscription. The -// channel is buffered and coalescing: a slow reader sees one wakeup, not a -// backlog of duplicate broadcasts. -func (s *LanScanner) Subscribe() (<-chan struct{}, func()) { - ch := make(chan struct{}, 1) - s.mu.Lock() - id := s.nextSub - s.nextSub++ - s.subs[id] = ch - s.mu.Unlock() - - var once sync.Once - cancel := func() { - once.Do(func() { - s.mu.Lock() - delete(s.subs, id) - s.mu.Unlock() - }) - } - return ch, cancel -} - -// notifyLocked wakes every subscriber. The caller must hold s.mu. -func (s *LanScanner) notifyLocked() { - for _, ch := range s.subs { - select { - case ch <- struct{}{}: - default: // subscriber has a pending wakeup already - } - } -} - -// --------------------------------------------------------------------------- -// parsing -// --------------------------------------------------------------------------- - -// parseLanAnnouncement extracts the MOTD and port from a Minecraft LAN -// broadcast. It is strict: anything not shaped exactly like -// "[MOTD]…[/MOTD][AD]<1..65535>[/AD]" is rejected. -func parseLanAnnouncement(payload string) (motd string, port int, ok bool) { - motd, ok = between(payload, "[MOTD]", "[/MOTD]") - if !ok { - return "", 0, false - } - ad, ok := between(payload, "[AD]", "[/AD]") - if !ok { - return "", 0, false - } - port, err := strconv.Atoi(strings.TrimSpace(ad)) - if err != nil || !validPort(port) { - return "", 0, false - } - return motd, port, true -} - -// between returns the text enclosed by the first open tag and the first close -// tag that follows it. -func between(s, openTag, closeTag string) (string, bool) { - i := strings.Index(s, openTag) - if i < 0 { - return "", false - } - rest := s[i+len(openTag):] - j := strings.Index(rest, closeTag) - if j < 0 { - return "", false - } - return rest[:j], true -} - -// cleanLanMotd strips Minecraft section-sign colour codes, trims whitespace and -// caps the result so an oversized announcement cannot distort the UI. -func cleanLanMotd(raw string) string { - var b strings.Builder - b.Grow(len(raw)) - skip := false - for _, r := range raw { - if skip { - // Drop the single formatting character following the section sign. - skip = false - continue - } - if r == '§' { - skip = true - continue - } - b.WriteRune(r) - } - out := strings.TrimSpace(b.String()) - - n := 0 - for i := range out { - n++ - if n > lanScanMotdRunes { - return out[:i] - } - } - return out -} - -// matchesSelf reports whether an announcement corresponds to one of our own -// advertised entries. Comparison uses the raw MOTD, which is exactly what -// core/lan.go puts on the wire. -func matchesSelf(self []LanEntry, rawMotd string, port int) bool { - for _, e := range self { - if e.Port == port && e.Motd == rawMotd { - return true - } - } - return false -} diff --git a/core/peermon.go b/core/peermon.go index 5f85e7c..80b24d9 100644 --- a/core/peermon.go +++ b/core/peermon.go @@ -154,6 +154,7 @@ type PeerMonitor struct { refreshStatus chan struct{} refreshPing chan struct{} + refreshLinks chan struct{} mu sync.RWMutex raw *ipnstate.Status // last good status, nil until the first poll lands @@ -180,6 +181,7 @@ func NewPeerMonitor(srv *tsnet.Server, rules map[string][]ConnectRule, logger *s opt: opt.withDefaults(), refreshStatus: make(chan struct{}, 1), refreshPing: make(chan struct{}, 1), + refreshLinks: make(chan struct{}, 1), hist: make(map[string][]PeerSample), last: make(map[string]pingOutcome), links: make(map[netip.Addr][]string), @@ -196,9 +198,14 @@ func (m *PeerMonitor) Start(ctx context.Context) { } // RefreshNow triggers an immediate status+ping cycle without blocking the caller. +// +// Link resolution is kicked too. It normally runs every linkResolveInterval, +// but the GUI now lists only linked peers, so a user staring at an empty page +// after a DNS hiccup has no other way to ask for a retry. func (m *PeerMonitor) RefreshNow() { kick(m.refreshStatus) kick(m.refreshPing) + kick(m.refreshLinks) } // kick delivers a coalescing wakeup: a pending signal is enough. @@ -534,6 +541,8 @@ func (m *PeerMonitor) linkLoop(ctx context.Context) { return case <-ticker.C: m.resolveLinks(ctx) + case <-m.refreshLinks: + m.resolveLinks(ctx) } } } diff --git a/core/supervisor.go b/core/supervisor.go index f42f592..4aa5ac2 100644 --- a/core/supervisor.go +++ b/core/supervisor.go @@ -96,7 +96,6 @@ type State struct { Config *Config Server *tsnet.Server Peers *PeerMonitor - Lan *LanScanner } // Ready reports whether the service finished booting. @@ -414,13 +413,8 @@ func (s *Supervisor) boot(ctx context.Context) error { peers := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{}) peers.Start(ctx) - lan := NewLanScanner(s.logger.With("from", "lan_scan")) - lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect)) - lan.Start(ctx) - s.update(func(st *State) { st.Peers = peers - st.Lan = lan }) s.stepDone(StepKeyMonitors, nil) @@ -457,7 +451,6 @@ func (s *Supervisor) teardown() { srv := s.state.Server s.state.Server = nil s.state.Peers = nil - s.state.Lan = nil s.mu.Unlock() if srv != nil { diff --git a/gui/app.go b/gui/app.go index c5a2a86..120fafe 100644 --- a/gui/app.go +++ b/gui/app.go @@ -12,12 +12,12 @@ import ( "gioui.org/app" "gioui.org/font" "gioui.org/io/clipboard" + "gioui.org/io/system" "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" "gioui.org/op/paint" "gioui.org/text" - "gioui.org/unit" "gioui.org/widget" "tslink/core" @@ -43,7 +43,6 @@ type pageID int const ( pageOverview pageID = iota pagePeers - pageLan pageDiag pageLogs pageSettings @@ -71,16 +70,21 @@ type App struct { overview *overviewPage peers *peersPage - lan *lanPage diag *diagPage logs *logsPage settings *settingsPage - splash *splashView - overlay *logOverlay + splash *splashView - overlayBtn widget.Clickable - themeBtn widget.Clickable + themeBtn widget.Clickable + + // grown records that the window has already been resized from its compact + // splash dimensions to the full shell. The splash sizes the window to just + // its progress bar and checklist, so the transition to the shell has to grow + // it — exactly once, or a user who resized the window would have it snapped + // back every time the service restarted. Atomic because the resize runs on + // the watch goroutine, not the UI one. + grown atomic.Bool toastMsg string toastLevel StatusLevel @@ -121,19 +125,16 @@ func New(opt Options) *App { a.nav = []navEntry{ {id: pageOverview, label: KNavOverview, icon: IconGrid}, {id: pagePeers, label: KNavPeers, icon: IconNodes}, - {id: pageLan, label: KNavLan, icon: IconBroadcast}, {id: pageDiag, label: KNavDiag, icon: IconPulse}, {id: pageLogs, label: KNavLogs, icon: IconList}, {id: pageSettings, label: KNavSettings, icon: IconSliders}, } a.overview = newOverviewPage() a.peers = newPeersPage() - a.lan = newLanPage() a.diag = newDiagPage(a) a.logs = newLogsPage(a) a.settings = newSettingsPage(a) a.splash = newSplashView() - a.overlay = newLogOverlay() return a } @@ -141,15 +142,24 @@ func New(opt Options) *App { // closes. func (a *App) Run(ctx context.Context) error { w := new(app.Window) + // Opens at splash size; grown to the shell dimensions below once the + // service is ready. w.Option( app.Title("tslink"), - app.Size(unit.Dp(1120), unit.Dp(740)), - app.MinSize(unit.Dp(880), unit.Dp(560)), + app.Size(splashWindowW, splashWindowH), + app.MinSize(splashMinW, splashMinH), ) a.win = w go a.watch(ctx, w) go a.upgradeFonts() + // Ctrl+C at the terminal cancels ctx. Without this the supervisor tears + // down but the window survives, dropping the user back to the splash — the + // GUI is the process, so cancelling it has to close the window too. + go func() { + <-ctx.Done() + w.Perform(system.ActionClose) + }() var ops op.Ops for { @@ -161,10 +171,34 @@ func (a *App) Run(ctx context.Context) error { a.applyFontUpgrade() a.layout(gtx) e.Frame(gtx.Ops) + // After the frame, so the resize is not applied midway through + // laying one out against the old constraints. Once grown this is a + // single atomic load. + if !a.grown.Load() && a.state().Ready() { + a.grow(w) + } } } } +// grow resizes the window from its compact splash dimensions to the full shell, +// once, when the service comes up. +// +// It must run on the goroutine that drives the event loop. On Linux the driver +// executes Window.Option's work inline on the calling goroutine rather than +// handing it to a UI thread, so calling this from anywhere else would mutate +// driver state concurrently with event dispatch. +func (a *App) grow(w *app.Window) { + if a.grown.Swap(true) { + return + } + w.Option( + app.Size(shellWindowW, shellWindowH), + app.MinSize(shellMinW, shellMinH), + ) + w.Invalidate() +} + // upgradeFonts parses the system CJK font off the UI goroutine. The splash // screen exists partly to cover this: a 20 MB font collection takes long // enough to parse that doing it inline would stall the first frame. @@ -295,6 +329,18 @@ func (a *App) copyToClipboard(gtx C, s string, msg string) { a.notify(msg, LevelOK) } +// reveal shows path in the platform file manager, off the UI goroutine so a +// slow or missing file manager cannot stall a frame. Failure is logged rather +// than surfaced: the file is already written and its path is already on screen, +// so there is nothing for the user to act on. +func (a *App) reveal(path string) { + go func() { + if err := RevealInFileManager(path, a.logger); err != nil { + a.logger.Warn("could not open the file manager", "path", path, "err", err) + } + }() +} + // notify shows a transient message at the bottom of the window. func (a *App) notify(msg string, level StatusLevel) { a.toastMsg = msg @@ -325,9 +371,6 @@ func (a *App) layout(gtx C) D { a.current = a.nav[i].id } } - if a.overlayBtn.Clicked(gtx) { - a.overlay.visible = !a.overlay.visible - } if a.themeBtn.Clicked(gtx) { th.SetDark(!th.Dark) } @@ -341,10 +384,6 @@ func (a *App) layout(gtx C) D { } return a.shell(gtx, st) }), - layout.Stacked(func(gtx C) D { - gtx.Constraints.Min = gtx.Constraints.Max - return a.overlay.Layout(a, gtx, !st.Ready()) - }), layout.Stacked(func(gtx C) D { gtx.Constraints.Min = gtx.Constraints.Max return a.layoutToast(gtx) @@ -379,8 +418,6 @@ func (a *App) page(gtx C, st core.State) D { switch a.current { case pagePeers: return a.peers.Layout(a, gtx, st) - case pageLan: - return a.lan.Layout(a, gtx, st) case pageDiag: return a.diag.Layout(a, gtx, st) case pageLogs: @@ -536,14 +573,6 @@ func (a *App) header(gtx C, st core.State) D { }), layout.Rigid(func(gtx C) D { return a.statusPill(gtx, st) }), HGap(SpaceSM), - layout.Rigid(func(gtx C) D { - icon := IconList - level := LevelNeutral - if a.overlay.visible { - level = LevelInfo - } - return th.IconButton(gtx, &a.overlayBtn, icon, level) - }), layout.Rigid(func(gtx C) D { return th.IconButton(gtx, &a.themeBtn, IconGlobe, LevelNeutral) }), @@ -675,3 +704,38 @@ func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget ) }) } + +// diagnosticHeader is the metadata block prepended to any exported log bundle, +// so a paste is self-describing without the reporter having to explain their +// setup. +func (a *App) diagnosticHeader() string { + st := a.state() + var b strings.Builder + b.WriteString("# tslink diagnostic bundle\n") + b.WriteString("# version: " + a.opt.Version + "\n") + b.WriteString("# os/arch: " + runtimeInfo() + "\n") + if a.opt.ConfigURL != "" { + b.WriteString("# config: (url)\n") + } else if a.opt.ConfigPath != "" { + b.WriteString("# config: " + a.opt.ConfigPath + "\n") + } + b.WriteString("# phase: " + st.Phase.String() + "\n") + b.WriteString("# restarts: " + itoa(st.Restarts) + "\n") + if !st.ReadyAt.IsZero() { + b.WriteString("# uptime: " + FormatDuration(timeSince(st.ReadyAt)) + "\n") + } + if st.Peers != nil { + snap := st.Peers.Snapshot() + b.WriteString("# tailnet: " + snap.TailnetName + "\n") + b.WriteString("# peers: " + itoa(len(snap.Peers)) + "\n") + } + // reportText takes the diag page's lock; reading a.diag.report directly + // would race the background diagnostic goroutine. + if a.diag != nil { + if txt := a.diag.reportText(); txt != "" { + b.WriteString("#\n") + b.WriteString(txt) + } + } + return b.String() +} diff --git a/gui/chart.go b/gui/chart.go index 06ac1fb..66978fe 100644 --- a/gui/chart.go +++ b/gui/chart.go @@ -40,9 +40,12 @@ type ChartSeries struct { // ChartStyle configures the plot. type ChartStyle struct { Height unit.Dp - // Window is how far back the x axis reaches. - Window time.Duration - // Now anchors the right edge. + // MaxWindow caps how far back the x axis reaches. The axis is scaled to the + // data's own extent and only clamped by this, so the plot fills its width + // from the second sample onward instead of leaving the first N minutes of + // the window blank while history accumulates. + MaxWindow time.Duration + // Now is the wall clock, used only as a fallback when there is no data. Now time.Time // Unit labels the y axis. Unit string @@ -57,18 +60,68 @@ type Chart struct { hovering bool // plot is the last plotted rectangle, used to map hover x back to a time. plot image.Rectangle + // tMin/tMax are the x domain resolved by the last Layout. HoverIndex maps + // the pointer through these rather than recomputing from ChartStyle, so the + // crosshair cannot disagree with the drawn line. + tMin, tMax time.Time +} + +// minPlotSpan keeps the axis sane when every visible sample shares a timestamp, +// which happens on the very first frame after a refresh. +const minPlotSpan = 10 * time.Second + +// domain resolves the x axis from the visible data, clamped to st.MaxWindow. +func domain(series []ChartSeries, st ChartStyle) (tMin, tMax time.Time) { + now := st.Now + if now.IsZero() { + now = time.Now() + } + window := st.MaxWindow + if window <= 0 { + window = 3 * time.Minute + } + + var first, last time.Time + for _, s := range series { + if s.Hidden { + continue + } + for _, p := range s.Points { + if first.IsZero() || p.At.Before(first) { + first = p.At + } + if last.IsZero() || p.At.After(last) { + last = p.At + } + } + } + if first.IsZero() { + return now.Add(-window), now + } + // Never show more than the window, however much history is retained. + if last.Sub(first) > window { + first = last.Add(-window) + } + if last.Sub(first) < minPlotSpan { + first = last.Add(-minPlotSpan) + } + return first, last } // HoverIndex returns the sample index the pointer is nearest within s, or -1. -func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int { +func (c *Chart) HoverIndex(series ChartSeries) int { if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 { return -1 } + span := c.tMax.Sub(c.tMin) + if span <= 0 { + return -1 + } frac := float64(c.hover.X-float32(c.plot.Min.X)) / float64(c.plot.Dx()) if frac < 0 || frac > 1 { return -1 } - target := st.Now.Add(-st.Window).Add(time.Duration(frac * float64(st.Window))) + target := c.tMin.Add(time.Duration(frac * float64(span))) best, bestDelta := -1, time.Duration(math.MaxInt64) for i, p := range series.Points { d := p.At.Sub(target) @@ -81,7 +134,7 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int { } // Only report a match when the nearest sample is genuinely close, so the // crosshair does not snap to a distant point in a sparse series. - if bestDelta > st.Window/20 { + if bestDelta > span/20 { return -1 } return best @@ -89,9 +142,6 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int { // Layout draws the chart. func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D { - if st.Window <= 0 { - st.Window = 20 * time.Minute - } if st.Now.IsZero() { st.Now = gtx.Now } @@ -114,16 +164,16 @@ func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D { c.update(gtx, size) yMax := niceMax(maxVisible(series)) - tMin := st.Now.Add(-st.Window) + c.tMin, c.tMax = domain(series, st) - c.drawGrid(t, gtx, plot, yMax, st) + c.drawGrid(t, gtx, plot, yMax, c.tMax.Sub(c.tMin)) for _, s := range series { if s.Hidden || len(s.Points) == 0 { continue } - c.drawSeries(t, gtx, plot, s, tMin, st.Now, yMax, st.FillSingle && visibleCount(series) == 1) + c.drawSeries(t, gtx, plot, s, c.tMin, c.tMax, yMax, st.FillSingle && visibleCount(series) == 1) } - c.drawCrosshair(t, gtx, plot, series, st, tMin, yMax) + c.drawCrosshair(t, gtx, plot, series, yMax) return D{Size: size} } @@ -199,7 +249,7 @@ func niceMax(v float64) float64 { } } -func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st ChartStyle) { +func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, span time.Duration) { const rows = 4 lineCol := WithAlpha(t.P.Border, 0.9) for i := 0; i <= rows; i++ { @@ -226,8 +276,8 @@ func (c *Chart) drawGrid(t *Theme, gtx C, plot image.Rectangle, yMax float64, st frac float64 txt string }{ - {0, "-" + FormatDuration(st.Window)}, - {0.5, "-" + FormatDuration(st.Window/2)}, + {0, "-" + FormatDuration(span)}, + {0.5, "-" + FormatDuration(span/2)}, {1, "now"}, } if t.Lang == LangZH { @@ -354,7 +404,7 @@ func (c *Chart) drawSeries(t *Theme, gtx C, plot image.Rectangle, s ChartSeries, } } -func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, st ChartStyle, tMin time.Time, yMax float64) { +func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []ChartSeries, yMax float64) { if !c.hovering { return } @@ -371,11 +421,11 @@ func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []Ch if s.Hidden { continue } - i := c.HoverIndex(s, st) + i := c.HoverIndex(s) if i < 0 || !s.Points[i].OK { continue } - pt := pos(plot, tMin, st.Now, yMax, s.Points[i]) + pt := pos(plot, c.tMin, c.tMax, yMax, s.Points[i]) d := gtx.Dp(7) off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops) Circle(gtx, d, s.Color) @@ -402,15 +452,20 @@ type LegendEntry struct { // Legend renders the chart legend as a wrapping row of toggles. The caller // supplies a clickable per entry so hiding a noisy peer is one click away. +// +// Wrapping matters here: with the eight series the chart allows, the chips are +// far wider than the card, and a plain Flex would silently clip the trailing +// ones — the peers you could no longer toggle were exactly the ones you could +// no longer identify. func (t *Theme) Legend(gtx C, entries []LegendEntry, click func(i int) layout.Widget) D { if len(entries) == 0 { return D{} } - children := make([]layout.FlexChild, 0, len(entries)) + children := make([]layout.Widget, 0, len(entries)) for i := range entries { - children = append(children, layout.Rigid(click(i))) + children = append(children, click(i)) } - return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...) + return WrapRow(gtx, 0, children) } // LegendChip draws one legend entry. @@ -434,7 +489,9 @@ func (t *Theme) LegendChip(gtx C, e LegendEntry, hovered bool) D { return D{Size: image.Pt(w, h)} }) }), - layout.Rigid(OneLine(t.Text(SizeCaption, fg, e.Name)).Layout), + // Bounded: peer names can be long, and one runaway chip would push + // every following one onto its own line. + layout.Rigid(OneLine(t.Text(SizeCaption, fg, Truncate(e.Name, 22))).Layout), layout.Rigid(func(gtx C) D { if e.Value == "" { return D{} diff --git a/gui/chart_test.go b/gui/chart_test.go new file mode 100644 index 0000000..6fa52ca --- /dev/null +++ b/gui/chart_test.go @@ -0,0 +1,149 @@ +package gui + +import ( + "image" + "testing" + "time" + + "gioui.org/layout" + + "tslink/netdiag" +) + +// TestChartDomainFillsWithSparseData is the regression for the blank-chart bug: +// a handful of samples used to occupy the left 3% of a fixed 20-minute axis. +// The domain must track the data, not the clock. +func TestChartDomainFillsWithSparseData(t *testing.T) { + now := time.Now() + // 30 seconds of uptime at the 10s ping interval. + pts := []ChartPoint{ + {At: now.Add(-20 * time.Second), Value: 10, OK: true}, + {At: now.Add(-10 * time.Second), Value: 12, OK: true}, + {At: now, Value: 11, OK: true}, + } + series := []ChartSeries{{Points: pts}} + st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now} + + tMin, tMax := domain(series, st) + if got := tMax.Sub(tMin); got != 20*time.Second { + t.Fatalf("span = %v, want the data's own 20s extent", got) + } + if !tMin.Equal(pts[0].At) || !tMax.Equal(pts[2].At) { + t.Errorf("domain = [%v, %v], want the first and last sample", tMin, tMax) + } +} + +func TestChartDomainClampsToWindow(t *testing.T) { + now := time.Now() + series := []ChartSeries{{Points: []ChartPoint{ + {At: now.Add(-30 * time.Minute), Value: 10, OK: true}, + {At: now, Value: 11, OK: true}, + }}} + st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now} + + tMin, tMax := domain(series, st) + if got := tMax.Sub(tMin); got != 3*time.Minute { + t.Fatalf("span = %v, want it clamped to MaxWindow", got) + } +} + +func TestChartDomainEdgeCases(t *testing.T) { + now := time.Now() + st := ChartStyle{MaxWindow: 3 * time.Minute, Now: now} + + // No data at all: fall back to the full window so the grid still renders. + tMin, tMax := domain(nil, st) + if got := tMax.Sub(tMin); got != 3*time.Minute { + t.Errorf("empty span = %v, want the full window", got) + } + + // One sample would otherwise give a zero-width axis and divide by zero. + one := []ChartSeries{{Points: []ChartPoint{{At: now, Value: 5, OK: true}}}} + tMin, tMax = domain(one, st) + if got := tMax.Sub(tMin); got != minPlotSpan { + t.Errorf("single-point span = %v, want minPlotSpan", got) + } + + // Hidden series must not widen the axis. + mixed := []ChartSeries{ + {Hidden: true, Points: []ChartPoint{{At: now.Add(-2 * time.Minute), Value: 1, OK: true}}}, + {Points: []ChartPoint{ + {At: now.Add(-30 * time.Second), Value: 1, OK: true}, + {At: now, Value: 2, OK: true}, + }}, + } + tMin, tMax = domain(mixed, st) + if got := tMax.Sub(tMin); got != 30*time.Second { + t.Errorf("span = %v, want only the visible series to count", got) + } +} + +// TestWrapRowWraps checks that children exceeding the width land on new lines +// instead of being clipped, which is what a plain Flex did. +func TestWrapRowWraps(t *testing.T) { + const ( + childW = 100 + childH = 20 + rowW = 250 // fits 2 children per line + n = 5 + ) + child := func(gtx C) D { return D{Size: image.Pt(childW, childH)} } + children := make([]layout.Widget, n) + for i := range children { + children[i] = child + } + + gtx, _ := newTestContext(image.Pt(rowW, 500)) + dims := WrapRow(gtx, 0, children) + + // 5 children, 2 per line => 3 lines. + if want := 3 * childH; dims.Size.Y != want { + t.Errorf("height = %d, want %d (3 wrapped lines)", dims.Size.Y, want) + } + if dims.Size.X != rowW { + t.Errorf("width = %d, want the full %d", dims.Size.X, rowW) + } +} + +func TestWrapRowSingleLine(t *testing.T) { + child := func(gtx C) D { return D{Size: image.Pt(50, 20)} } + gtx, _ := newTestContext(image.Pt(500, 500)) + dims := WrapRow(gtx, 0, []layout.Widget{child, child, child}) + if dims.Size.Y != 20 { + t.Errorf("height = %d, want a single 20px line", dims.Size.Y) + } +} + +func TestWrapRowEmpty(t *testing.T) { + gtx, _ := newTestContext(image.Pt(100, 100)) + if dims := WrapRow(gtx, 0, nil); dims.Size != (image.Point{}) { + t.Errorf("want zero dims for no children, got %v", dims.Size) + } +} + +// TestUDPProbeLabel covers the naming rules for the UDP table: prefer the +// configured hostname over the resolved address, and keep the two rows of a +// dual-stack server distinguishable. +func TestUDPProbeLabel(t *testing.T) { + cases := []struct { + name string + in netdiag.UDPProbe + want string + }{ + {"resolved v4", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米"}, + "小米 stun.miwifi.com:3478 · IPv4"}, + {"resolved v6", netdiag.UDPProbe{Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米"}, + "小米 stun.miwifi.com:3478 · IPv6"}, + {"dns failure keeps the hostname", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478", Name: "X"}, + "X a.example:3478"}, + {"no name", netdiag.UDPProbe{Host: "a.example:3478", Target: "a.example:3478"}, "a.example:3478"}, + {"no host falls back to target", netdiag.UDPProbe{Target: "1.2.3.4:3478"}, "1.2.3.4:3478 · IPv4"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := udpProbeLabel(tc.in); got != tc.want { + t.Errorf("udpProbeLabel() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/gui/fontload.go b/gui/fontload.go index bb789d5..3f18c47 100644 --- a/gui/fontload.go +++ b/gui/fontload.go @@ -221,19 +221,74 @@ func LoadCJKFaces(path string, logger *slog.Logger) ([]font.FontFace, error) { if err != nil { return nil, err } - // A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping - // only the first regular-weight face avoids paying for five near-identical - // fallbacks on every glyph miss. - if len(faces) > 1 { - faces = faces[:1] + if len(faces) == 0 { + return nil, nil } + // A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping + // one avoids paying for five near-identical fallbacks on every glyph miss; + // pickCJKFace decides which one. + base := faces[pickCJKFace(faces)] + out := cjkWeightVariants(base) logger.Debug("cjk font loaded", "path", path, - "faces", len(faces), + "typeface", string(base.Font.Typeface), + "faces", len(out), "bytes", st.Size(), "took", time.Since(start).Round(time.Millisecond), ) - return faces, nil + return out, nil +} + +// scWeights are the family-name markers of the Simplified Chinese cut, in +// preference order. Pan-CJK collections order their faces JP first, so taking +// faces[0] blindly renders Han characters with Japanese glyph variants — legible, +// but visibly wrong to a Chinese reader. +var scMarkers = []string{"sc", "simplified", "cn", "hans"} + +// pickCJKFace returns the index of the face to use, preferring the Simplified +// Chinese cut and falling back to the first face. +func pickCJKFace(faces []font.FontFace) int { + for _, marker := range scMarkers { + for i, f := range faces { + name := strings.ToLower(string(f.Font.Typeface)) + // Match on a word/suffix boundary so "sc" does not hit "Sans". + for _, field := range strings.FieldsFunc(name, func(r rune) bool { + return r == ' ' || r == '-' || r == '_' + }) { + if field == marker || strings.HasSuffix(field, marker) { + return i + } + } + } + } + return 0 +} + +// cjkWeightVariants registers one parsed face under every weight the UI asks +// for. +// +// This exists because of how Gio resolves fonts. The theme pins every label's +// Typeface to "Go" (see NewTheme), and Gio never tells go-text which script it +// is shaping, so our explicitly-loaded CJK font is only reachable through +// fontscan's user-provided tier — which prunes candidates by weight before +// checking coverage. A face registered only at Normal is therefore invisible to +// any label that sets Font.Weight, and every section title, card header and +// button does exactly that. The result was Chinese body text rendering fine +// while every heading turned into tofu boxes. +// +// The variants share the same underlying Face, so CJK headings are not visually +// bolder than body text. That is a deliberate trade: identical weight beats +// missing glyphs, and synthetic emboldening is not available here. +func cjkWeightVariants(base font.FontFace) []font.FontFace { + weights := []font.Weight{font.Normal, font.Medium, font.SemiBold, font.Bold} + out := make([]font.FontFace, 0, len(weights)) + for _, w := range weights { + f := base.Font + f.Weight = w + f.Style = font.Regular + out = append(out, font.FontFace{Font: f, Face: base.Face}) + } + return out } // goCollection returns the built-in Go font faces. It exists so tests can diff --git a/gui/i18n.go b/gui/i18n.go index dea8393..bd8d91b 100644 --- a/gui/i18n.go +++ b/gui/i18n.go @@ -28,7 +28,6 @@ const ( // Navigation. KNavOverview KNavPeers - KNavLan KNavDiag KNavLogs KNavSettings @@ -51,7 +50,8 @@ const ( KStepMonitors KStepReady KSplashHint - KSplashLogHint + KSplashStuckHint + KSplashExportLog KSplashRetry // Shared vocabulary. @@ -84,7 +84,6 @@ const ( KOvTailnet KOvSelf KOvPeersOnline - KOvLanServers KOvForwardRules KOvConnectRules KOvUptime @@ -95,8 +94,8 @@ const ( // Peers page. KPeersTitle KPeersLinked - KPeersOther KPeersEmpty + KPeersResolving KPeerLatency KPeerRoute KPeerRouteDirect @@ -123,19 +122,11 @@ const ( KGraphWindow KGraphLegendHint - // LAN page. - KLanTitle - KLanSubtitle - KLanEmpty - KLanListening - KLanMotd - KLanPort - KLanAddress - KLanSeen - KLanSelf - KLanSelfHint - KLanPackets - KLanBindError + // Local services (overview). + KSvcTitle + KSvcSubtitle + KSvcEmpty + KSvcBroadcast // Diagnostics page. KDiagTitle @@ -174,6 +165,7 @@ const ( KDiagEgressGeo KDiagEgressDivergent KDiagEgressDivergentHint + KDiagEgressDivergentHTTP KDiagGeoSkipped KDiagPreferredDERP KDiagDerpLatency @@ -211,7 +203,6 @@ const ( KLogsShown KLogsDropped KLogsIncludeDiag - KLogsOpenOverlay // Settings. KSetTheme @@ -233,7 +224,6 @@ var zhStrings = [kCount]string{ KNavOverview: "概览", KNavPeers: "节点", - KNavLan: "局域网", KNavDiag: "网络诊断", KNavLogs: "日志", KNavSettings: "设置", @@ -246,16 +236,17 @@ var zhStrings = [kCount]string{ KStateError: "出错", KStateRetrying: "正在重试", - KStepConfig: "读取配置", - KStepFonts: "加载字体", - KStepTsnet: "接入 Tailscale 网络", - KStepRules: "解析转发规则", - KStepDiscovery: "启动局域网发现", - KStepMonitors: "启动状态监控", - KStepReady: "准备就绪", - KSplashHint: "首次接入 Tailscale 可能需要十几秒", - KSplashLogHint: "实时日志(截图时可一并保留)", - KSplashRetry: "启动失败,正在重试", + KStepConfig: "读取配置", + KStepFonts: "加载字体", + KStepTsnet: "接入 Tailscale 网络", + KStepRules: "解析转发规则", + KStepDiscovery: "启动局域网发现", + KStepMonitors: "启动状态监控", + KStepReady: "准备就绪", + KSplashHint: "首次接入 Tailscale 可能需要十几秒", + KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查", + KSplashExportLog: "导出日志", + KSplashRetry: "启动失败,正在重试", KYes: "是", KNo: "否", @@ -285,7 +276,6 @@ var zhStrings = [kCount]string{ KOvTailnet: "Tailnet", KOvSelf: "本机", KOvPeersOnline: "在线节点", - KOvLanServers: "局域网服务器", KOvForwardRules: "转发规则", KOvConnectRules: "连接规则", KOvUptime: "运行时长", @@ -295,8 +285,8 @@ var zhStrings = [kCount]string{ KPeersTitle: "Tailscale 节点", KPeersLinked: "已关联", - KPeersOther: "其他节点", KPeersEmpty: "暂无节点", + KPeersResolving: "正在解析配置中的节点", KPeerLatency: "延迟", KPeerRoute: "链路", KPeerRouteDirect: "直连", @@ -320,21 +310,13 @@ var zhStrings = [kCount]string{ KPeerTags: "标签", KGraphTitle: "延迟图谱", KGraphEmpty: "正在采集延迟数据", - KGraphWindow: "最近 20 分钟", + KGraphWindow: "最近", KGraphLegendHint: "点击图例可隐藏对应节点", - KLanTitle: "局域网 Minecraft 服务器", - KLanSubtitle: "监听 224.0.2.60:4445 的广播", - KLanEmpty: "未发现局域网服务器", - KLanListening: "监听中", - KLanMotd: "服务器名称", - KLanPort: "端口", - KLanAddress: "地址", - KLanSeen: "最后广播", - KLanSelf: "本机广播", - KLanSelfHint: "由 tslink 转发并广播,说明隧道已生效", - KLanPackets: "收包", - KLanBindError: "无法监听组播", + KSvcTitle: "本机服务", + KSvcSubtitle: "tslink 在本机监听并转发到对应服务器", + KSvcEmpty: "配置中没有连接规则", + KSvcBroadcast: "已广播", KDiagTitle: "网络诊断", KDiagRun: "开始诊断", @@ -371,7 +353,8 @@ var zhStrings = [kCount]string{ KDiagEgressIP: "出口 IP", KDiagEgressGeo: "归属地", KDiagEgressDivergent: "出口不一致", - KDiagEgressDivergentHint: "不同探测方式得到了不同的公网 IP,通常说明有代理或分流工具在生效", + KDiagEgressDivergentHint: "STUN(UDP)本身就看到多个公网 IP,直连打洞会受影响", + KDiagEgressDivergentHTTP: "仅 HTTP 探测看到不同的公网 IP,STUN(UDP)出口一致,通常不影响打洞", KDiagGeoSkipped: "已跳过归属地查询", KDiagPreferredDERP: "首选 DERP", KDiagDerpLatency: "DERP 延迟", @@ -407,7 +390,6 @@ var zhStrings = [kCount]string{ KLogsShown: "已显示", KLogsDropped: "条早期日志已被丢弃", KLogsIncludeDiag: "附带诊断报告", - KLogsOpenOverlay: "浮层日志", KSetTheme: "主题", KSetThemeDark: "深色", @@ -426,7 +408,6 @@ var enStrings = [kCount]string{ KNavOverview: "Overview", KNavPeers: "Peers", - KNavLan: "LAN", KNavDiag: "Diagnostics", KNavLogs: "Logs", KNavSettings: "Settings", @@ -439,16 +420,17 @@ var enStrings = [kCount]string{ KStateError: "Error", KStateRetrying: "Retrying", - KStepConfig: "Loading configuration", - KStepFonts: "Loading fonts", - KStepTsnet: "Joining the tailnet", - KStepRules: "Resolving forward rules", - KStepDiscovery: "Starting LAN discovery", - KStepMonitors: "Starting monitors", - KStepReady: "Ready", - KSplashHint: "The first tailnet join can take a dozen seconds", - KSplashLogHint: "Live log (stays visible in screenshots)", - KSplashRetry: "Startup failed, retrying", + KStepConfig: "Loading configuration", + KStepFonts: "Loading fonts", + KStepTsnet: "Joining the tailnet", + KStepRules: "Resolving forward rules", + KStepDiscovery: "Starting LAN discovery", + KStepMonitors: "Starting monitors", + KStepReady: "Ready", + KSplashHint: "The first tailnet join can take a dozen seconds", + KSplashStuckHint: "This step is taking unusually long — export the log to investigate", + KSplashExportLog: "Export log", + KSplashRetry: "Startup failed, retrying", KYes: "Yes", KNo: "No", @@ -478,7 +460,6 @@ var enStrings = [kCount]string{ KOvTailnet: "Tailnet", KOvSelf: "This node", KOvPeersOnline: "Peers online", - KOvLanServers: "LAN servers", KOvForwardRules: "Forward rules", KOvConnectRules: "Connect rules", KOvUptime: "Uptime", @@ -488,8 +469,8 @@ var enStrings = [kCount]string{ KPeersTitle: "Tailscale peers", KPeersLinked: "Linked", - KPeersOther: "Other peers", KPeersEmpty: "No peers yet", + KPeersResolving: "Resolving the peers named in the config", KPeerLatency: "Latency", KPeerRoute: "Route", KPeerRouteDirect: "Direct", @@ -513,21 +494,13 @@ var enStrings = [kCount]string{ KPeerTags: "Tags", KGraphTitle: "Latency graph", KGraphEmpty: "Collecting latency samples", - KGraphWindow: "last 20 minutes", + KGraphWindow: "last", KGraphLegendHint: "Click a legend entry to hide that peer", - KLanTitle: "Minecraft servers on the LAN", - KLanSubtitle: "Listening for broadcasts on 224.0.2.60:4445", - KLanEmpty: "No LAN servers discovered", - KLanListening: "Listening", - KLanMotd: "Name", - KLanPort: "Port", - KLanAddress: "Address", - KLanSeen: "Last broadcast", - KLanSelf: "Ours", - KLanSelfHint: "Advertised by tslink, so the tunnel is working", - KLanPackets: "packets", - KLanBindError: "Cannot join multicast group", + KSvcTitle: "Local services", + KSvcSubtitle: "Listening on this machine, forwarded to each server", + KSvcEmpty: "No connect rules configured", + KSvcBroadcast: "Broadcast", KDiagTitle: "Network diagnostics", KDiagRun: "Run diagnostics", @@ -564,7 +537,8 @@ var enStrings = [kCount]string{ KDiagEgressIP: "Egress IP", KDiagEgressGeo: "Location", KDiagEgressDivergent: "Egress mismatch", - KDiagEgressDivergentHint: "Different probes saw different public IPs, which usually means a proxy or split tunnel is active", + KDiagEgressDivergentHint: "STUN (UDP) itself saw more than one public IP, so direct connections will suffer", + KDiagEgressDivergentHTTP: "Only the HTTP probes disagreed; the STUN (UDP) egress is consistent, so hole punching is usually unaffected", KDiagGeoSkipped: "Geolocation skipped", KDiagPreferredDERP: "Preferred DERP", KDiagDerpLatency: "DERP latency", @@ -600,7 +574,6 @@ var enStrings = [kCount]string{ KLogsShown: "shown", KLogsDropped: "earlier entries were dropped", KLogsIncludeDiag: "Include diagnostics", - KLogsOpenOverlay: "Log overlay", KSetTheme: "Theme", KSetThemeDark: "Dark", diff --git a/gui/icons.go b/gui/icons.go index 3e197c0..90accd0 100644 --- a/gui/icons.go +++ b/gui/icons.go @@ -257,14 +257,6 @@ func IconWarn(gtx C, size int, col color.NRGBA) D { return d } -// IconClose dismisses an overlay. -func IconClose(gtx C, size int, col color.NRGBA) D { - return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { - line(p, pt, 0.24, 0.24, 0.76, 0.76) - line(p, pt, 0.76, 0.24, 0.24, 0.76) - }) -} - // IconChevronRight indicates an expandable row. func IconChevronRight(gtx C, size int, col color.NRGBA) D { return iconCanvas(gtx, size, col, defaultStroke, func(p *clip.Path, pt func(x, y float32) f32.Point) { diff --git a/gui/overlay.go b/gui/overlay.go deleted file mode 100644 index c6d5009..0000000 --- a/gui/overlay.go +++ /dev/null @@ -1,269 +0,0 @@ -package gui - -import ( - "log/slog" - "strings" - - "gioui.org/font" - "gioui.org/layout" - "gioui.org/op/clip" - "gioui.org/unit" - "gioui.org/widget" - - "tslink/core" -) - -// logOverlay is the translucent live-log panel. -// -// It exists for one specific situation: someone is looking at a stuck loading -// screen and takes a screenshot to ask for help. If the logs are on another -// page, that screenshot is useless. Rendering them as a translucent sheet over -// the loading screen means the interesting information is in the picture -// without hiding what the app is doing. -type logOverlay struct { - visible bool - list layout.List - copyBtn widget.Clickable - closeBtn widget.Clickable - // docked is set while the splash is up: the panel then spans the window - // bottom instead of floating in the corner. - docked bool - - // Cached tail. The overlay redraws at the animation rate because of its - // live status dot, but the log only changes when a record is appended, so - // the slice is rebuilt on sequence change rather than every frame. - cached []core.LogEntry - cachedSeq uint64 - cachedLen int -} - -// tail returns the newest records, rebuilding only when the buffer advanced. -func (o *logOverlay) tail(buf *core.LogBuffer) []core.LogEntry { - seq, n := buf.LastSeq(), buf.Len() - if o.cached != nil && seq == o.cachedSeq && n == o.cachedLen { - return o.cached - } - o.cached = buf.Tail(overlayTailSize) - o.cachedSeq, o.cachedLen = seq, n - return o.cached -} - -func newLogOverlay() *logOverlay { - return &logOverlay{ - list: layout.List{Axis: layout.Vertical, ScrollToEnd: true}, - } -} - -// overlayTailSize is how many recent records the overlay renders. The full -// history lives on the logs page; this is a live tail, not an archive. -const overlayTailSize = 400 - -// Docked geometry. The splash reserves exactly this much room at the bottom of -// the window so the checklist is never hidden behind the log sheet — the point -// of the overlay is that both are legible in one screenshot. -const ( - dockedLogHeight unit.Dp = 176 - dockedHeaderHeight unit.Dp = 28 -) - -// dockedReserve is the total vertical space the docked overlay occupies, -// including its insets and the margin below it. -func dockedReserve(gtx C) int { - return gtx.Dp(dockedLogHeight + dockedHeaderHeight + SpaceSM + SpaceMD*2 + SpaceXL) -} - -// Layout draws the overlay. duringSplash forces it visible and docked. -func (o *logOverlay) Layout(a *App, gtx C, duringSplash bool) D { - o.docked = duringSplash - if !duringSplash && !o.visible { - return D{} - } - if a.opt.Logs == nil { - return D{} - } - - entries := o.tail(a.opt.Logs) - - if o.copyBtn.Clicked(gtx) { - a.copyToClipboard(gtx, a.opt.Logs.ExportText(core.ExportOptions{ - Header: a.diagnosticHeader(), - Query: core.LogQuery{MinLevel: slog.LevelDebug}, - }), a.th.T(KCopied)) - } - if o.closeBtn.Clicked(gtx) { - o.visible = false - } - - if duringSplash { - return layout.S.Layout(gtx, func(gtx C) D { - return layout.Inset{ - Left: SpaceXL, Right: SpaceXL, Bottom: SpaceXL, - }.Layout(gtx, func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return o.panel(a, gtx, entries, dockedLogHeight) - }) - }) - } - return layout.SE.Layout(gtx, func(gtx C) D { - return layout.Inset{Right: SpaceXL, Bottom: SpaceXL}.Layout(gtx, func(gtx C) D { - w := min(gtx.Constraints.Max.X, gtx.Dp(520)) - gtx.Constraints.Max.X = w - gtx.Constraints.Min.X = w - return o.panel(a, gtx, entries, unit.Dp(300)) - }) - }) -} - -func (o *logOverlay) panel(a *App, gtx C, entries []core.LogEntry, height unit.Dp) D { - th := a.th - h := gtx.Dp(height) - return layout.Stack{}.Layout(gtx, - layout.Expanded(func(gtx C) D { - glassPanel(th, gtx, gtx.Constraints.Min, float32(gtx.Dp(RadiusMD))) - return D{Size: gtx.Constraints.Min} - }), - layout.Stacked(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return layout.Inset{ - Top: SpaceMD, Bottom: SpaceMD, Left: SpaceLG, Right: SpaceMD, - }.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { return o.header(a, gtx, len(entries)) }), - VGap(SpaceSM), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.Y = h - gtx.Constraints.Max.Y = h - return o.body(a, gtx, entries) - }), - ) - }) - }), - ) -} - -func (o *logOverlay) header(a *App, gtx C, n int) D { - th := a.th - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return th.StatusDot(gtx, LevelInfo, false) - }), - HGap(SpaceSM), - layout.Flexed(1, func(gtx C) D { - l := th.Text(SizeCaption, th.P.TextSec, th.T(KSplashLogHint)) - l.Font.Weight = font.Medium - return OneLine(l).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return th.IconButton(gtx, &o.copyBtn, IconCopy, LevelNeutral) - }), - layout.Rigid(func(gtx C) D { - if o.docked { - return D{} - } - return th.IconButton(gtx, &o.closeBtn, IconClose, LevelNeutral) - }), - ) -} - -func (o *logOverlay) body(a *App, gtx C, entries []core.LogEntry) D { - th := a.th - if len(entries) == 0 { - return layout.Center.Layout(gtx, th.Caption(th.T(KLoading)).Layout) - } - defer clip.Rect{Max: gtx.Constraints.Max}.Push(gtx.Ops).Pop() - return o.list.Layout(gtx, len(entries), func(gtx C, i int) D { - return o.line(th, gtx, entries[i]) - }) -} - -// line renders one compact log record: time, level, message, and the most -// useful attributes folded into a single trailing run so the column stays -// narrow. -func (o *logOverlay) line(th *Theme, gtx C, e core.LogEntry) D { - lvlCol := th.P.TextDim - switch { - case e.Level >= slog.LevelError: - lvlCol = th.P.Fail - case e.Level >= slog.LevelWarn: - lvlCol = th.P.Warn - case e.Level >= slog.LevelInfo: - lvlCol = th.P.Info - } - - msgCol := th.P.TextSec - if e.Level >= slog.LevelWarn { - msgCol = th.P.TextPri - } - - return layout.Inset{Top: 1, Bottom: 1}.Layout(gtx, func(gtx C) D { - return layout.Flex{Axis: layout.Horizontal, Alignment: layout.Start}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return th.MonoLabel(SizeCaption, WithAlpha(th.P.TextDim, 0.85), - e.Time.Format("15:04:05")).Layout(gtx) - }), - HGap(SpaceSM), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = gtx.Dp(26) - return th.MonoLabel(SizeCaption, lvlCol, core.LevelLabel(e.Level)).Layout(gtx) - }), - HGap(SpaceSM), - layout.Flexed(1, func(gtx C) D { - l := th.MonoLabel(SizeCaption, msgCol, overlayLineText(e)) - l.MaxLines = 2 - return l.Layout(gtx) - }), - ) - }) -} - -// overlayLineText folds a record's attributes onto one line, dropping the -// "from" attribute because the subsystem is already implied by the message. -func overlayLineText(e core.LogEntry) string { - var b strings.Builder - b.WriteString(e.Msg) - for _, a := range e.Attrs { - if a.Key == "from" { - continue - } - b.WriteByte(' ') - b.WriteString(a.Key) - b.WriteByte('=') - b.WriteString(Truncate(core.Redact(a.Value), 64)) - } - return b.String() -} - -// diagnosticHeader is the metadata block prepended to any exported log bundle, -// so a paste is self-describing without the reporter having to explain their -// setup. -func (a *App) diagnosticHeader() string { - st := a.state() - var b strings.Builder - b.WriteString("# tslink diagnostic bundle\n") - b.WriteString("# version: " + a.opt.Version + "\n") - b.WriteString("# os/arch: " + runtimeInfo() + "\n") - if a.opt.ConfigURL != "" { - b.WriteString("# config: (url)\n") - } else if a.opt.ConfigPath != "" { - b.WriteString("# config: " + a.opt.ConfigPath + "\n") - } - b.WriteString("# phase: " + st.Phase.String() + "\n") - b.WriteString("# restarts: " + itoa(st.Restarts) + "\n") - if !st.ReadyAt.IsZero() { - b.WriteString("# uptime: " + FormatDuration(timeSince(st.ReadyAt)) + "\n") - } - if st.Peers != nil { - snap := st.Peers.Snapshot() - b.WriteString("# tailnet: " + snap.TailnetName + "\n") - b.WriteString("# peers: " + itoa(len(snap.Peers)) + "\n") - } - // reportText takes the diag page's lock; reading a.diag.report directly - // would race the background diagnostic goroutine. - if a.diag != nil { - if txt := a.diag.reportText(); txt != "" { - b.WriteString("#\n") - b.WriteString(txt) - } - } - return b.String() -} diff --git a/gui/page_diag.go b/gui/page_diag.go index 2c1c417..a90fbd2 100644 --- a/gui/page_diag.go +++ b/gui/page_diag.go @@ -2,6 +2,7 @@ package gui import ( "context" + "net/netip" "sort" "strings" "sync" @@ -188,7 +189,10 @@ func (p *diagPage) controlCard(a *App, gtx C, running bool, rep *netdiag.Report, col = th.P.TextPri } else if rep != nil { headline = rep.Headline - col = th.StatusColor(diagLevel(rep.Status)) + // The headline's own severity, not the report's: an + // unrelated failure elsewhere must not paint a merely + // cautionary sentence in alarm red. + col = th.StatusColor(diagLevel(rep.HeadlineStatus)) } l := th.Text(SizeSubtitle, col, headline) l.Font.Weight = font.SemiBold @@ -393,6 +397,132 @@ func (p *diagPage) triLabel(th *Theme, v *bool) (string, StatusLevel) { return th.T(KNo), LevelWarn } +// A bare "unknown" or "no" in the results tells the user what was measured but +// not what it costs them. These helpers add the one-line consequence, which is +// the part that actually answers "should I care". +// +// They follow behaviorLabel's inline bilingual switch rather than i18n keys: +// the strings are explanatory prose, only ever used here. + +// behaviorHint explains an RFC 5780 mapping/filtering behaviour. +func behaviorHint(th *Theme, b netdiag.Behavior) string { + zh := th.Lang == LangZH + switch b { + case netdiag.BehaviorEndpointIndependent: + if zh { + return "对所有目标复用同一个外部端口,最利于打洞" + } + return "one external port for every destination — best case for hole punching" + case netdiag.BehaviorAddressDependent: + if zh { + return "换一个目标地址就换一个映射" + } + return "the mapping changes with the destination address" + case netdiag.BehaviorAddressAndPortDependent: + if zh { + return "目标地址或端口一变映射就变,等同对称型" + } + return "the mapping changes with address or port — effectively symmetric" + default: + if zh { + return "没有服务器支持 CHANGE-REQUEST,无法判定" + } + return "no server supported CHANGE-REQUEST, so this could not be determined" + } +} + +// hairpinHint explains whether the NAT loops traffic sent to its own external +// address back inside. +func hairpinHint(th *Theme, v *bool) string { + zh := th.Lang == LangZH + switch { + case v == nil: + if zh { + return "未测试" + } + return "not tested" + case *v: + if zh { + return "同一内网的两台机器可经外网地址互连" + } + return "two machines behind this NAT can reach each other via the external address" + default: + if zh { + return "同一内网内无法经外网地址回环,需走内网地址" + } + return "traffic to the external address does not loop back; use the LAN address instead" + } +} + +// preserveHint explains whether the external port matches the local one. +func preserveHint(th *Theme, v *bool) string { + zh := th.Lang == LangZH + switch { + case v == nil: + if zh { + return "未测试" + } + return "not tested" + case *v: + if zh { + return "外部端口与本地端口一致,对端更容易预测" + } + return "the external port matches the local one, so peers can predict it" + default: + if zh { + return "外部端口被改写,端口预测不可靠" + } + return "the external port is rewritten, so port prediction is unreliable" + } +} + +// reachHint labels the CN/international pair, which is otherwise four bare +// numbers with no indication of what they count. +func reachHint(th *Theme) string { + if th.Lang == LangZH { + return "各自可达 / 探测总数" + } + return "reachable / probed, per region" +} + +// udpFamilyStats counts responding and probed servers per address family. +// Probes whose DNS lookup failed carry no address and belong to neither. +func udpFamilyStats(r netdiag.UDPReport) (v4ok, v4n, v6ok, v6n int) { + for _, p := range r.Probes { + ap, err := netip.ParseAddrPort(p.Target) + if err != nil { + continue + } + if ap.Addr().Is4() || ap.Addr().Is4In6() { + v4n++ + if p.OK { + v4ok++ + } + continue + } + v6n++ + if p.OK { + v6ok++ + } + } + return +} + +// udpFamilyHint reports how many servers answered on one address family. +func udpFamilyHint(th *Theme, ok, total int) string { + zh := th.Lang == LangZH + if total == 0 { + if zh { + return "没有可探测的地址" + } + return "no address to probe" + } + if zh { + return itoa(ok) + "/" + itoa(total) + " 台服务器响应" + } + return itoa(ok) + "/" + itoa(total) + " servers responded" +} + func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D { th := a.th hairpin, hairpinLvl := p.triLabel(th, r.Hairpin) @@ -411,10 +541,10 @@ func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D { }), layout.Rigid(func(gtx C) D { return th.KVList(gtx, []KV{ - {Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping)}, - {Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering)}, - {Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl}, - {Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl}, + {Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping), Hint: behaviorHint(th, r.Mapping)}, + {Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering), Hint: behaviorHint(th, r.Filtering)}, + {Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl, Hint: hairpinHint(th, r.Hairpin)}, + {Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl, Hint: preserveHint(th, r.PortPreserving)}, }) }), layout.Rigid(func(gtx C) D { @@ -493,6 +623,35 @@ func (p *diagPage) stunTable(a *App, gtx C, results []netdiag.STUNResult) D { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) } +// udpProbeLabel names a UDP probe the way the STUN table names its rows: the +// operator, then the hostname the user configured. +// +// The raw resolved address is not a useful label — nobody recognises +// 111.206.174.2:3478 as 小米 — but it is the only thing distinguishing the two +// rows a dual-stack server produces, so the family is appended instead. +func udpProbeLabel(pr netdiag.UDPProbe) string { + host := pr.Host + if host == "" { + host = pr.Target + } + label := host + if pr.Name != "" { + label = pr.Name + " " + host + } + // Only meaningful when Target is a resolved address rather than a copy of + // Host, which is what the DNS-failure path stores. + if pr.Target != "" && pr.Target != pr.Host { + if ap, err := netip.ParseAddrPort(pr.Target); err == nil { + if ap.Addr().Is4() || ap.Addr().Is4In6() { + label += " · IPv4" + } else { + label += " · IPv6" + } + } + } + return label +} + // regionTag prefixes a probe target so the CN/international split — the whole // reason both are probed — is visible at a glance. func regionTag(th *Theme, r netdiag.Region) string { @@ -560,15 +719,18 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D { v6lvl = LevelNeutral } + v4ok, v4n, v6ok, v6n := udpFamilyStats(r) + return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecUDP), r.Status, r.Summary, func(gtx C) D { rows := []KV{ - {Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl}, - {Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl}, + {Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl, Hint: udpFamilyHint(th, v4ok, v4n)}, + {Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl, Hint: udpFamilyHint(th, v6ok, v6n)}, { Key: "国内 / 境外", Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " + itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal), Mono: true, + Hint: reachHint(th), }, } if th.Lang != LangZH { @@ -600,7 +762,7 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D { if !pr.OK { val, level, rtt = orDash(pr.Err), LevelFail, "" } - return p.tableRow(a, gtx, regionTag(th, pr.Region)+pr.Target, val, rtt, level) + return p.tableRow(a, gtx, regionTag(th, pr.Region)+udpProbeLabel(pr), val, rtt, level) })) } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) @@ -704,9 +866,14 @@ func (p *diagPage) egressCard(a *App, gtx C, r netdiag.EgressReport) D { if !r.Divergent { return D{} } + // Only a split seen by STUN itself threatens the UDP path, so + // only that one gets the red treatment. + level, hint := LevelWarn, th.T(KDiagEgressDivergentHTTP) + if r.DivergentSTUN { + level, hint = LevelFail, th.T(KDiagEgressDivergentHint) + } return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D { - return p.callout(a, gtx, LevelWarn, - th.T(KDiagEgressDivergent), th.T(KDiagEgressDivergentHint)) + return p.callout(a, gtx, level, th.T(KDiagEgressDivergent), hint) }) }), // Geolocation first: "where do I appear to be" is the question, the diff --git a/gui/page_lan.go b/gui/page_lan.go deleted file mode 100644 index 1003dd0..0000000 --- a/gui/page_lan.go +++ /dev/null @@ -1,190 +0,0 @@ -package gui - -import ( - "time" - - "gioui.org/layout" - "gioui.org/widget" - "gioui.org/widget/material" - - "tslink/core" -) - -type lanPage struct { - list widget.List - copy map[string]*widget.Clickable -} - -func newLanPage() *lanPage { - p := &lanPage{copy: make(map[string]*widget.Clickable)} - p.list.Axis = layout.Vertical - return p -} - -func (p *lanPage) copyBtn(key string) *widget.Clickable { - c, ok := p.copy[key] - if !ok { - c = &widget.Clickable{} - p.copy[key] = c - } - return c -} - -func (p *lanPage) Layout(a *App, gtx C, st core.State) D { - th := a.th - if st.Lan == nil { - return th.EmptyState(gtx, IconBroadcast, th.T(KLanEmpty), th.T(KLoading)) - } - - servers := st.Lan.Servers() - scanErr := st.Lan.Err() - - var advertised []core.LanEntry - if st.Config != nil { - advertised = core.LanEntriesFromRules(st.Config.Connect) - } - - items := []layout.Widget{ - func(gtx C) D { return p.summaryCard(a, gtx, servers, advertised, scanErr) }, - } - if len(servers) == 0 { - items = append(items, func(gtx C) D { - hint := th.T(KLanSubtitle) - if scanErr != "" { - hint = scanErr - } - return th.EmptyState(gtx, IconServer, th.T(KLanEmpty), hint) - }) - } - for _, s := range servers { - items = append(items, func(gtx C) D { return p.serverCard(a, gtx, s) }) - } - - return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D { - return layout.Inset{Bottom: SpaceMD}.Layout(gtx, items[i]) - }) -} - -// summaryCard states what the scanner is doing and, crucially, whether the -// advertisements tslink itself emits are being heard back. A rule that is -// configured but not audible means the tunnel or the multicast path is broken, -// and that is the single most useful thing this page can tell someone. -func (p *lanPage) summaryCard(a *App, gtx C, servers []core.LanServer, advertised []core.LanEntry, scanErr string) D { - th := a.th - selfHeard := 0 - for _, s := range servers { - if s.IsSelf { - selfHeard++ - } - } - missing := len(advertised) - selfHeard - if missing < 0 { - missing = 0 - } - - card := th.Card() - card.Title = th.T(KLanTitle) - card.Subtitle = th.T(KLanSubtitle) - card.Trailing = func(gtx C) D { - level, label := LevelOK, th.T(KLanListening) - if scanErr != "" { - level, label = LevelFail, th.T(KLanBindError) - } - return th.Chip(gtx, ChipStyle{Text: label, Level: level, Dot: true}) - } - - rows := []KV{ - {Key: th.T(KOvLanServers), Value: itoa(len(servers))}, - {Key: th.T(KLanSelf), Value: itoa(selfHeard) + " / " + itoa(len(advertised)), - Hint: th.T(KLanSelfHint), - Level: selfLevel(len(advertised), selfHeard)}, - } - if scanErr != "" { - rows = append(rows, KV{Key: th.T(KError), Value: scanErr, Level: LevelFail}) - } - return card.Layout(th, gtx, func(gtx C) D { - return th.KVList(gtx, rows) - }) -} - -func selfLevel(advertised, heard int) StatusLevel { - switch { - case advertised == 0: - return LevelNeutral - case heard >= advertised: - return LevelOK - case heard == 0: - return LevelFail - default: - return LevelWarn - } -} - -func (p *lanPage) serverCard(a *App, gtx C, s core.LanServer) D { - th := a.th - addr := s.Addr.String() + ":" + itoa(s.Port) - btn := p.copyBtn(addr) - if btn.Clicked(gtx) { - a.copyToClipboard(gtx, addr, "") - } - - stale := time.Since(s.LastSeen) > 8*time.Second - level := LevelOK - if stale { - level = LevelWarn - } - - card := th.Card() - card.Pad = SpaceMD - if s.IsSelf { - accent := th.P.Accent - card.Accent = &accent - } - return card.Layout(th, gtx, func(gtx C) D { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return IconServer(gtx, gtx.Dp(18), th.StatusColor(level)) - }), - HGap(SpaceMD), - layout.Flexed(1, func(gtx C) D { - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(OneLine(th.Body(orDash(s.Motd))).Layout), - layout.Rigid(func(gtx C) D { - if !s.IsSelf { - return D{} - } - return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D { - return th.Chip(gtx, ChipStyle{ - Text: th.T(KLanSelf), - Level: LevelInfo, - }) - }) - }), - ) - }), - layout.Rigid(func(gtx C) D { - return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, addr)).Layout(gtx) - }), - ) - }), - HGap(SpaceMD), - layout.Rigid(func(gtx C) D { - return layout.Flex{Axis: layout.Vertical, Alignment: layout.End}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return th.Text(SizeCaption, th.StatusColor(level), - RelTime(th, s.LastSeen, time.Now())).Layout(gtx) - }), - layout.Rigid(func(gtx C) D { - return th.Caption(itoa(s.Count) + " " + th.T(KLanPackets)).Layout(gtx) - }), - ) - }), - HGap(SpaceSM), - layout.Rigid(func(gtx C) D { - return th.IconButton(gtx, btn, IconCopy, LevelNeutral) - }), - ) - }) -} diff --git a/gui/page_logs.go b/gui/page_logs.go index 04e6dc2..e8bb1c7 100644 --- a/gui/page_logs.go +++ b/gui/page_logs.go @@ -153,6 +153,7 @@ func (p *logsPage) handleActions(a *App, gtx C, buf *core.LogBuffer) { a.notify(th.T(KError)+": "+err.Error(), LevelFail) } else { a.notify(path, LevelOK) + a.reveal(path) } } if p.uploadBtn.Clicked(gtx) { diff --git a/gui/page_overview.go b/gui/page_overview.go index e9923fd..57f7b42 100644 --- a/gui/page_overview.go +++ b/gui/page_overview.go @@ -1,11 +1,13 @@ package gui import ( + "image" "strings" "time" "gioui.org/font" "gioui.org/layout" + "gioui.org/op" "gioui.org/widget" "gioui.org/widget/material" @@ -18,6 +20,8 @@ type overviewPage struct { diagBtn widget.Clickable peersBtn widget.Clickable copySelf widget.Clickable + // svcCopy holds one clickable per service address, allocated on demand. + svcCopy map[string]*widget.Clickable } func newOverviewPage() *overviewPage { @@ -41,19 +45,17 @@ func (p *overviewPage) Layout(a *App, gtx C, st core.State) D { if st.Peers != nil { snap = st.Peers.Snapshot() } - var lanServers []core.LanServer - if st.Lan != nil { - lanServers = st.Lan.Servers() - } + servers := buildServices(st.Config, snap) if p.copySelf.Clicked(gtx) { a.copyToClipboard(gtx, selfAddrText(snap.Self), "") } items := []layout.Widget{ - func(gtx C) D { return p.statRow(a, gtx, st, snap, lanServers) }, + func(gtx C) D { return p.statRow(a, gtx, st, snap, servers) }, func(gtx C) D { return p.healthCard(a, gtx) }, func(gtx C) D { return p.selfCard(a, gtx, st, snap) }, + func(gtx C) D { return p.servicesCard(a, gtx, servers) }, func(gtx C) D { return p.linkedCard(a, gtx, snap) }, } return material.List(th.Theme, &p.list).Layout(gtx, len(items), func(gtx C, i int) D { @@ -88,19 +90,29 @@ func (p *overviewPage) statTile(a *App, gtx C, value, label, hint string, level if level != LevelNeutral { l.Color = th.StatusColor(level) } - return l.Layout(gtx) + // Single line, always. A value like "19 / 25" wraps at narrow + // tile widths where "8" does not, and one tile a whole line + // taller than its neighbours is what makes the row look broken. + return OneLine(l).Layout(gtx) }), layout.Rigid(func(gtx C) D { - if hint == "" { - return D{} + if hint != "" { + return OneLine(th.Caption(hint)).Layout(gtx) } - return OneLine(th.Caption(hint)).Layout(gtx) + // Reserve the hint line even when there is no hint. These tiles + // sit in a row, and Flex does not equalise child heights, so a + // tile that skipped this line came out shorter than its + // neighbours and the row looked misaligned. + macro := op.Record(gtx.Ops) + d := OneLine(th.Caption("X")).Layout(gtx) + macro.Stop() + return D{Size: image.Pt(0, d.Size.Y)} }), ) }) } -func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, lan []core.LanServer) D { +func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, servers []serviceServer) D { th := a.th online, linked := 0, 0 @@ -121,10 +133,13 @@ func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnaps connectRules += len(rs) } } - selfLan := 0 - for _, s := range lan { - if s.IsSelf { - selfLan++ + services, broadcast := 0, 0 + for _, srv := range servers { + services += len(srv.Services) + for _, svc := range srv.Services { + if svc.Broadcast { + broadcast++ + } } } @@ -148,9 +163,9 @@ func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnaps }, func(gtx C) D { return p.statTile(a, gtx, - itoa(len(lan)), - th.T(KOvLanServers), - itoa(selfLan)+" "+th.T(KLanSelf), + itoa(services), + th.T(KSvcTitle), + itoa(broadcast)+" "+th.T(KSvcBroadcast), LevelNeutral, IconServer) }, func(gtx C) D { @@ -202,7 +217,7 @@ func (p *overviewPage) healthCard(a *App, gtx C) D { } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { - l := th.Text(SizeBody, th.StatusColor(diagLevel(rep.Status)), rep.Headline) + l := th.Text(SizeBody, th.StatusColor(diagLevel(rep.HeadlineStatus)), rep.Headline) l.Font.Weight = font.Medium l.MaxLines = 2 return l.Layout(gtx) diff --git a/gui/page_peers.go b/gui/page_peers.go index 5802b8c..e644e0f 100644 --- a/gui/page_peers.go +++ b/gui/page_peers.go @@ -12,9 +12,12 @@ import ( "tslink/core" ) -// chartWindow is how much latency history the graph shows. It matches the -// monitor's default 120-sample ring at a 10s ping interval. -const chartWindow = 20 * time.Minute +// chartWindow is the most latency history the graph shows. The monitor retains +// 20 minutes, but a spike that old tells you nothing about the session you are +// in right now, and stretching the axis over it flattens everything recent into +// noise. The axis scales to whatever data exists within this bound, so the plot +// is full from the second sample rather than after 20 minutes of uptime. +const chartWindow = 3 * time.Minute // maxChartSeries caps how many peers are plotted at once. Beyond about eight // lines a latency graph stops being readable, so linked peers win and the rest @@ -75,8 +78,10 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D { a.notify(th.T(KRefresh), LevelInfo) } - linked, other := splitPeers(snap.Peers) - series := p.buildSeries(th, snap.Peers) + // Only nodes a config rule points at. The netmap contains every machine on + // the tailnet, most of which the user has no rule for and no interest in. + linked, _ := splitPeers(snap.Peers) + series := p.buildSeries(th, linked) // Legend clicks toggle series visibility. for i := range series { @@ -87,7 +92,7 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D { series[i].s.Hidden = p.hidden[id] } - items := make([]layout.Widget, 0, len(snap.Peers)+4) + items := make([]layout.Widget, 0, len(linked)+4) items = append(items, func(gtx C) D { return p.chartCard(a, gtx, series) }) if len(linked) > 0 { @@ -97,22 +102,21 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D { for _, pr := range linked { items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) }) } - } - if len(other) > 0 { - items = append(items, func(gtx C) D { - return a.sectionTitle(gtx, th.T(KPeersOther), "", nil) - }) - for _, pr := range other { - items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) }) - } - } - if len(snap.Peers) == 0 { + } else { items = append(items, func(gtx C) D { + // Link resolution is periodic and needs DNS, so on a fresh boot + // every peer is briefly unlinked. Saying "no peers" there would be + // wrong; the netmap may be full of machines we simply have no rule + // for yet. hint := snap.Err if hint == "" { hint = snap.BackendState } - return th.EmptyState(gtx, IconNodes, th.T(KPeersEmpty), hint) + title := th.T(KPeersEmpty) + if len(snap.Peers) > 0 { + title = th.T(KPeersResolving) + } + return th.EmptyState(gtx, IconNodes, title, hint) }) } @@ -156,14 +160,24 @@ func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries if len(pr.Samples) == 0 { continue } + // Drop samples outside the window by age rather than by count: the + // monitor's ring is not evenly spaced, because a manual refresh injects + // an off-cycle sweep. + cutoff := time.Now().Add(-chartWindow) pts := make([]ChartPoint, 0, len(pr.Samples)) for _, s := range pr.Samples { + if s.At.Before(cutoff) { + continue + } pts = append(pts, ChartPoint{ At: s.At, Value: float64(s.Latency) / float64(time.Millisecond), OK: s.OK, }) } + if len(pts) == 0 { + continue + } out = append(out, namedSeries{ id: pr.ID, s: ChartSeries{ @@ -179,9 +193,26 @@ func (p *peersPage) buildSeries(th *Theme, peers []core.PeerInfo) []namedSeries func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D { th := a.th + plot := make([]ChartSeries, len(series)) + for i, s := range series { + plot[i] = s.s + } + style := ChartStyle{ + Height: 200, + MaxWindow: chartWindow, + Now: time.Now(), + Unit: "ms", + FillSingle: true, + } + card := th.Card() card.Title = th.T(KGraphTitle) - card.Subtitle = th.T(KGraphWindow) + // The axis follows the data, so the subtitle has to as well — a fixed + // "last 20 minutes" was a lie for the first 20 minutes of every run. + if len(plot) > 0 { + tMin, tMax := domain(plot, style) + card.Subtitle = th.T(KGraphWindow) + " " + FormatDuration(tMax.Sub(tMin)) + } card.Trailing = func(gtx C) D { return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral) } @@ -189,19 +220,9 @@ func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D { if len(series) == 0 { return th.EmptyState(gtx, IconPulse, th.T(KGraphEmpty), "") } - plot := make([]ChartSeries, len(series)) - for i, s := range series { - plot[i] = s.s - } return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx C) D { - return p.chart.Layout(th, gtx, ChartStyle{ - Height: 200, - Window: chartWindow, - Now: time.Now(), - Unit: "ms", - FillSingle: true, - }, plot) + return p.chart.Layout(th, gtx, style, plot) }), VGap(SpaceMD), layout.Rigid(func(gtx C) D { @@ -213,23 +234,23 @@ func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D { func (p *peersPage) legendRow(a *App, gtx C, series []namedSeries) D { th := a.th - children := make([]layout.FlexChild, 0, len(series)) + entries := make([]LegendEntry, 0, len(series)) for _, s := range series { - id := s.id - entry := LegendEntry{ + entries = append(entries, LegendEntry{ Name: s.s.Name, Color: s.s.Color, - Hidden: p.hidden[id], + Hidden: p.hidden[s.id], Value: lastValue(s.s.Points), - } - click := p.legendClick(id) - children = append(children, layout.Rigid(func(gtx C) D { - return click.Layout(gtx, func(gtx C) D { - return th.LegendChip(gtx, entry, click.Hovered()) - }) - })) + }) } - return layout.Flex{Axis: layout.Horizontal, Spacing: layout.SpaceEnd}.Layout(gtx, children...) + return th.Legend(gtx, entries, func(i int) layout.Widget { + click := p.legendClick(series[i].id) + return func(gtx C) D { + return click.Layout(gtx, func(gtx C) D { + return th.LegendChip(gtx, entries[i], click.Hovered()) + }) + } + }) } func lastValue(points []ChartPoint) string { diff --git a/gui/render_test.go b/gui/render_test.go index 2629f48..4713ebc 100644 --- a/gui/render_test.go +++ b/gui/render_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "gioui.org/app" "gioui.org/io/input" "gioui.org/layout" "gioui.org/op" @@ -95,7 +96,6 @@ func readyState(t *testing.T) core.State { ReadyAt: time.Now().Add(-time.Hour), Config: cfg, Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}), - Lan: core.NewLanScanner(logger), } } @@ -105,7 +105,7 @@ func TestPagesLayout(t *testing.T) { {X: 880, Y: 560}, // the declared minimum window {X: 640, Y: 400}, // below minimum: compact rail, everything must still fit } - pages := []pageID{pageOverview, pagePeers, pageLan, pageDiag, pageLogs, pageSettings} + pages := []pageID{pageOverview, pagePeers, pageDiag, pageLogs, pageSettings} for _, size := range sizes { for _, page := range pages { @@ -138,13 +138,32 @@ func TestSplashLayout(t *testing.T) { } gtx, _ := newTestContext(size) a.splash.Layout(a, gtx, st) - // The overlay is forced visible during the splash; it must lay out - // on top without depending on the splash having run. - a.overlay.Layout(a, gtx, true) } } } +// TestSplashStuck covers the >20s branch, which swaps the footer hint and +// promotes the export button. +func TestSplashStuck(t *testing.T) { + a := testApp(t) + steps := splashTestSteps() + for i := range steps { + if steps[i].State == core.StepRunning { + steps[i].Started = time.Now().Add(-45 * time.Second) + } + } + st := core.State{ + Phase: core.PhaseStarting, + Steps: steps, + StartedAt: time.Now().Add(-45 * time.Second), + } + if !stalled(st) { + t.Fatal("stalled() should report a step running past stuckAfter") + } + gtx, _ := newTestContext(image.Pt(460, 450)) + a.splash.Layout(a, gtx, st) +} + func splashTestSteps() []core.BootStep { now := time.Now() return []core.BootStep{ @@ -184,8 +203,12 @@ func TestDiagPageWithReport(t *testing.T) { CNReachable: 3, CNTotal: 5, IntlReachabl: 1, IntlTotal: 7, BlockedPorts: []int{19302}, Probes: []netdiag.UDPProbe{ - {Target: "stun.miwifi.com:3478", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")}, - {Target: "stun.l.google.com:19302", Region: netdiag.RegionIntl, Err: "i/o timeout"}, + // A resolved probe: Host names the server, Target is the + // address actually hit, and the label must show the former. + {Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, Mapped: netip.MustParseAddrPort("1.2.3.4:54321")}, + {Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米", Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond, Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")}, + // DNS failed, so Target still holds the hostname. + {Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google", Region: netdiag.RegionIntl, Err: "i/o timeout"}, }, }, NAT: netdiag.NATReport{ @@ -253,15 +276,6 @@ func TestDiagPageWithReport(t *testing.T) { } } -// TestOverlayLayout covers the floating (non-docked) overlay, which has a -// different anchor and a close button the docked one hides. -func TestOverlayLayout(t *testing.T) { - a := testApp(t) - a.overlay.visible = true - gtx, _ := newTestContext(image.Pt(1000, 700)) - a.overlay.Layout(a, gtx, false) -} - func TestFormatHelpers(t *testing.T) { cases := []struct { got, want string @@ -306,3 +320,55 @@ func TestTrFallsBackToEnglish(t *testing.T) { } } } + +// TestGrowOnce guards the window-resize latch. Growing more than once would +// snap a window the user had deliberately resized back to the shell default +// every time the service restarted. +func TestGrowOnce(t *testing.T) { + a := testApp(t) + // A Window with no driver queues options instead of touching a display, + // which is what makes this testable without one. + w := new(app.Window) + + if a.grown.Load() { + t.Fatal("a fresh App must not be marked grown") + } + a.grow(w) + if !a.grown.Load() { + t.Fatal("grow() must latch") + } + // Must be a no-op now. + a.grow(w) + a.grow(w) +} + +// TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a +// Flex, which does not equalise child heights, so anything that makes one tile +// taller — a wrapped value, a hint line present on some tiles but not others — +// visibly misaligns the row. Narrow widths are the interesting case: that is +// where "19 / 25" wraps and "8" does not. +func TestStatTilesUniformHeight(t *testing.T) { + a := testApp(t) + tiles := []struct{ value, label, hint string }{ + {"19 / 25", "在线节点", "3 已关联"}, + {"8", "本机服务", "5 已广播"}, + {"8 / 0", "连接规则 / 转发规则", ""}, + {"10s", "运行时长", ""}, + } + for _, w := range []int{60, 80, 100, 140, 200, 300} { + var first int + for i, c := range tiles { + gtx, _ := newTestContext(image.Pt(w, 400)) + gtx.Constraints.Min = image.Point{} + h := a.overview.statTile(a, gtx, c.value, c.label, c.hint, LevelNeutral, IconNodes).Size.Y + if i == 0 { + first = h + continue + } + if h != first { + t.Errorf("width=%d: tile %q is %dpx, tile %q is %dpx — the row must be flush", + w, c.label, h, tiles[0].label, first) + } + } + } +} diff --git a/gui/reveal.go b/gui/reveal.go new file mode 100644 index 0000000..74cfdd3 --- /dev/null +++ b/gui/reveal.go @@ -0,0 +1,65 @@ +package gui + +import ( + "context" + "log/slog" + "os/exec" + "path/filepath" + "runtime" + "time" +) + +// revealTimeout bounds the helper process. A missing or wedged file manager +// must not leave a goroutine parked forever. +const revealTimeout = 10 * time.Second + +// RevealInFileManager opens the platform file manager with path selected, +// falling back to opening its containing directory. +// +// Writing a log file and only printing where it went is not much use to someone +// who is about to attach it to a bug report, so the export shows it instead of +// describing it. +// +// It blocks; callers should run it off the UI goroutine. +func RevealInFileManager(path string, logger *slog.Logger) error { + if logger == nil { + logger = slog.Default() + } + ctx, cancel := context.WithTimeout(context.Background(), revealTimeout) + defer cancel() + + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + + switch runtime.GOOS { + case "darwin": + // -R reveals rather than opens, so Finder highlights the file. + return exec.CommandContext(ctx, "open", "-R", abs).Run() + + case "windows": + // explorer wants the comma glued to the flag, and exits non-zero even + // when it succeeds, so its status is deliberately ignored. + _ = exec.CommandContext(ctx, "explorer", "/select,"+abs).Run() + return nil + + default: + // The freedesktop interface highlights the file; every major Linux file + // manager implements it. Fall back to opening the directory when the + // service is absent — dbus-send itself may not even be installed. + uri := "file://" + abs + dbus := exec.CommandContext(ctx, "dbus-send", + "--session", "--dest=org.freedesktop.FileManager1", "--type=method_call", + "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", + "array:string:"+uri, "string:tslink", + ) + if err := dbus.Run(); err == nil { + return nil + } else { + logger.Debug("FileManager1.ShowItems unavailable, opening the directory", + "err", err) + } + return exec.CommandContext(ctx, "xdg-open", filepath.Dir(abs)).Run() + } +} diff --git a/gui/services.go b/gui/services.go new file mode 100644 index 0000000..a4f4f7b --- /dev/null +++ b/gui/services.go @@ -0,0 +1,257 @@ +package gui + +import ( + "net" + "sort" + "strings" + + "gioui.org/layout" + "gioui.org/widget" + + "tslink/core" +) + +// The services section answers "what did tslink open on this machine, and which +// server is behind it". +// +// It is built entirely from the parsed config plus the peer snapshot the app +// already holds — no multicast, no I/O on the render path. The previous LAN page +// listened for the same MOTD broadcasts tslink itself emits, which meant the +// list was assembled from packets: the same server appeared once per IP family, +// nothing deduplicated the two, and the rows were ordered by last-seen so a 1.5s +// broadcast cycle permuted them continuously. Deriving the list from config +// instead makes it exact and, because it is sorted on a stable key, still. + +// serviceEntry is one local listener created by a connect rule. +type serviceEntry struct { + Name string // the rule's MOTD, or its config tag + Tag string // the [[connect.]] key + Proto string + Addr string // the local address a client points at + Port int + // Broadcast reports that this service is announced on the LAN, i.e. it + // shows up in Minecraft's server list without being typed in. + Broadcast bool +} + +// serviceServer groups every local listener that targets one remote host. +type serviceServer struct { + // Host is the dst_addr hostname, already MagicDNS-qualified by the + // supervisor's NormalizeConnectRulesDstAddr pass. + Host string + // Peer is the tailnet node Host resolved to, when the peer monitor managed + // to resolve it. Nil for destinations outside the tailnet, which are + // legitimate config entries and must still render. + Peer *core.PeerInfo + Services []serviceEntry +} + +// Online reports the peer's reachability, defaulting to true when the +// destination is not a tailnet peer and we therefore have nothing to say. +func (s serviceServer) Online() bool { return s.Peer == nil || s.Peer.Online } + +// Title is the friendliest name available for the target. +func (s serviceServer) Title() string { + if s.Peer != nil && s.Peer.DisplayName != "" { + return s.Peer.DisplayName + } + return s.Host +} + +// buildServices turns connect rules into the per-server view. +// +// Grouping is by destination host rather than by config tag: a server reached +// over both TCP and UDP is written as two tagged rules pointing at the same +// dst_addr, and the user thinks of that as one server with two services. +func buildServices(cfg *core.Config, snap core.PeerSnapshot) []serviceServer { + if cfg == nil { + return nil + } + + // tag -> peer, via the links the monitor already resolved. + byTag := make(map[string]*core.PeerInfo) + for i := range snap.Peers { + pr := &snap.Peers[i] + for _, tag := range pr.LinkTags { + byTag[tag] = pr + } + } + + grouped := make(map[string]*serviceServer) + for tag, rules := range cfg.Connect { + for _, rule := range rules { + host := rule.DstAddr + if h, _, err := net.SplitHostPort(rule.DstAddr); err == nil { + host = h + } + g, ok := grouped[host] + if !ok { + g = &serviceServer{Host: host, Peer: byTag[tag]} + grouped[host] = g + } else if g.Peer == nil { + g.Peer = byTag[tag] + } + g.Services = append(g.Services, serviceEntry{ + Name: rule.LANMotdOr(tag), + Tag: tag, + Proto: rule.Protocol, + Addr: net.JoinHostPort(rule.BindIP(), itoa(rule.LocalPort)), + Port: rule.LocalPort, + Broadcast: rule.LANEnabled(), + }) + } + } + + out := make([]serviceServer, 0, len(grouped)) + for _, g := range grouped { + // Stable within a server: port, then protocol for the tcp/udp pair that + // shares one. + sort.SliceStable(g.Services, func(i, j int) bool { + if g.Services[i].Port != g.Services[j].Port { + return g.Services[i].Port < g.Services[j].Port + } + return g.Services[i].Proto < g.Services[j].Proto + }) + out = append(out, *g) + } + // Ordered by what is actually on screen, so the list reads alphabetically + // rather than by a hostname the user may never see. Host breaks ties and + // keeps the order total — map iteration is randomised, so without a full + // ordering the whole section would reshuffle every frame. + sort.SliceStable(out, func(i, j int) bool { + if ti, tj := out[i].Title(), out[j].Title(); ti != tj { + return ti < tj + } + return out[i].Host < out[j].Host + }) + return out +} + +// copyBtn lazily allocates a clickable per address. +func (p *overviewPage) copyBtn(addr string) *widget.Clickable { + if p.svcCopy == nil { + p.svcCopy = make(map[string]*widget.Clickable) + } + b, ok := p.svcCopy[addr] + if !ok { + b = new(widget.Clickable) + p.svcCopy[addr] = b + } + return b +} + +func (p *overviewPage) servicesCard(a *App, gtx C, servers []serviceServer) D { + th := a.th + card := th.Card() + card.Title = th.T(KSvcTitle) + card.Subtitle = th.T(KSvcSubtitle) + + return card.Layout(th, gtx, func(gtx C) D { + if len(servers) == 0 { + return th.EmptyState(gtx, IconServer, th.T(KSvcEmpty), "") + } + children := make([]layout.FlexChild, 0, len(servers)*2) + for i, srv := range servers { + if i > 0 { + children = append(children, layout.Rigid(th.Divider)) + } + children = append(children, layout.Rigid(func(gtx C) D { + return p.serverGroup(a, gtx, srv) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +// serverGroup renders one target host and the services pointing at it. +func (p *overviewPage) serverGroup(a *App, gtx C, srv serviceServer) D { + th := a.th + level := LevelOK + if !srv.Online() { + level = LevelFail + } + + return layout.Inset{Top: SpaceSM, Bottom: SpaceSM}.Layout(gtx, func(gtx C) D { + children := []layout.FlexChild{ + layout.Rigid(func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return th.StatusDot(gtx, level, false) + }), + HGap(SpaceMD), + layout.Flexed(1, func(gtx C) D { + return OneLine(th.Body(srv.Title())).Layout(gtx) + }), + layout.Rigid(func(gtx C) D { + // Only worth showing when it differs from the title, + // i.e. when the peer resolved to a nicer name. + if srv.Peer == nil || srv.Title() == srv.Host { + return D{} + } + return OneLine(th.MonoLabel(SizeCaption, th.P.TextDim, srv.Host)).Layout(gtx) + }), + ) + }), + } + for _, svc := range srv.Services { + children = append(children, layout.Rigid(func(gtx C) D { + return p.serviceRow(a, gtx, svc) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +// serviceRow is the name-over-address entry: the address is the thing a user +// actually needs to type somewhere else, so it gets a monospace line of its own +// rather than being folded into the label. +func (p *overviewPage) serviceRow(a *App, gtx C, svc serviceEntry) D { + th := a.th + btn := p.copyBtn(svc.Addr) + if btn.Clicked(gtx) { + a.copyToClipboard(gtx, svc.Addr, "") + } + + return layout.Inset{Top: 4, Bottom: 4, Left: SpaceLG}.Layout(gtx, func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return IconLink(gtx, gtx.Dp(14), th.P.TextDim) + }), + HGap(SpaceMD), + layout.Flexed(1, func(gtx C) D { + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, + layout.Rigid(func(gtx C) D { + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(OneLine(th.Body(svc.Name)).Layout), + layout.Rigid(func(gtx C) D { + if !svc.Broadcast { + return D{} + } + return layout.Inset{Left: SpaceSM}.Layout(gtx, func(gtx C) D { + return th.Chip(gtx, ChipStyle{ + Text: th.T(KSvcBroadcast), + Level: LevelInfo, + }) + }) + }), + ) + }), + layout.Rigid(func(gtx C) D { + return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, svc.Addr)).Layout(gtx) + }), + ) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + if svc.Proto == "" { + return D{} + } + return th.Chip(gtx, ChipStyle{Text: strings.ToUpper(svc.Proto)}) + }), + HGap(SpaceSM), + layout.Rigid(func(gtx C) D { + return th.IconButton(gtx, btn, IconCopy, LevelNeutral) + }), + ) + }) +} diff --git a/gui/services_test.go b/gui/services_test.go new file mode 100644 index 0000000..76b923e --- /dev/null +++ b/gui/services_test.go @@ -0,0 +1,106 @@ +package gui + +import ( + "testing" + + "tslink/core" +) + +// TestBuildServicesGroupsByHost pins the two properties the old LAN page got +// wrong: one entry per server (not per rule, and not per IP family), and an +// order that does not depend on map iteration. +func TestBuildServicesGroupsByHost(t *testing.T) { + cfg := &core.Config{ + Connect: map[string][]core.ConnectRule{ + // Same destination host over two protocols: must collapse into one + // server carrying two services. + "l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}}, + "l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.example:27015"}}, + "sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565", LanMotd: "SFCraft"}}, + // Not a tailnet host; it still has to render. + "voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}}, + }, + } + + got := buildServices(cfg, core.PeerSnapshot{}) + + if len(got) != 3 { + t.Fatalf("want 3 servers, got %d: %+v", len(got), got) + } + + // Sorted by host. + wantHosts := []string{"a.mc.example", "mc.lxns.net", "server.l4d2.example"} + for i, want := range wantHosts { + if got[i].Host != want { + t.Errorf("server[%d].Host = %q, want %q", i, got[i].Host, want) + } + } + + l4d2 := got[2] + if len(l4d2.Services) != 2 { + t.Fatalf("l4d2 should carry both protocols, got %d", len(l4d2.Services)) + } + if l4d2.Services[0].Proto != "tcp" || l4d2.Services[1].Proto != "udp" { + t.Errorf("services not ordered by protocol: %+v", l4d2.Services) + } + // No peer resolved: must not claim the server is down. + if !l4d2.Online() { + t.Error("a server with no resolved peer should not render as offline") + } + if l4d2.Title() != "server.l4d2.example" { + t.Errorf("Title() = %q, want the host", l4d2.Title()) + } + + // LANEnabled defaults to true only for minecraft. + mc := got[0] + if !mc.Services[0].Broadcast { + t.Error("a minecraft rule should be marked as broadcast") + } + if mc.Services[0].Name != "SFCraft" { + t.Errorf("Name = %q, want the lan_motd", mc.Services[0].Name) + } + if got[1].Services[0].Broadcast { + t.Error("a plain udp rule should not be marked as broadcast") + } + + // Repeated builds must agree, or the section jitters between frames. + for i := 0; i < 20; i++ { + again := buildServices(cfg, core.PeerSnapshot{}) + for j := range again { + if again[j].Host != got[j].Host { + t.Fatalf("ordering is unstable: %q vs %q", again[j].Host, got[j].Host) + } + } + } +} + +// TestBuildServicesUsesPeer checks the enrichment path: a resolved peer supplies +// the display name and the online state. +func TestBuildServicesUsesPeer(t *testing.T) { + cfg := &core.Config{ + Connect: map[string][]core.ConnectRule{ + "sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "a.mc.example:25565"}}, + }, + } + snap := core.PeerSnapshot{Peers: []core.PeerInfo{{ + ID: "n1", DisplayName: "homelab", Online: false, + Linked: true, LinkTags: []string{"sfcraft"}, + }}} + + got := buildServices(cfg, snap) + if len(got) != 1 { + t.Fatalf("want 1 server, got %d", len(got)) + } + if got[0].Title() != "homelab" { + t.Errorf("Title() = %q, want the peer display name", got[0].Title()) + } + if got[0].Online() { + t.Error("an offline peer should make the server render as offline") + } +} + +func TestBuildServicesNilConfig(t *testing.T) { + if got := buildServices(nil, core.PeerSnapshot{}); got != nil { + t.Errorf("want nil for a nil config, got %+v", got) + } +} diff --git a/gui/shot_test.go b/gui/shot_test.go new file mode 100644 index 0000000..1aedcf3 --- /dev/null +++ b/gui/shot_test.go @@ -0,0 +1,310 @@ +//go:build shots + +package gui + +import ( + "image" + "image/png" + "log/slog" + "net/netip" + "os" + "testing" + "time" + + "gioui.org/font" + "gioui.org/gpu/headless" + "gioui.org/io/input" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/op/paint" + "gioui.org/text" + "gioui.org/unit" + + "tslink/core" + "tslink/netdiag" +) + +// Offscreen renders of the changed UI, for eyeballing what no assertion can +// capture — glyph coverage at bold weights, legend wrapping, how full the chart +// looks with only a few samples. +// +// go test ./gui/ -tags shots -run TestShots +// +// Build-tagged so the normal suite stays GPU-free and font-config independent. + +const shotDir = "/tmp/tslink-shots" + +func shoot(t *testing.T, th *Theme, name string, size image.Point, w func(gtx C) D) { + t.Helper() + win, err := headless.NewWindow(size.X, size.Y) + if err != nil { + t.Skipf("no GPU backend: %v", err) + } + defer win.Release() + + var r input.Router + ops := new(op.Ops) + // Two frames: the second takes the paths that depend on widget state. + for i := 0; i < 2; i++ { + ops.Reset() + gtx := layout.Context{ + Ops: ops, + Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, + Constraints: layout.Exact(size), + Now: time.Now(), + Source: r.Source(), + } + paint.Fill(gtx.Ops, th.P.Bg) + w(gtx) + if err := win.Frame(ops); err != nil { + t.Fatalf("frame: %v", err) + } + } + + img := image.NewRGBA(image.Rectangle{Max: size}) + if err := win.Screenshot(img); err != nil { + t.Fatalf("screenshot: %v", err) + } + f, err := os.Create(shotDir + "/" + name + ".png") + if err != nil { + t.Fatal(err) + } + defer f.Close() + if err := png.Encode(f, img); err != nil { + t.Fatal(err) + } + t.Logf("wrote %s/%s.png", shotDir, name) +} + +// realTheme builds the theme the way the app does, including the host's CJK +// font. Unlike testTheme this deliberately depends on the local font config — +// that is the thing under inspection. +func realTheme(t *testing.T) *Theme { + t.Helper() + fonts := LoadFonts() + if !fonts.HasCJK { + t.Skip("no CJK font on this host") + } + faces, err := LoadCJKFaces(fonts.CJKPath, slog.New(slog.DiscardHandler)) + if err != nil { + t.Fatalf("cjk: %v", err) + } + fonts.Collection = append(fonts.Collection, faces...) + th := NewTheme(fonts, true) + th.Shaper = text.NewShaper(text.WithCollection(fonts.Collection)) + th.Lang = LangZH + return th +} + +func shotApp(t *testing.T, th *Theme) *App { + a := testApp(t) + a.th = th + return a +} + +func TestShots(t *testing.T) { + if err := os.MkdirAll(shotDir, 0o755); err != nil { + t.Fatal(err) + } + th := realTheme(t) + + // --- 1. CJK at every weight the UI uses ------------------------------- + // The bug was that only weight 400 had a CJK face, so everything below + // rendered as tofu boxes. All five lines must show Chinese glyphs. + t.Run("cjk-weights", func(t *testing.T) { + weights := []struct { + w font.Weight + name string + }{ + {font.Normal, "Normal 正文:延迟图谱 已关联 本机服务"}, + {font.Medium, "Medium 按钮:重试 导出日志 刷新"}, + {font.SemiBold, "SemiBold 标题:网络诊断 节点延迟"}, + {font.Bold, "Bold 强调:局域网 转发规则"}, + } + shoot(t, th, "cjk-weights", image.Pt(560, 200), func(gtx C) D { + return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D { + children := make([]layout.FlexChild, 0, len(weights)*2) + for _, w := range weights { + children = append(children, layout.Rigid(func(gtx C) D { + l := th.Text(SizeSubtitle, th.P.TextPri, w.name) + l.Font.Weight = w.w + return l.Layout(gtx) + }), VGap(SpaceSM)) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) + }) + }) + + // --- 2. Splash, normal and stuck -------------------------------------- + for _, tc := range []struct { + name string + age time.Duration + }{ + {"splash", 10 * time.Second}, + {"splash-stuck", 45 * time.Second}, + } { + t.Run(tc.name, func(t *testing.T) { + a := shotApp(t, th) + steps := splashTestSteps() + for i := range steps { + if steps[i].State == core.StepRunning { + steps[i].Started = time.Now().Add(-tc.age) + } + } + st := core.State{ + Phase: core.PhaseStarting, Steps: steps, + StartedAt: time.Now().Add(-tc.age), + } + shoot(t, th, tc.name, image.Pt(460, 450), func(gtx C) D { + return a.splash.Layout(a, gtx, st) + }) + }) + } + + // --- 3. Chart + legend with 8 series and only 30s of history ---------- + // Previously this filled ~2.5% of the plot and clipped the legend. + t.Run("chart", func(t *testing.T) { + a := shotApp(t, th) + p := a.peers + series := p.buildSeries(th, shotPeers()) + shoot(t, th, "chart", image.Pt(760, 400), func(gtx C) D { + return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return p.chartCard(a, gtx, series) + }) + }) + }) + + // --- 3b. Stat tiles: equal height with and without a hint ------------- + t.Run("stat-tiles", func(t *testing.T) { + a := shotApp(t, th) + st := readyState(t) + snap := core.PeerSnapshot{Peers: []core.PeerInfo{ + {ID: "n1", DisplayName: "a", Online: true, Linked: true}, + }} + servers := buildServices(st.Config, snap) + shoot(t, th, "stat-tiles", image.Pt(1000, 160), func(gtx C) D { + return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return a.overview.statRow(a, gtx, st, snap, servers) + }) + }) + }) + + // --- 4. Services card grouped per server ------------------------------ + t.Run("services", func(t *testing.T) { + a := shotApp(t, th) + cfg := &core.Config{Connect: map[string][]core.ConnectRule{ + "sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565", LanMotd: "SFCraft Vanilla | 原版生电 1.21.8"}}, + "mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}}, + "voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}}, + "l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}}, + "l4d2_udp": {{Protocol: "udp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}}, + }} + snap := core.PeerSnapshot{Peers: []core.PeerInfo{ + {ID: "n1", DisplayName: "homelab-mc", Online: true, Linked: true, LinkTags: []string{"sfcraft"}}, + {ID: "n2", DisplayName: "l4d2-box", Online: false, Linked: true, LinkTags: []string{"l4d2_tcp", "l4d2_udp"}}, + }} + servers := buildServices(cfg, snap) + shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D { + return layout.UniformInset(SpaceLG).Layout(gtx, func(gtx C) D { + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return a.overview.servicesCard(a, gtx, servers) + }) + }) + }) +} + +// TestShotsDiag renders the UDP table, which must name servers rather than +// print bare resolved addresses. +func TestShotsDiag(t *testing.T) { + if err := os.MkdirAll(shotDir, 0o755); err != nil { + t.Fatal(err) + } + th := realTheme(t) + a := shotApp(t, th) + a.current = pageDiag + st := readyState(t) + a.diag.report = diagShotReport() + shoot(t, th, "diag-udp", image.Pt(1120, 900), func(gtx C) D { + return a.diag.Layout(a, gtx, st) + }) +} + +// diagShotReport is a healthy report whose only complaint is an HTTP-only +// egress split — the case that must read as a yellow "may affect", not a red +// "is affecting". +func diagShotReport() *netdiag.Report { + rep := &netdiag.Report{ + StartedAt: time.Now().Add(-18 * time.Second), + Duration: 17 * time.Second, + UDP: netdiag.UDPReport{ + Status: netdiag.StatusOK, Summary: "UDP 可用", V4OK: true, + CNReachable: 2, CNTotal: 2, IntlReachabl: 1, IntlTotal: 2, + Probes: []netdiag.UDPProbe{ + {Host: "stun.miwifi.com:3478", Target: "111.206.174.2:3478", Name: "小米", + Region: netdiag.RegionCN, OK: true, RTT: 12 * time.Millisecond, + Mapped: netip.MustParseAddrPort("1.2.3.4:54321")}, + {Host: "stun.miwifi.com:3478", Target: "[2408::1]:3478", Name: "小米", + Region: netdiag.RegionCN, OK: true, RTT: 15 * time.Millisecond, + Mapped: netip.MustParseAddrPort("[2001:db8::9]:54321")}, + {Host: "stun.chat.bilibili.com:3478", Target: "203.107.1.33:3478", Name: "哔哩哔哩", + Region: netdiag.RegionCN, OK: true, RTT: 21 * time.Millisecond, + Mapped: netip.MustParseAddrPort("1.2.3.4:54322")}, + {Host: "stun.l.google.com:19302", Target: "stun.l.google.com:19302", Name: "Google", + Region: netdiag.RegionIntl, Err: "i/o timeout"}, + }, + }, + NAT: netdiag.NATReport{ + Status: netdiag.StatusOK, Type: netdiag.NATFullCone, + Mapping: netdiag.BehaviorEndpointIndependent, + Filtering: netdiag.BehaviorUnknown, + }, + Overseas: netdiag.OverseasReport{Status: netdiag.StatusOK, Summary: "境外可达"}, + Egress: netdiag.EgressReport{ + Observations: []netdiag.EgressObservation{ + {Method: netdiag.MethodSTUN, Source: "stun.miwifi.com:3478", IP: netip.MustParseAddr("1.2.3.4")}, + {Method: netdiag.MethodHTTPv4, Source: "https://example/ip", IP: netip.MustParseAddr("5.6.7.8")}, + }, + }, + } + eg := &rep.Egress + eg.UniqueIPs = []netip.Addr{netip.MustParseAddr("1.2.3.4"), netip.MustParseAddr("5.6.7.8")} + eg.Divergent = true + eg.DivergentSTUN = false + eg.Status = netdiag.StatusWarn + eg.Summary = "出口 IP 不一致:IPv4 有 2 个(1.2.3.4、5.6.7.8),仅 HTTP 探测存在差异" + rep.Status = netdiag.StatusWarn + rep.Headline = "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接" + return rep +} + +// shotPeers fabricates eight linked peers with ~30 seconds of history each — +// the short-uptime case the chart used to render almost entirely blank, and +// enough series to force the legend to wrap. +func shotPeers() []core.PeerInfo { + now := time.Now() + names := []string{ + "sfcraft-homelab", "mayday", "l4d2-server", "voice-relay", + "mcp2-survival", "backup-node", "gateway-cn", "storage-nas", + } + peers := make([]core.PeerInfo, 0, len(names)) + for i, n := range names { + var samples []core.PeerSample + for k := 0; k < 4; k++ { + samples = append(samples, core.PeerSample{ + At: now.Add(time.Duration(-30+k*10) * time.Second), + Latency: time.Duration(18+i*9+k*4) * time.Millisecond, + OK: true, + }) + } + peers = append(peers, core.PeerInfo{ + ID: n, DisplayName: n, Online: true, Linked: true, + LinkTags: []string{n}, Route: core.RouteDirect, + LastLatency: samples[len(samples)-1].Latency, LatencyOK: true, + Samples: samples, + }) + } + return peers +} diff --git a/gui/splash.go b/gui/splash.go index 632a334..ea7a7cf 100644 --- a/gui/splash.go +++ b/gui/splash.go @@ -2,28 +2,52 @@ package gui import ( "image" + "log/slog" "math" "time" "gioui.org/f32" "gioui.org/font" "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" "gioui.org/text" + "gioui.org/unit" "gioui.org/widget" "gioui.org/widget/material" "tslink/core" + + "gioui.org/op/paint" ) +// Window geometry. The splash is sized to just its progress bar and checklist — +// it has nothing else to show, and a loading screen floating in a 1120x740 +// window reads as a broken main window rather than as progress. App.layout +// grows the window to the shell dimensions once the service is ready. +const ( + splashWindowW unit.Dp = 460 + splashWindowH unit.Dp = 450 + splashMinW unit.Dp = 380 + splashMinH unit.Dp = 380 + + shellWindowW unit.Dp = 1120 + shellWindowH unit.Dp = 740 + shellMinW unit.Dp = 880 + shellMinH unit.Dp = 560 +) + +// stuckAfter is how long a single boot step may run before the splash offers +// the log export. Tailscale's first connection legitimately takes several +// seconds, so this has to be long enough not to cry wolf, but short enough that +// someone staring at a hung step is told what to do about it. +const stuckAfter = 20 * time.Second + // splashView is the loading screen. It covers the window until the service is // up, which is also the window during which the CJK font is parsed and // tailscale negotiates its first connection — both slow enough that showing a // bare grey rectangle would read as a hang. type splashView struct { - retry widget.Clickable + retry widget.Clickable + export widget.Clickable // list keeps the panel reachable on short windows. Without it the retry // button — the one control on this screen — falls off the bottom edge once // the checklist and an error message are both showing. @@ -56,6 +80,16 @@ func stepTitle(th *Theme, key string) string { } } +// stalled reports whether a step has been running long enough to look stuck. +func stalled(st core.State) bool { + for _, step := range st.Steps { + if step.State == core.StepRunning && step.Elapsed() >= stuckAfter { + return true + } + } + return false +} + func (s *splashView) Layout(a *App, gtx C, st core.State) D { th := a.th paint.Fill(gtx.Ops, th.P.Bg) @@ -63,40 +97,49 @@ func (s *splashView) Layout(a *App, gtx C, st core.State) D { if s.retry.Clicked(gtx) && a.opt.Supervisor != nil { a.opt.Supervisor.Restart() } - - // The docked log sheet sits along the bottom edge, so the panel is centred - // in whatever is left above it. Reserving the space rather than stacking - // the two is the whole point: a screenshot taken mid-load has to show both - // the checklist and the log. - reserve := dockedReserve(gtx) - if maxReserve := gtx.Constraints.Max.Y / 2; reserve > maxReserve { - reserve = maxReserve + if s.export.Clicked(gtx) { + s.exportLogs(a) } - return layout.Flex{Axis: layout.Vertical}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - // A single-element list: centred when it fits, scrollable when the - // window is too short for the checklist plus an error message. - return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D { - return layout.Center.Layout(gtx, func(gtx C) D { - gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(460)) - gtx.Constraints.Min.X = gtx.Constraints.Max.X - return layout.Inset{Top: SpaceLG, Bottom: SpaceLG}.Layout(gtx, func(gtx C) D { - return s.panel(a, gtx, st) - }) - }) + + // A single-element list: centred when it fits, scrollable when the window is + // too short for the checklist plus an error message. + return material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D { + return layout.Center.Layout(gtx, func(gtx C) D { + gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(400)) + gtx.Constraints.Min.X = gtx.Constraints.Max.X + return layout.Inset{ + Top: SpaceLG, Bottom: SpaceLG, Left: SpaceMD, Right: SpaceMD, + }.Layout(gtx, func(gtx C) D { + return s.panel(a, gtx, st) }) - }), - layout.Rigid(func(gtx C) D { return D{Size: image.Pt(0, reserve)} }), - ) + }) + }) +} + +// exportLogs writes the current buffer to a file and reports where it went. +// This is the splash's replacement for the live log tail: someone looking at a +// stuck boot needs the log in a file they can attach, not on screen. +func (s *splashView) exportLogs(a *App) { + if a.opt.Logs == nil { + return + } + content := a.opt.Logs.ExportText(core.ExportOptions{ + Header: a.diagnosticHeader(), + // Debug and up: a stuck boot is exactly when the quiet records matter. + Query: core.LogQuery{MinLevel: slog.LevelDebug}, + }) + path, err := saveLogFile(content) + if err != nil { + a.notify(a.th.T(KError)+": "+err.Error(), LevelFail) + return + } + a.notify(path, LevelOK) + a.reveal(path) } func (s *splashView) panel(a *App, gtx C, st core.State) D { th := a.th return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { - return s.pulse(a, gtx, st) - }), - VGap(SpaceMD), layout.Rigid(func(gtx C) D { l := th.Text(SizeDisplay, th.P.TextPri, "tslink") l.Font.Weight = font.Bold @@ -122,49 +165,6 @@ func (s *splashView) panel(a *App, gtx C, st core.State) D { ) } -// pulse draws concentric rings radiating from a solid core. Three rings offset -// in phase read as continuous motion without a spinning element, which suits a -// "connecting to a network" wait better than a rotating arc. -func (s *splashView) pulse(a *App, gtx C, st core.State) D { - th := a.th - size := gtx.Dp(64) - center := f32.Pt(float32(size)/2, float32(size)/2) - - col := th.P.Accent - switch st.Phase { - case core.PhaseError: - col = th.P.Fail - case core.PhaseRetrying: - col = th.P.Warn - } - - const period = 2400 * time.Millisecond - base := float32(gtx.Dp(14)) - grow := float32(size)/2 - base - - if st.Phase != core.PhaseError { - phase := float64(gtx.Now.UnixNano()%int64(period)) / float64(period) - for i := 0; i < 3; i++ { - p := math.Mod(phase+float64(i)/3, 1) - r := base + grow*float32(p) - // Ease the fade so rings vanish before they hit the edge. - alpha := float32(1-p) * 0.55 - drawArc(gtx, center, r, float32(gtx.Dp(1.5)), 0, 2*math.Pi, WithAlpha(col, alpha)) - } - // A 2.4s cycle does not need 25fps, and this is the one animation that - // can legitimately run for minutes while tailscale negotiates. - animateSlow(gtx) - } - - // Solid core. - d := gtx.Dp(22) - off := op.Offset(image.Pt((size-d)/2, (size-d)/2)).Push(gtx.Ops) - Circle(gtx, d, col) - off.Pop() - - return D{Size: image.Pt(size, size)} -} - func (s *splashView) checklist(a *App, gtx C, st core.State) D { th := a.th children := make([]layout.FlexChild, 0, len(st.Steps)) @@ -226,11 +226,19 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D { ) }), layout.Rigid(func(gtx C) D { - if step.State != core.StepDone || step.Elapsed() < 100*time.Millisecond { + // A running step shows its timer once it is slow enough to be + // worth watching; a finished one shows what it cost. + switch { + case step.State == core.StepRunning && step.Elapsed() >= time.Second: + case step.State == core.StepDone && step.Elapsed() >= 100*time.Millisecond: + default: return D{} } - return th.MonoLabel(SizeCaption, th.P.TextDim, - FormatLatency(step.Elapsed())).Layout(gtx) + col := th.P.TextDim + if step.State == core.StepRunning && step.Elapsed() >= stuckAfter { + col = th.P.Warn + } + return th.MonoLabel(SizeCaption, col, FormatLatency(step.Elapsed())).Layout(gtx) }), ) }) @@ -238,60 +246,72 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D { func (s *splashView) footer(a *App, gtx C, st core.State) D { th := a.th + stuck := stalled(st) + return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D { - switch st.Phase { - case core.PhaseError: - // Error text and the retry button sit side by side: stacking them - // pushes the only control on this screen below the fold on a short - // window. - return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx C) D { - l := th.Text(SizeCaption, th.P.Fail, st.Err) - l.MaxLines = 4 + return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx, + layout.Rigid(func(gtx C) D { + switch st.Phase { + case core.PhaseError: + // Error text and the retry button sit side by side: stacking + // them pushes the only control on this screen below the fold + // on a short window. + return layout.Flex{Alignment: layout.Middle}.Layout(gtx, + layout.Flexed(1, func(gtx C) D { + l := th.Text(SizeCaption, th.P.Fail, st.Err) + l.MaxLines = 4 + return l.Layout(gtx) + }), + HGap(SpaceMD), + layout.Rigid(func(gtx C) D { + gtx.Constraints.Min.X = 0 + return th.Button(gtx, &s.retry, ButtonStyle{ + Kind: ButtonPrimary, + Text: th.T(KRetry), + Icon: IconRefresh, + }) + }), + ) + + case core.PhaseRetrying: + msg := th.T(KSplashRetry) + if st.Err != "" { + msg = st.Err + } + l := th.Text(SizeCaption, th.P.Warn, msg) + l.Alignment = text.Middle + l.MaxLines = 3 return l.Layout(gtx) - }), - HGap(SpaceMD), - layout.Rigid(func(gtx C) D { - gtx.Constraints.Min.X = 0 - return th.Button(gtx, &s.retry, ButtonStyle{ - Kind: ButtonPrimary, - Text: th.T(KRetry), - Icon: IconRefresh, - }) - }), - ) - case core.PhaseRetrying: - msg := th.T(KSplashRetry) - if st.Err != "" { - msg = st.Err - } - l := th.Text(SizeCaption, th.P.Warn, msg) - l.Alignment = text.Middle - l.MaxLines = 3 - return l.Layout(gtx) - - default: - l := th.Caption(th.T(KSplashHint)) - l.Alignment = text.Middle - return l.Layout(gtx) - } + default: + hint, col := th.T(KSplashHint), th.P.TextDim + if stuck { + hint, col = th.T(KSplashStuckHint), th.P.Warn + } + l := th.Text(SizeCaption, col, hint) + l.Alignment = text.Middle + l.MaxLines = 3 + return l.Layout(gtx) + } + }), + VGap(SpaceMD), + layout.Rigid(func(gtx C) D { + if a.opt.Logs == nil { + return D{} + } + // Promoted once something looks stuck: that is the moment the + // log is worth exporting. + kind := ButtonGhost + if stuck || st.Phase == core.PhaseError { + kind = ButtonSubtle + } + gtx.Constraints.Min.X = 0 + return th.Button(gtx, &s.export, ButtonStyle{ + Kind: kind, + Text: th.T(KSplashExportLog), + Icon: IconSave, + }) + }), + ) }) } - -// --------------------------------------------------------------------------- -// Shared: a translucent panel backdrop -// --------------------------------------------------------------------------- - -// glassPanel fills the current bounds with a translucent surface plus border. -// It is what makes the log overlay readable over whatever is behind it while -// still showing that something is behind it. -func glassPanel(t *Theme, gtx C, size image.Point, radius float32) { - r := int(radius) - bg := t.P.BgElevated - bg.A = 0xE0 - paint.FillShape(gtx.Ops, bg, clip.UniformRRect(image.Rectangle{Max: size}, r).Op(gtx.Ops)) - spec := clip.UniformRRect(image.Rectangle{Max: size}, r).Path(gtx.Ops) - paint.FillShape(gtx.Ops, WithAlpha(t.P.BorderHi, 0.8), - clip.Stroke{Path: spec, Width: 1}.Op()) -} diff --git a/gui/widgets.go b/gui/widgets.go index ad3514e..e33e423 100644 --- a/gui/widgets.go +++ b/gui/widgets.go @@ -139,21 +139,12 @@ func Circle(gtx C, diameter int, col color.NRGBA) D { // window must settle at zero frames per second, not a slow trickle. const animFrame = 40 * time.Millisecond -// animSlowFrame is the cadence for ambient motion with a multi-second cycle, -// where 12fps is indistinguishable from 25 but costs half as much. -const animSlowFrame = 80 * time.Millisecond - // animate requests the next animation frame at the capped rate. Every animated // widget in this package goes through it. func animate(gtx C) { gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animFrame)}) } -// animateSlow is [animate] for slow, decorative motion. -func animateSlow(gtx C) { - gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animSlowFrame)}) -} - // Spacer returns a fixed-size gap. func Spacer(v unit.Dp) layout.Spacer { return layout.Spacer{Height: v, Width: v} } @@ -167,6 +158,64 @@ func HGap(v unit.Dp) layout.FlexChild { return layout.Rigid(layout.Spacer{Width: v}.Layout) } +// WrapRow lays children out left to right, starting a new line whenever the +// next child would not fit. gap is the vertical space between lines; horizontal +// spacing is left to the children's own insets. +// +// Gio's Flex does not wrap — it divides the available space among its children +// and lets the overflow clip — and gioui.org/x (which has outlay.FlowWrap) is +// not a dependency, so this measures each child with op.Record and packs the +// results greedily. Children are recorded once and replayed at their final +// offset, so the cost is one layout pass, not two. +func WrapRow(gtx C, gap unit.Dp, children []layout.Widget) D { + if len(children) == 0 { + return D{} + } + maxW := gtx.Constraints.Max.X + + // Each child is measured against the full width but with no minimum, so a + // child wider than the row still gets a line to itself rather than a + // negative constraint. + cgtx := gtx + cgtx.Constraints.Min = image.Point{} + + type placed struct { + call op.CallOp + dims D + x, y int + } + var ( + items []placed + rowW, rowH int + total, lineNo int + ) + vgap := gtx.Dp(gap) + + for _, w := range children { + macro := op.Record(gtx.Ops) + dims := w(cgtx) + call := macro.Stop() + + if rowW > 0 && rowW+dims.Size.X > maxW { + // Commit the line and start the next one. + total += rowH + vgap + rowW, rowH = 0, 0 + lineNo++ + } + items = append(items, placed{call: call, dims: dims, x: rowW, y: total}) + rowW += dims.Size.X + rowH = max(rowH, dims.Size.Y) + } + total += rowH + + for _, it := range items { + off := op.Offset(image.Pt(it.x, it.y)).Push(gtx.Ops) + it.call.Add(gtx.Ops) + off.Pop() + } + return D{Size: image.Pt(maxW, total)} +} + // Divider draws a hairline separator. func (t *Theme) Divider(gtx C) D { h := max(gtx.Dp(1), 1) diff --git a/netdiag/egress.go b/netdiag/egress.go index 34b33cc..e27c105 100644 --- a/netdiag/egress.go +++ b/netdiag/egress.go @@ -130,6 +130,7 @@ func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Log egSortObservations(rep.Observations) rep.UniqueIPs = egUniqueIPs(rep.Observations) rep.Divergent = egDivergent(rep.UniqueIPs) + rep.DivergentSTUN = egDivergentSTUN(rep.Observations) egFinish(&rep) log.With( @@ -221,17 +222,26 @@ func egSortObservations(os []EgressObservation) { // egUniqueIPs returns the deduplicated, sorted set of valid addresses. func egUniqueIPs(os []EgressObservation) []netip.Addr { - seen := make(map[netip.Addr]struct{}, len(os)) - var out []netip.Addr + ips := make([]netip.Addr, 0, len(os)) for _, o := range os { - if !o.IP.IsValid() { + ips = append(ips, o.IP) + } + return egDedupAddrs(ips) +} + +// egDedupAddrs drops invalid and repeated addresses and sorts the rest. +func egDedupAddrs(ips []netip.Addr) []netip.Addr { + seen := make(map[netip.Addr]struct{}, len(ips)) + var out []netip.Addr + for _, ip := range ips { + if !ip.IsValid() { continue } - if _, dup := seen[o.IP]; dup { + if _, dup := seen[ip]; dup { continue } - seen[o.IP] = struct{}{} - out = append(out, o.IP) + seen[ip] = struct{}{} + out = append(out, ip) } sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 }) return out @@ -261,6 +271,22 @@ func egDivergent(ips []netip.Addr) bool { return len(v4) > 1 || len(v6) > 1 } +// egDivergentSTUN applies the same test to the STUN observations alone. +// +// Only these travel the UDP path Tailscale actually uses, so a split visible +// here is the one that costs you a direct connection. HTTP-only disagreement +// says something about the browser path, not the tunnel. +func egDivergentSTUN(obs []EgressObservation) bool { + var ips []netip.Addr + for _, o := range obs { + if o.Method != MethodSTUN || o.Err != "" || !o.IP.IsValid() { + continue + } + ips = append(ips, o.IP.Unmap()) + } + return egDivergent(egDedupAddrs(ips)) +} + // egFinish derives Status and the one-line Chinese Summary from the collected // addresses. It is called again by [AnnotateGeo] once geolocation is known, so // it must stay idempotent. @@ -270,6 +296,10 @@ func egFinish(rep *EgressReport) { switch { case len(rep.UniqueIPs) == 0: rep.Status = StatusFail + case rep.DivergentSTUN: + // The UDP egress itself varies, which is what actually costs a direct + // connection — a stronger claim than "some probe disagreed". + rep.Status = StatusFail case rep.Divergent: rep.Status = StatusWarn default: @@ -291,8 +321,15 @@ func egFinish(rep *EgressReport) { if len(v6) > 1 { parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s)", len(v6), egJoinAddrs(v6, 4))) } - fmt.Fprintf(&b, "出口 IP 不一致:%s,代理、VPN 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路", - strings.Join(parts, ";")) + if rep.DivergentSTUN { + fmt.Fprintf(&b, "出口 IP 不一致:%s,STUN 探测本身就看到多个地址,代理、VPN 或多线接入正在拆分 UDP 流量,对端看到的地址取决于走哪条链路", + strings.Join(parts, ";")) + } else { + // HTTP saw a split that STUN did not: the web path is proxied but + // the UDP path Tailscale uses may well be intact. + fmt.Fprintf(&b, "出口 IP 不一致:%s,仅 HTTP 探测存在差异,STUN(UDP)出口一致,多为浏览器代理或分流规则所致,通常不影响打洞", + strings.Join(parts, ";")) + } default: var parts []string diff --git a/netdiag/egress_test.go b/netdiag/egress_test.go new file mode 100644 index 0000000..cb80f7b --- /dev/null +++ b/netdiag/egress_test.go @@ -0,0 +1,154 @@ +package netdiag + +import ( + "net/netip" + "testing" +) + +func obs(m EgressMethod, ip string) EgressObservation { + o := EgressObservation{Method: m} + if ip != "" { + o.IP = netip.MustParseAddr(ip) + } + return o +} + +// TestEgressDivergenceSeverity pins the distinction the verdict depends on: +// STUN disagreeing with itself is a hard failure for hole punching, whereas +// HTTP-only disagreement is a proxy artefact and must stay a warning. +func TestEgressDivergenceSeverity(t *testing.T) { + cases := []struct { + name string + obs []EgressObservation + wantDivergent bool + wantSTUN bool + wantStatus Status + }{ + { + name: "single egress", + obs: []EgressObservation{obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv4, "1.2.3.4")}, + wantStatus: StatusOK, + }, + { + name: "dual stack is not divergence", + obs: []EgressObservation{ + obs(MethodSTUN, "1.2.3.4"), obs(MethodHTTPv6, "2001:db8::1"), + }, + wantStatus: StatusOK, + }, + { + name: "http-only split warns", + obs: []EgressObservation{ + obs(MethodSTUN, "1.2.3.4"), + obs(MethodHTTPv4, "5.6.7.8"), + }, + wantDivergent: true, + wantSTUN: false, + wantStatus: StatusWarn, + }, + { + name: "stun split fails", + obs: []EgressObservation{ + obs(MethodSTUN, "1.2.3.4"), + obs(MethodSTUN, "5.6.7.8"), + }, + wantDivergent: true, + wantSTUN: true, + wantStatus: StatusFail, + }, + { + name: "proxy split alone stays a warning", + obs: []EgressObservation{ + obs(MethodSTUN, "1.2.3.4"), + obs(MethodHTTPProxy, "9.9.9.9"), + }, + wantDivergent: true, + wantSTUN: false, + wantStatus: StatusWarn, + }, + { + name: "no observations fails", + obs: []EgressObservation{obs(MethodSTUN, "")}, + wantStatus: StatusFail, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rep := EgressReport{Observations: tc.obs} + rep.UniqueIPs = egUniqueIPs(rep.Observations) + rep.Divergent = egDivergent(rep.UniqueIPs) + rep.DivergentSTUN = egDivergentSTUN(rep.Observations) + egFinish(&rep) + + if rep.Divergent != tc.wantDivergent { + t.Errorf("Divergent = %v, want %v", rep.Divergent, tc.wantDivergent) + } + if rep.DivergentSTUN != tc.wantSTUN { + t.Errorf("DivergentSTUN = %v, want %v", rep.DivergentSTUN, tc.wantSTUN) + } + if rep.Status != tc.wantStatus { + t.Errorf("Status = %v, want %v (summary: %s)", rep.Status, tc.wantStatus, rep.Summary) + } + }) + } +} + +// A STUN observation that errored carries no address and must not be mistaken +// for a second egress. +func TestEgressDivergentSTUNIgnoresErrors(t *testing.T) { + o := []EgressObservation{ + obs(MethodSTUN, "1.2.3.4"), + {Method: MethodSTUN, Err: "timeout"}, + } + if egDivergentSTUN(o) { + t.Error("a failed STUN probe must not count as a second egress IP") + } +} + +// egFinish runs again after geolocation, so it must not drift. +func TestEgFinishIdempotent(t *testing.T) { + rep := EgressReport{Observations: []EgressObservation{ + obs(MethodSTUN, "1.2.3.4"), obs(MethodSTUN, "5.6.7.8"), + }} + rep.UniqueIPs = egUniqueIPs(rep.Observations) + rep.Divergent = egDivergent(rep.UniqueIPs) + rep.DivergentSTUN = egDivergentSTUN(rep.Observations) + + egFinish(&rep) + first, status := rep.Summary, rep.Status + egFinish(&rep) + + if rep.Summary != first || rep.Status != status { + t.Errorf("egFinish is not idempotent:\n first: %s (%v)\nsecond: %s (%v)", + first, status, rep.Summary, rep.Status) + } +} + +// TestHeadlineDivergence checks the two verdict strings the user sees. +// +// The report is otherwise healthy: earlier branches (blocked UDP, symmetric +// NAT, unreachable overseas) all outrank egress and would mask it. +func healthyReport(eg EgressReport) *Report { + return &Report{ + UDP: UDPReport{V4OK: true}, + NAT: NATReport{Type: NATFullCone}, + Overseas: OverseasReport{Status: StatusOK}, + Egress: eg, + } +} + +func TestHeadlineDivergence(t *testing.T) { + strong := healthyReport(EgressReport{Divergent: true, DivergentSTUN: true}) + if got, lvl := headline(strong); got != "STUN 检测到多个出口 IP,代理或分流工具正在影响连接" { + t.Errorf("strong headline = %q", got) + } else if lvl != StatusFail { + t.Errorf("strong headline severity = %v, want fail", lvl) + } + weak := healthyReport(EgressReport{Divergent: true}) + if got, lvl := headline(weak); got != "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接" { + t.Errorf("weak headline = %q", got) + } else if lvl != StatusWarn { + t.Errorf("weak headline severity = %v, want warn", lvl) + } +} diff --git a/netdiag/run.go b/netdiag/run.go index 0e48d98..6930481 100644 --- a/netdiag/run.go +++ b/netdiag/run.go @@ -214,7 +214,7 @@ func Run(ctx context.Context, opt Options) *Report { rep.Egress.Status, rep.Tailscale.Status, ) - rep.Headline = headline(rep) + rep.Headline, rep.HeadlineStatus = headline(rep) logger.Info("diagnostics finished", "took", rep.Duration.Round(time.Millisecond), "status", rep.Status.String(), @@ -232,29 +232,40 @@ func stepTitle(key string) string { return key } -// headline picks the single most consequential finding. The ordering is by how -// badly each condition breaks the thing this app exists to do — carry game -// traffic between peers — not by section order. -func headline(r *Report) string { +// headline picks the single most consequential finding, together with that +// sentence's own severity. The ordering is by how badly each condition breaks +// the thing this app exists to do — carry game traffic between peers — not by +// section order. +// +// The severity is returned separately because Report.Status is the worst of +// every section: an unrelated port-mapping failure would otherwise render a +// "may be affecting" headline in the same red as "is affecting", which is +// exactly the overstatement this split exists to prevent. +func headline(r *Report) (string, Status) { switch { case r.NAT.Type == NATUDPBlocked: - return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继" + return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继", StatusFail case !r.UDP.V4OK && !r.UDP.V6OK: - return "UDP 探测全部失败,请检查防火墙或网络策略" + return "UDP 探测全部失败,请检查防火墙或网络策略", StatusFail case r.NAT.Type == NATSymmetric: - return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继" + return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继", StatusFail case r.Overseas.Status == StatusFail: - return "无法访问任何外部网络" + return "无法访问任何外部网络", StatusFail case r.Overseas.Status == StatusWarn: - return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响" + return "境外网络不可达,Tailscale 控制面与 DERP 可能受影响", StatusWarn + case r.Egress.DivergentSTUN: + // STUN itself saw several egress addresses: the UDP path Tailscale uses + // really does vary per flow. + return "STUN 检测到多个出口 IP,代理或分流工具正在影响连接", StatusFail case r.Egress.Divergent: - return "检测到多个出口 IP,代理或分流工具正在影响连接" + // Only the web path disagreed; UDP may well be intact. + return "仅 HTTP 探测到多个出口 IP,代理或分流工具可能影响连接", StatusWarn case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict: - return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般" + return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般", StatusWarn case r.Status == StatusOK: - return "网络状况良好,具备直连条件" + return "网络状况良好,具备直连条件", StatusOK default: - return "诊断完成,存在若干需要注意的项目" + return "诊断完成,存在若干需要注意的项目", r.Status } } @@ -319,7 +330,15 @@ func (r *Report) Text() string { status = "OK" detail = p.Mapped.String() + " " + p.RTT.Round(time.Millisecond).String() } - w(" %-4s %-34s %-5s %s\n", status, p.Target, p.Region, detail) + // Name the server, then the address actually probed — a shared bundle + // has to be readable without the reader resolving IPs by hand. + target := p.Host + if target == "" { + target = p.Target + } else if p.Target != "" && p.Target != p.Host { + target += " (" + p.Target + ")" + } + w(" %-4s %-46s %-5s %s\n", status, target, p.Region, detail) } b.WriteByte('\n') @@ -389,8 +408,10 @@ func (r *Report) Text() string { if r.Egress.Summary != "" { w("%s\n", r.Egress.Summary) } - if r.Egress.Divergent { - w("!! 不同探测方式得到了不同的公网 IP,通常说明有代理或分流在生效\n") + if r.Egress.DivergentSTUN { + w("!! STUN(UDP) 本身看到多个公网 IP,直连打洞会受影响\n") + } else if r.Egress.Divergent { + w("!! 仅 HTTP 探测得到了不同的公网 IP,STUN(UDP) 出口一致,通常不影响打洞\n") } for _, o := range r.Egress.Observations { val := o.IP.String() diff --git a/netdiag/stun.go b/netdiag/stun.go index 9c65564..a35a6ea 100644 --- a/netdiag/stun.go +++ b/netdiag/stun.go @@ -647,6 +647,7 @@ func ProbeUDP(ctx context.Context, servers []STUNServer, logger *slog.Logger) UD mu.Lock() per[i] = []udpAttempt{{ probe: UDPProbe{ + Host: srv.Host, Target: srv.Host, Name: srv.Name, Region: srv.Region, @@ -734,6 +735,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [ if err != nil { return []udpAttempt{{ probe: UDPProbe{ + Host: srv.Host, Target: srv.Host, Name: srv.Name, Region: srv.Region, @@ -756,7 +758,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [ doneV4 = true } dst := netip.AddrPortFrom(a, port) - p := UDPProbe{Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)} + p := UDPProbe{Host: srv.Host, Target: dst.String(), Name: srv.Name, Region: srv.Region, Port: int(port)} pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout) msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval) cancel() diff --git a/netdiag/types.go b/netdiag/types.go index b5909c1..cb3b518 100644 --- a/netdiag/types.go +++ b/netdiag/types.go @@ -130,6 +130,11 @@ type STUNResult struct { // UDPProbe is a plain "can I send and receive UDP here" datapoint. type UDPProbe struct { + // Host is the configured "hostname:port", kept alongside the resolved + // Target so the UI can name the server rather than an anonymous address. + Host string + // Target is the address actually probed, "ip:port". A server reachable over + // both families yields one probe per family, and only this tells them apart. Target string Name string Region Region @@ -317,6 +322,15 @@ type EgressReport struct { // intercepting part of the traffic. Having both an IPv4 and an IPv6 egress // is ordinary dual stack and does not set this. Divergent bool + // DivergentSTUN narrows Divergent to the case that actually breaks NAT + // traversal: STUN itself — plain UDP, the same path Tailscale punches + // through — saw more than one address in a family. That means the UDP + // egress genuinely varies per flow. + // + // Divergence seen only by the HTTP probes is a weaker signal. An HTTP proxy + // or split-tunnel rule can rewrite web traffic while leaving UDP alone, so + // it warrants a warning, not a verdict. + DivergentSTUN bool // Countries is the set of distinct countries seen, sorted. Countries []string Status Status @@ -386,6 +400,11 @@ type Report struct { // Headline is the single most important sentence about this report. Headline string + // HeadlineStatus is the severity of Headline specifically, which is not + // always Status. Status is the worst of every section, so a report with an + // unrelated failure elsewhere would otherwise paint a merely-cautionary + // headline in alarm red and overstate what was actually found. + HeadlineStatus Status // Status is the worst status across all sections. Status Status }