fix gui
This commit is contained in:
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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{})
|
||||
|
||||
|
||||
-539
@@ -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>[/MOTD][AD]<port>[/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+91
-27
@@ -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,17 +70,22 @@ type App struct {
|
||||
|
||||
overview *overviewPage
|
||||
peers *peersPage
|
||||
lan *lanPage
|
||||
diag *diagPage
|
||||
logs *logsPage
|
||||
settings *settingsPage
|
||||
|
||||
splash *splashView
|
||||
overlay *logOverlay
|
||||
|
||||
overlayBtn 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
|
||||
toastUntil time.Time
|
||||
@@ -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,9 +171,33 @@ 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
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
+80
-23
@@ -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{}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+62
-7
@@ -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
|
||||
|
||||
+29
-56
@@ -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: "设置",
|
||||
@@ -254,7 +244,8 @@ var zhStrings = [kCount]string{
|
||||
KStepMonitors: "启动状态监控",
|
||||
KStepReady: "准备就绪",
|
||||
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
|
||||
KSplashLogHint: "实时日志(截图时可一并保留)",
|
||||
KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查",
|
||||
KSplashExportLog: "导出日志",
|
||||
KSplashRetry: "启动失败,正在重试",
|
||||
|
||||
KYes: "是",
|
||||
@@ -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",
|
||||
@@ -447,7 +428,8 @@ var enStrings = [kCount]string{
|
||||
KStepMonitors: "Starting monitors",
|
||||
KStepReady: "Ready",
|
||||
KSplashHint: "The first tailnet join can take a dozen seconds",
|
||||
KSplashLogHint: "Live log (stays visible in screenshots)",
|
||||
KSplashStuckHint: "This step is taking unusually long — export the log to investigate",
|
||||
KSplashExportLog: "Export log",
|
||||
KSplashRetry: "Startup failed, retrying",
|
||||
|
||||
KYes: "Yes",
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
-269
@@ -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()
|
||||
}
|
||||
+177
-10
@@ -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
|
||||
|
||||
-190
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+33
-18
@@ -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)
|
||||
}
|
||||
// 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)
|
||||
|
||||
+61
-40
@@ -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 {
|
||||
|
||||
+82
-16
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
+257
@@ -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.<tag>]] 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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+111
-91
@@ -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
|
||||
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.
|
||||
|
||||
// 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.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}.Layout(gtx, func(gtx C) D {
|
||||
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,12 +246,16 @@ 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 {
|
||||
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.
|
||||
// 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)
|
||||
@@ -272,26 +284,34 @@ func (s *splashView) footer(a *App, gtx C, st core.State) D {
|
||||
return l.Layout(gtx)
|
||||
|
||||
default:
|
||||
l := th.Caption(th.T(KSplashHint))
|
||||
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())
|
||||
}
|
||||
|
||||
+58
-9
@@ -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)
|
||||
|
||||
+44
-7
@@ -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 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路",
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+38
-17
@@ -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()
|
||||
|
||||
+3
-1
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user