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 —
|
// 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,
|
// 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
|
// diagnostic run, plus a searchable log view that can be shared to a paste
|
||||||
// service for support.
|
// service for support.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package core
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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) {
|
func TestPeerMonitorSnapshotIsIsolated(t *testing.T) {
|
||||||
m := NewPeerMonitor(nil, nil, slog.New(slog.DiscardHandler), PeerMonitorOptions{})
|
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{}
|
refreshStatus chan struct{}
|
||||||
refreshPing chan struct{}
|
refreshPing chan struct{}
|
||||||
|
refreshLinks chan struct{}
|
||||||
|
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
raw *ipnstate.Status // last good status, nil until the first poll lands
|
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(),
|
opt: opt.withDefaults(),
|
||||||
refreshStatus: make(chan struct{}, 1),
|
refreshStatus: make(chan struct{}, 1),
|
||||||
refreshPing: make(chan struct{}, 1),
|
refreshPing: make(chan struct{}, 1),
|
||||||
|
refreshLinks: make(chan struct{}, 1),
|
||||||
hist: make(map[string][]PeerSample),
|
hist: make(map[string][]PeerSample),
|
||||||
last: make(map[string]pingOutcome),
|
last: make(map[string]pingOutcome),
|
||||||
links: make(map[netip.Addr][]string),
|
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.
|
// 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() {
|
func (m *PeerMonitor) RefreshNow() {
|
||||||
kick(m.refreshStatus)
|
kick(m.refreshStatus)
|
||||||
kick(m.refreshPing)
|
kick(m.refreshPing)
|
||||||
|
kick(m.refreshLinks)
|
||||||
}
|
}
|
||||||
|
|
||||||
// kick delivers a coalescing wakeup: a pending signal is enough.
|
// kick delivers a coalescing wakeup: a pending signal is enough.
|
||||||
@@ -534,6 +541,8 @@ func (m *PeerMonitor) linkLoop(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
m.resolveLinks(ctx)
|
m.resolveLinks(ctx)
|
||||||
|
case <-m.refreshLinks:
|
||||||
|
m.resolveLinks(ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ type State struct {
|
|||||||
Config *Config
|
Config *Config
|
||||||
Server *tsnet.Server
|
Server *tsnet.Server
|
||||||
Peers *PeerMonitor
|
Peers *PeerMonitor
|
||||||
Lan *LanScanner
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ready reports whether the service finished booting.
|
// 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 := NewPeerMonitor(srv, cfg.Connect, s.logger, PeerMonitorOptions{})
|
||||||
peers.Start(ctx)
|
peers.Start(ctx)
|
||||||
|
|
||||||
lan := NewLanScanner(s.logger.With("from", "lan_scan"))
|
|
||||||
lan.SetSelfEntries(LanEntriesFromRules(cfg.Connect))
|
|
||||||
lan.Start(ctx)
|
|
||||||
|
|
||||||
s.update(func(st *State) {
|
s.update(func(st *State) {
|
||||||
st.Peers = peers
|
st.Peers = peers
|
||||||
st.Lan = lan
|
|
||||||
})
|
})
|
||||||
s.stepDone(StepKeyMonitors, nil)
|
s.stepDone(StepKeyMonitors, nil)
|
||||||
|
|
||||||
@@ -457,7 +451,6 @@ func (s *Supervisor) teardown() {
|
|||||||
srv := s.state.Server
|
srv := s.state.Server
|
||||||
s.state.Server = nil
|
s.state.Server = nil
|
||||||
s.state.Peers = nil
|
s.state.Peers = nil
|
||||||
s.state.Lan = nil
|
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
if srv != nil {
|
if srv != nil {
|
||||||
|
|||||||
+93
-29
@@ -12,12 +12,12 @@ import (
|
|||||||
"gioui.org/app"
|
"gioui.org/app"
|
||||||
"gioui.org/font"
|
"gioui.org/font"
|
||||||
"gioui.org/io/clipboard"
|
"gioui.org/io/clipboard"
|
||||||
|
"gioui.org/io/system"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
"gioui.org/op/paint"
|
"gioui.org/op/paint"
|
||||||
"gioui.org/text"
|
"gioui.org/text"
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
|
|
||||||
"tslink/core"
|
"tslink/core"
|
||||||
@@ -43,7 +43,6 @@ type pageID int
|
|||||||
const (
|
const (
|
||||||
pageOverview pageID = iota
|
pageOverview pageID = iota
|
||||||
pagePeers
|
pagePeers
|
||||||
pageLan
|
|
||||||
pageDiag
|
pageDiag
|
||||||
pageLogs
|
pageLogs
|
||||||
pageSettings
|
pageSettings
|
||||||
@@ -71,16 +70,21 @@ type App struct {
|
|||||||
|
|
||||||
overview *overviewPage
|
overview *overviewPage
|
||||||
peers *peersPage
|
peers *peersPage
|
||||||
lan *lanPage
|
|
||||||
diag *diagPage
|
diag *diagPage
|
||||||
logs *logsPage
|
logs *logsPage
|
||||||
settings *settingsPage
|
settings *settingsPage
|
||||||
|
|
||||||
splash *splashView
|
splash *splashView
|
||||||
overlay *logOverlay
|
|
||||||
|
|
||||||
overlayBtn widget.Clickable
|
themeBtn widget.Clickable
|
||||||
themeBtn widget.Clickable
|
|
||||||
|
// grown records that the window has already been resized from its compact
|
||||||
|
// splash dimensions to the full shell. The splash sizes the window to just
|
||||||
|
// its progress bar and checklist, so the transition to the shell has to grow
|
||||||
|
// it — exactly once, or a user who resized the window would have it snapped
|
||||||
|
// back every time the service restarted. Atomic because the resize runs on
|
||||||
|
// the watch goroutine, not the UI one.
|
||||||
|
grown atomic.Bool
|
||||||
|
|
||||||
toastMsg string
|
toastMsg string
|
||||||
toastLevel StatusLevel
|
toastLevel StatusLevel
|
||||||
@@ -121,19 +125,16 @@ func New(opt Options) *App {
|
|||||||
a.nav = []navEntry{
|
a.nav = []navEntry{
|
||||||
{id: pageOverview, label: KNavOverview, icon: IconGrid},
|
{id: pageOverview, label: KNavOverview, icon: IconGrid},
|
||||||
{id: pagePeers, label: KNavPeers, icon: IconNodes},
|
{id: pagePeers, label: KNavPeers, icon: IconNodes},
|
||||||
{id: pageLan, label: KNavLan, icon: IconBroadcast},
|
|
||||||
{id: pageDiag, label: KNavDiag, icon: IconPulse},
|
{id: pageDiag, label: KNavDiag, icon: IconPulse},
|
||||||
{id: pageLogs, label: KNavLogs, icon: IconList},
|
{id: pageLogs, label: KNavLogs, icon: IconList},
|
||||||
{id: pageSettings, label: KNavSettings, icon: IconSliders},
|
{id: pageSettings, label: KNavSettings, icon: IconSliders},
|
||||||
}
|
}
|
||||||
a.overview = newOverviewPage()
|
a.overview = newOverviewPage()
|
||||||
a.peers = newPeersPage()
|
a.peers = newPeersPage()
|
||||||
a.lan = newLanPage()
|
|
||||||
a.diag = newDiagPage(a)
|
a.diag = newDiagPage(a)
|
||||||
a.logs = newLogsPage(a)
|
a.logs = newLogsPage(a)
|
||||||
a.settings = newSettingsPage(a)
|
a.settings = newSettingsPage(a)
|
||||||
a.splash = newSplashView()
|
a.splash = newSplashView()
|
||||||
a.overlay = newLogOverlay()
|
|
||||||
return a
|
return a
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,15 +142,24 @@ func New(opt Options) *App {
|
|||||||
// closes.
|
// closes.
|
||||||
func (a *App) Run(ctx context.Context) error {
|
func (a *App) Run(ctx context.Context) error {
|
||||||
w := new(app.Window)
|
w := new(app.Window)
|
||||||
|
// Opens at splash size; grown to the shell dimensions below once the
|
||||||
|
// service is ready.
|
||||||
w.Option(
|
w.Option(
|
||||||
app.Title("tslink"),
|
app.Title("tslink"),
|
||||||
app.Size(unit.Dp(1120), unit.Dp(740)),
|
app.Size(splashWindowW, splashWindowH),
|
||||||
app.MinSize(unit.Dp(880), unit.Dp(560)),
|
app.MinSize(splashMinW, splashMinH),
|
||||||
)
|
)
|
||||||
a.win = w
|
a.win = w
|
||||||
|
|
||||||
go a.watch(ctx, w)
|
go a.watch(ctx, w)
|
||||||
go a.upgradeFonts()
|
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
|
var ops op.Ops
|
||||||
for {
|
for {
|
||||||
@@ -161,10 +171,34 @@ func (a *App) Run(ctx context.Context) error {
|
|||||||
a.applyFontUpgrade()
|
a.applyFontUpgrade()
|
||||||
a.layout(gtx)
|
a.layout(gtx)
|
||||||
e.Frame(gtx.Ops)
|
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
|
// 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
|
// screen exists partly to cover this: a 20 MB font collection takes long
|
||||||
// enough to parse that doing it inline would stall the first frame.
|
// enough to parse that doing it inline would stall the first frame.
|
||||||
@@ -295,6 +329,18 @@ func (a *App) copyToClipboard(gtx C, s string, msg string) {
|
|||||||
a.notify(msg, LevelOK)
|
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.
|
// notify shows a transient message at the bottom of the window.
|
||||||
func (a *App) notify(msg string, level StatusLevel) {
|
func (a *App) notify(msg string, level StatusLevel) {
|
||||||
a.toastMsg = msg
|
a.toastMsg = msg
|
||||||
@@ -325,9 +371,6 @@ func (a *App) layout(gtx C) D {
|
|||||||
a.current = a.nav[i].id
|
a.current = a.nav[i].id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a.overlayBtn.Clicked(gtx) {
|
|
||||||
a.overlay.visible = !a.overlay.visible
|
|
||||||
}
|
|
||||||
if a.themeBtn.Clicked(gtx) {
|
if a.themeBtn.Clicked(gtx) {
|
||||||
th.SetDark(!th.Dark)
|
th.SetDark(!th.Dark)
|
||||||
}
|
}
|
||||||
@@ -341,10 +384,6 @@ func (a *App) layout(gtx C) D {
|
|||||||
}
|
}
|
||||||
return a.shell(gtx, st)
|
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 {
|
layout.Stacked(func(gtx C) D {
|
||||||
gtx.Constraints.Min = gtx.Constraints.Max
|
gtx.Constraints.Min = gtx.Constraints.Max
|
||||||
return a.layoutToast(gtx)
|
return a.layoutToast(gtx)
|
||||||
@@ -379,8 +418,6 @@ func (a *App) page(gtx C, st core.State) D {
|
|||||||
switch a.current {
|
switch a.current {
|
||||||
case pagePeers:
|
case pagePeers:
|
||||||
return a.peers.Layout(a, gtx, st)
|
return a.peers.Layout(a, gtx, st)
|
||||||
case pageLan:
|
|
||||||
return a.lan.Layout(a, gtx, st)
|
|
||||||
case pageDiag:
|
case pageDiag:
|
||||||
return a.diag.Layout(a, gtx, st)
|
return a.diag.Layout(a, gtx, st)
|
||||||
case pageLogs:
|
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) }),
|
layout.Rigid(func(gtx C) D { return a.statusPill(gtx, st) }),
|
||||||
HGap(SpaceSM),
|
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 {
|
layout.Rigid(func(gtx C) D {
|
||||||
return th.IconButton(gtx, &a.themeBtn, IconGlobe, LevelNeutral)
|
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.
|
// ChartStyle configures the plot.
|
||||||
type ChartStyle struct {
|
type ChartStyle struct {
|
||||||
Height unit.Dp
|
Height unit.Dp
|
||||||
// Window is how far back the x axis reaches.
|
// MaxWindow caps how far back the x axis reaches. The axis is scaled to the
|
||||||
Window time.Duration
|
// data's own extent and only clamped by this, so the plot fills its width
|
||||||
// Now anchors the right edge.
|
// 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
|
Now time.Time
|
||||||
// Unit labels the y axis.
|
// Unit labels the y axis.
|
||||||
Unit string
|
Unit string
|
||||||
@@ -57,18 +60,68 @@ type Chart struct {
|
|||||||
hovering bool
|
hovering bool
|
||||||
// plot is the last plotted rectangle, used to map hover x back to a time.
|
// plot is the last plotted rectangle, used to map hover x back to a time.
|
||||||
plot image.Rectangle
|
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.
|
// 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 {
|
if !c.hovering || len(series.Points) == 0 || c.plot.Dx() <= 0 {
|
||||||
return -1
|
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())
|
frac := float64(c.hover.X-float32(c.plot.Min.X)) / float64(c.plot.Dx())
|
||||||
if frac < 0 || frac > 1 {
|
if frac < 0 || frac > 1 {
|
||||||
return -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)
|
best, bestDelta := -1, time.Duration(math.MaxInt64)
|
||||||
for i, p := range series.Points {
|
for i, p := range series.Points {
|
||||||
d := p.At.Sub(target)
|
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
|
// 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.
|
// crosshair does not snap to a distant point in a sparse series.
|
||||||
if bestDelta > st.Window/20 {
|
if bestDelta > span/20 {
|
||||||
return -1
|
return -1
|
||||||
}
|
}
|
||||||
return best
|
return best
|
||||||
@@ -89,9 +142,6 @@ func (c *Chart) HoverIndex(series ChartSeries, st ChartStyle) int {
|
|||||||
|
|
||||||
// Layout draws the chart.
|
// Layout draws the chart.
|
||||||
func (c *Chart) Layout(t *Theme, gtx C, st ChartStyle, series []ChartSeries) D {
|
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() {
|
if st.Now.IsZero() {
|
||||||
st.Now = gtx.Now
|
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)
|
c.update(gtx, size)
|
||||||
|
|
||||||
yMax := niceMax(maxVisible(series))
|
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 {
|
for _, s := range series {
|
||||||
if s.Hidden || len(s.Points) == 0 {
|
if s.Hidden || len(s.Points) == 0 {
|
||||||
continue
|
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}
|
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
|
const rows = 4
|
||||||
lineCol := WithAlpha(t.P.Border, 0.9)
|
lineCol := WithAlpha(t.P.Border, 0.9)
|
||||||
for i := 0; i <= rows; i++ {
|
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
|
frac float64
|
||||||
txt string
|
txt string
|
||||||
}{
|
}{
|
||||||
{0, "-" + FormatDuration(st.Window)},
|
{0, "-" + FormatDuration(span)},
|
||||||
{0.5, "-" + FormatDuration(st.Window/2)},
|
{0.5, "-" + FormatDuration(span/2)},
|
||||||
{1, "now"},
|
{1, "now"},
|
||||||
}
|
}
|
||||||
if t.Lang == LangZH {
|
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 {
|
if !c.hovering {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -371,11 +421,11 @@ func (c *Chart) drawCrosshair(t *Theme, gtx C, plot image.Rectangle, series []Ch
|
|||||||
if s.Hidden {
|
if s.Hidden {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
i := c.HoverIndex(s, st)
|
i := c.HoverIndex(s)
|
||||||
if i < 0 || !s.Points[i].OK {
|
if i < 0 || !s.Points[i].OK {
|
||||||
continue
|
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)
|
d := gtx.Dp(7)
|
||||||
off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops)
|
off := op.Offset(image.Pt(int(pt.X)-d/2, int(pt.Y)-d/2)).Push(gtx.Ops)
|
||||||
Circle(gtx, d, s.Color)
|
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
|
// 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.
|
// 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 {
|
func (t *Theme) Legend(gtx C, entries []LegendEntry, click func(i int) layout.Widget) D {
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return D{}
|
return D{}
|
||||||
}
|
}
|
||||||
children := make([]layout.FlexChild, 0, len(entries))
|
children := make([]layout.Widget, 0, len(entries))
|
||||||
for i := range 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.
|
// 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)}
|
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 {
|
layout.Rigid(func(gtx C) D {
|
||||||
if e.Value == "" {
|
if e.Value == "" {
|
||||||
return D{}
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// A pan-CJK .ttc carries SC/TC/HK/JP/KR cuts of the same design. Keeping
|
if len(faces) == 0 {
|
||||||
// only the first regular-weight face avoids paying for five near-identical
|
return nil, nil
|
||||||
// fallbacks on every glyph miss.
|
|
||||||
if len(faces) > 1 {
|
|
||||||
faces = faces[:1]
|
|
||||||
}
|
}
|
||||||
|
// 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",
|
logger.Debug("cjk font loaded",
|
||||||
"path", path,
|
"path", path,
|
||||||
"faces", len(faces),
|
"typeface", string(base.Font.Typeface),
|
||||||
|
"faces", len(out),
|
||||||
"bytes", st.Size(),
|
"bytes", st.Size(),
|
||||||
"took", time.Since(start).Round(time.Millisecond),
|
"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
|
// goCollection returns the built-in Go font faces. It exists so tests can
|
||||||
|
|||||||
+47
-74
@@ -28,7 +28,6 @@ const (
|
|||||||
// Navigation.
|
// Navigation.
|
||||||
KNavOverview
|
KNavOverview
|
||||||
KNavPeers
|
KNavPeers
|
||||||
KNavLan
|
|
||||||
KNavDiag
|
KNavDiag
|
||||||
KNavLogs
|
KNavLogs
|
||||||
KNavSettings
|
KNavSettings
|
||||||
@@ -51,7 +50,8 @@ const (
|
|||||||
KStepMonitors
|
KStepMonitors
|
||||||
KStepReady
|
KStepReady
|
||||||
KSplashHint
|
KSplashHint
|
||||||
KSplashLogHint
|
KSplashStuckHint
|
||||||
|
KSplashExportLog
|
||||||
KSplashRetry
|
KSplashRetry
|
||||||
|
|
||||||
// Shared vocabulary.
|
// Shared vocabulary.
|
||||||
@@ -84,7 +84,6 @@ const (
|
|||||||
KOvTailnet
|
KOvTailnet
|
||||||
KOvSelf
|
KOvSelf
|
||||||
KOvPeersOnline
|
KOvPeersOnline
|
||||||
KOvLanServers
|
|
||||||
KOvForwardRules
|
KOvForwardRules
|
||||||
KOvConnectRules
|
KOvConnectRules
|
||||||
KOvUptime
|
KOvUptime
|
||||||
@@ -95,8 +94,8 @@ const (
|
|||||||
// Peers page.
|
// Peers page.
|
||||||
KPeersTitle
|
KPeersTitle
|
||||||
KPeersLinked
|
KPeersLinked
|
||||||
KPeersOther
|
|
||||||
KPeersEmpty
|
KPeersEmpty
|
||||||
|
KPeersResolving
|
||||||
KPeerLatency
|
KPeerLatency
|
||||||
KPeerRoute
|
KPeerRoute
|
||||||
KPeerRouteDirect
|
KPeerRouteDirect
|
||||||
@@ -123,19 +122,11 @@ const (
|
|||||||
KGraphWindow
|
KGraphWindow
|
||||||
KGraphLegendHint
|
KGraphLegendHint
|
||||||
|
|
||||||
// LAN page.
|
// Local services (overview).
|
||||||
KLanTitle
|
KSvcTitle
|
||||||
KLanSubtitle
|
KSvcSubtitle
|
||||||
KLanEmpty
|
KSvcEmpty
|
||||||
KLanListening
|
KSvcBroadcast
|
||||||
KLanMotd
|
|
||||||
KLanPort
|
|
||||||
KLanAddress
|
|
||||||
KLanSeen
|
|
||||||
KLanSelf
|
|
||||||
KLanSelfHint
|
|
||||||
KLanPackets
|
|
||||||
KLanBindError
|
|
||||||
|
|
||||||
// Diagnostics page.
|
// Diagnostics page.
|
||||||
KDiagTitle
|
KDiagTitle
|
||||||
@@ -174,6 +165,7 @@ const (
|
|||||||
KDiagEgressGeo
|
KDiagEgressGeo
|
||||||
KDiagEgressDivergent
|
KDiagEgressDivergent
|
||||||
KDiagEgressDivergentHint
|
KDiagEgressDivergentHint
|
||||||
|
KDiagEgressDivergentHTTP
|
||||||
KDiagGeoSkipped
|
KDiagGeoSkipped
|
||||||
KDiagPreferredDERP
|
KDiagPreferredDERP
|
||||||
KDiagDerpLatency
|
KDiagDerpLatency
|
||||||
@@ -211,7 +203,6 @@ const (
|
|||||||
KLogsShown
|
KLogsShown
|
||||||
KLogsDropped
|
KLogsDropped
|
||||||
KLogsIncludeDiag
|
KLogsIncludeDiag
|
||||||
KLogsOpenOverlay
|
|
||||||
|
|
||||||
// Settings.
|
// Settings.
|
||||||
KSetTheme
|
KSetTheme
|
||||||
@@ -233,7 +224,6 @@ var zhStrings = [kCount]string{
|
|||||||
|
|
||||||
KNavOverview: "概览",
|
KNavOverview: "概览",
|
||||||
KNavPeers: "节点",
|
KNavPeers: "节点",
|
||||||
KNavLan: "局域网",
|
|
||||||
KNavDiag: "网络诊断",
|
KNavDiag: "网络诊断",
|
||||||
KNavLogs: "日志",
|
KNavLogs: "日志",
|
||||||
KNavSettings: "设置",
|
KNavSettings: "设置",
|
||||||
@@ -246,16 +236,17 @@ var zhStrings = [kCount]string{
|
|||||||
KStateError: "出错",
|
KStateError: "出错",
|
||||||
KStateRetrying: "正在重试",
|
KStateRetrying: "正在重试",
|
||||||
|
|
||||||
KStepConfig: "读取配置",
|
KStepConfig: "读取配置",
|
||||||
KStepFonts: "加载字体",
|
KStepFonts: "加载字体",
|
||||||
KStepTsnet: "接入 Tailscale 网络",
|
KStepTsnet: "接入 Tailscale 网络",
|
||||||
KStepRules: "解析转发规则",
|
KStepRules: "解析转发规则",
|
||||||
KStepDiscovery: "启动局域网发现",
|
KStepDiscovery: "启动局域网发现",
|
||||||
KStepMonitors: "启动状态监控",
|
KStepMonitors: "启动状态监控",
|
||||||
KStepReady: "准备就绪",
|
KStepReady: "准备就绪",
|
||||||
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
|
KSplashHint: "首次接入 Tailscale 可能需要十几秒",
|
||||||
KSplashLogHint: "实时日志(截图时可一并保留)",
|
KSplashStuckHint: "当前步骤耗时异常,可导出日志以便排查",
|
||||||
KSplashRetry: "启动失败,正在重试",
|
KSplashExportLog: "导出日志",
|
||||||
|
KSplashRetry: "启动失败,正在重试",
|
||||||
|
|
||||||
KYes: "是",
|
KYes: "是",
|
||||||
KNo: "否",
|
KNo: "否",
|
||||||
@@ -285,7 +276,6 @@ var zhStrings = [kCount]string{
|
|||||||
KOvTailnet: "Tailnet",
|
KOvTailnet: "Tailnet",
|
||||||
KOvSelf: "本机",
|
KOvSelf: "本机",
|
||||||
KOvPeersOnline: "在线节点",
|
KOvPeersOnline: "在线节点",
|
||||||
KOvLanServers: "局域网服务器",
|
|
||||||
KOvForwardRules: "转发规则",
|
KOvForwardRules: "转发规则",
|
||||||
KOvConnectRules: "连接规则",
|
KOvConnectRules: "连接规则",
|
||||||
KOvUptime: "运行时长",
|
KOvUptime: "运行时长",
|
||||||
@@ -295,8 +285,8 @@ var zhStrings = [kCount]string{
|
|||||||
|
|
||||||
KPeersTitle: "Tailscale 节点",
|
KPeersTitle: "Tailscale 节点",
|
||||||
KPeersLinked: "已关联",
|
KPeersLinked: "已关联",
|
||||||
KPeersOther: "其他节点",
|
|
||||||
KPeersEmpty: "暂无节点",
|
KPeersEmpty: "暂无节点",
|
||||||
|
KPeersResolving: "正在解析配置中的节点",
|
||||||
KPeerLatency: "延迟",
|
KPeerLatency: "延迟",
|
||||||
KPeerRoute: "链路",
|
KPeerRoute: "链路",
|
||||||
KPeerRouteDirect: "直连",
|
KPeerRouteDirect: "直连",
|
||||||
@@ -320,21 +310,13 @@ var zhStrings = [kCount]string{
|
|||||||
KPeerTags: "标签",
|
KPeerTags: "标签",
|
||||||
KGraphTitle: "延迟图谱",
|
KGraphTitle: "延迟图谱",
|
||||||
KGraphEmpty: "正在采集延迟数据",
|
KGraphEmpty: "正在采集延迟数据",
|
||||||
KGraphWindow: "最近 20 分钟",
|
KGraphWindow: "最近",
|
||||||
KGraphLegendHint: "点击图例可隐藏对应节点",
|
KGraphLegendHint: "点击图例可隐藏对应节点",
|
||||||
|
|
||||||
KLanTitle: "局域网 Minecraft 服务器",
|
KSvcTitle: "本机服务",
|
||||||
KLanSubtitle: "监听 224.0.2.60:4445 的广播",
|
KSvcSubtitle: "tslink 在本机监听并转发到对应服务器",
|
||||||
KLanEmpty: "未发现局域网服务器",
|
KSvcEmpty: "配置中没有连接规则",
|
||||||
KLanListening: "监听中",
|
KSvcBroadcast: "已广播",
|
||||||
KLanMotd: "服务器名称",
|
|
||||||
KLanPort: "端口",
|
|
||||||
KLanAddress: "地址",
|
|
||||||
KLanSeen: "最后广播",
|
|
||||||
KLanSelf: "本机广播",
|
|
||||||
KLanSelfHint: "由 tslink 转发并广播,说明隧道已生效",
|
|
||||||
KLanPackets: "收包",
|
|
||||||
KLanBindError: "无法监听组播",
|
|
||||||
|
|
||||||
KDiagTitle: "网络诊断",
|
KDiagTitle: "网络诊断",
|
||||||
KDiagRun: "开始诊断",
|
KDiagRun: "开始诊断",
|
||||||
@@ -371,7 +353,8 @@ var zhStrings = [kCount]string{
|
|||||||
KDiagEgressIP: "出口 IP",
|
KDiagEgressIP: "出口 IP",
|
||||||
KDiagEgressGeo: "归属地",
|
KDiagEgressGeo: "归属地",
|
||||||
KDiagEgressDivergent: "出口不一致",
|
KDiagEgressDivergent: "出口不一致",
|
||||||
KDiagEgressDivergentHint: "不同探测方式得到了不同的公网 IP,通常说明有代理或分流工具在生效",
|
KDiagEgressDivergentHint: "STUN(UDP)本身就看到多个公网 IP,直连打洞会受影响",
|
||||||
|
KDiagEgressDivergentHTTP: "仅 HTTP 探测看到不同的公网 IP,STUN(UDP)出口一致,通常不影响打洞",
|
||||||
KDiagGeoSkipped: "已跳过归属地查询",
|
KDiagGeoSkipped: "已跳过归属地查询",
|
||||||
KDiagPreferredDERP: "首选 DERP",
|
KDiagPreferredDERP: "首选 DERP",
|
||||||
KDiagDerpLatency: "DERP 延迟",
|
KDiagDerpLatency: "DERP 延迟",
|
||||||
@@ -407,7 +390,6 @@ var zhStrings = [kCount]string{
|
|||||||
KLogsShown: "已显示",
|
KLogsShown: "已显示",
|
||||||
KLogsDropped: "条早期日志已被丢弃",
|
KLogsDropped: "条早期日志已被丢弃",
|
||||||
KLogsIncludeDiag: "附带诊断报告",
|
KLogsIncludeDiag: "附带诊断报告",
|
||||||
KLogsOpenOverlay: "浮层日志",
|
|
||||||
|
|
||||||
KSetTheme: "主题",
|
KSetTheme: "主题",
|
||||||
KSetThemeDark: "深色",
|
KSetThemeDark: "深色",
|
||||||
@@ -426,7 +408,6 @@ var enStrings = [kCount]string{
|
|||||||
|
|
||||||
KNavOverview: "Overview",
|
KNavOverview: "Overview",
|
||||||
KNavPeers: "Peers",
|
KNavPeers: "Peers",
|
||||||
KNavLan: "LAN",
|
|
||||||
KNavDiag: "Diagnostics",
|
KNavDiag: "Diagnostics",
|
||||||
KNavLogs: "Logs",
|
KNavLogs: "Logs",
|
||||||
KNavSettings: "Settings",
|
KNavSettings: "Settings",
|
||||||
@@ -439,16 +420,17 @@ var enStrings = [kCount]string{
|
|||||||
KStateError: "Error",
|
KStateError: "Error",
|
||||||
KStateRetrying: "Retrying",
|
KStateRetrying: "Retrying",
|
||||||
|
|
||||||
KStepConfig: "Loading configuration",
|
KStepConfig: "Loading configuration",
|
||||||
KStepFonts: "Loading fonts",
|
KStepFonts: "Loading fonts",
|
||||||
KStepTsnet: "Joining the tailnet",
|
KStepTsnet: "Joining the tailnet",
|
||||||
KStepRules: "Resolving forward rules",
|
KStepRules: "Resolving forward rules",
|
||||||
KStepDiscovery: "Starting LAN discovery",
|
KStepDiscovery: "Starting LAN discovery",
|
||||||
KStepMonitors: "Starting monitors",
|
KStepMonitors: "Starting monitors",
|
||||||
KStepReady: "Ready",
|
KStepReady: "Ready",
|
||||||
KSplashHint: "The first tailnet join can take a dozen seconds",
|
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",
|
||||||
KSplashRetry: "Startup failed, retrying",
|
KSplashExportLog: "Export log",
|
||||||
|
KSplashRetry: "Startup failed, retrying",
|
||||||
|
|
||||||
KYes: "Yes",
|
KYes: "Yes",
|
||||||
KNo: "No",
|
KNo: "No",
|
||||||
@@ -478,7 +460,6 @@ var enStrings = [kCount]string{
|
|||||||
KOvTailnet: "Tailnet",
|
KOvTailnet: "Tailnet",
|
||||||
KOvSelf: "This node",
|
KOvSelf: "This node",
|
||||||
KOvPeersOnline: "Peers online",
|
KOvPeersOnline: "Peers online",
|
||||||
KOvLanServers: "LAN servers",
|
|
||||||
KOvForwardRules: "Forward rules",
|
KOvForwardRules: "Forward rules",
|
||||||
KOvConnectRules: "Connect rules",
|
KOvConnectRules: "Connect rules",
|
||||||
KOvUptime: "Uptime",
|
KOvUptime: "Uptime",
|
||||||
@@ -488,8 +469,8 @@ var enStrings = [kCount]string{
|
|||||||
|
|
||||||
KPeersTitle: "Tailscale peers",
|
KPeersTitle: "Tailscale peers",
|
||||||
KPeersLinked: "Linked",
|
KPeersLinked: "Linked",
|
||||||
KPeersOther: "Other peers",
|
|
||||||
KPeersEmpty: "No peers yet",
|
KPeersEmpty: "No peers yet",
|
||||||
|
KPeersResolving: "Resolving the peers named in the config",
|
||||||
KPeerLatency: "Latency",
|
KPeerLatency: "Latency",
|
||||||
KPeerRoute: "Route",
|
KPeerRoute: "Route",
|
||||||
KPeerRouteDirect: "Direct",
|
KPeerRouteDirect: "Direct",
|
||||||
@@ -513,21 +494,13 @@ var enStrings = [kCount]string{
|
|||||||
KPeerTags: "Tags",
|
KPeerTags: "Tags",
|
||||||
KGraphTitle: "Latency graph",
|
KGraphTitle: "Latency graph",
|
||||||
KGraphEmpty: "Collecting latency samples",
|
KGraphEmpty: "Collecting latency samples",
|
||||||
KGraphWindow: "last 20 minutes",
|
KGraphWindow: "last",
|
||||||
KGraphLegendHint: "Click a legend entry to hide that peer",
|
KGraphLegendHint: "Click a legend entry to hide that peer",
|
||||||
|
|
||||||
KLanTitle: "Minecraft servers on the LAN",
|
KSvcTitle: "Local services",
|
||||||
KLanSubtitle: "Listening for broadcasts on 224.0.2.60:4445",
|
KSvcSubtitle: "Listening on this machine, forwarded to each server",
|
||||||
KLanEmpty: "No LAN servers discovered",
|
KSvcEmpty: "No connect rules configured",
|
||||||
KLanListening: "Listening",
|
KSvcBroadcast: "Broadcast",
|
||||||
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",
|
|
||||||
|
|
||||||
KDiagTitle: "Network diagnostics",
|
KDiagTitle: "Network diagnostics",
|
||||||
KDiagRun: "Run diagnostics",
|
KDiagRun: "Run diagnostics",
|
||||||
@@ -564,7 +537,8 @@ var enStrings = [kCount]string{
|
|||||||
KDiagEgressIP: "Egress IP",
|
KDiagEgressIP: "Egress IP",
|
||||||
KDiagEgressGeo: "Location",
|
KDiagEgressGeo: "Location",
|
||||||
KDiagEgressDivergent: "Egress mismatch",
|
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",
|
KDiagGeoSkipped: "Geolocation skipped",
|
||||||
KDiagPreferredDERP: "Preferred DERP",
|
KDiagPreferredDERP: "Preferred DERP",
|
||||||
KDiagDerpLatency: "DERP latency",
|
KDiagDerpLatency: "DERP latency",
|
||||||
@@ -600,7 +574,6 @@ var enStrings = [kCount]string{
|
|||||||
KLogsShown: "shown",
|
KLogsShown: "shown",
|
||||||
KLogsDropped: "earlier entries were dropped",
|
KLogsDropped: "earlier entries were dropped",
|
||||||
KLogsIncludeDiag: "Include diagnostics",
|
KLogsIncludeDiag: "Include diagnostics",
|
||||||
KLogsOpenOverlay: "Log overlay",
|
|
||||||
|
|
||||||
KSetTheme: "Theme",
|
KSetTheme: "Theme",
|
||||||
KSetThemeDark: "Dark",
|
KSetThemeDark: "Dark",
|
||||||
|
|||||||
@@ -257,14 +257,6 @@ func IconWarn(gtx C, size int, col color.NRGBA) D {
|
|||||||
return 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.
|
// IconChevronRight indicates an expandable row.
|
||||||
func IconChevronRight(gtx C, size int, col color.NRGBA) D {
|
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) {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/netip"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -188,7 +189,10 @@ func (p *diagPage) controlCard(a *App, gtx C, running bool, rep *netdiag.Report,
|
|||||||
col = th.P.TextPri
|
col = th.P.TextPri
|
||||||
} else if rep != nil {
|
} else if rep != nil {
|
||||||
headline = rep.Headline
|
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 := th.Text(SizeSubtitle, col, headline)
|
||||||
l.Font.Weight = font.SemiBold
|
l.Font.Weight = font.SemiBold
|
||||||
@@ -393,6 +397,132 @@ func (p *diagPage) triLabel(th *Theme, v *bool) (string, StatusLevel) {
|
|||||||
return th.T(KNo), LevelWarn
|
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 {
|
func (p *diagPage) natCard(a *App, gtx C, r netdiag.NATReport) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
hairpin, hairpinLvl := p.triLabel(th, r.Hairpin)
|
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 {
|
layout.Rigid(func(gtx C) D {
|
||||||
return th.KVList(gtx, []KV{
|
return th.KVList(gtx, []KV{
|
||||||
{Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping)},
|
{Key: th.T(KDiagNatMapping), Value: behaviorLabel(th, r.Mapping), Hint: behaviorHint(th, r.Mapping)},
|
||||||
{Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering)},
|
{Key: th.T(KDiagNatFiltering), Value: behaviorLabel(th, r.Filtering), Hint: behaviorHint(th, r.Filtering)},
|
||||||
{Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl},
|
{Key: th.T(KDiagNatHairpin), Value: hairpin, Level: hairpinLvl, Hint: hairpinHint(th, r.Hairpin)},
|
||||||
{Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl},
|
{Key: th.T(KDiagNatPortPreserve), Value: preserve, Level: preserveLvl, Hint: preserveHint(th, r.PortPreserving)},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
layout.Rigid(func(gtx C) D {
|
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...)
|
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
|
// regionTag prefixes a probe target so the CN/international split — the whole
|
||||||
// reason both are probed — is visible at a glance.
|
// reason both are probed — is visible at a glance.
|
||||||
func regionTag(th *Theme, r netdiag.Region) string {
|
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
|
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 {
|
return p.sectionCard(a, gtx, IconGlobe, th.T(KDiagSecUDP), r.Status, r.Summary, func(gtx C) D {
|
||||||
rows := []KV{
|
rows := []KV{
|
||||||
{Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl},
|
{Key: th.T(KDiagUdpV4), Value: v4, Level: v4lvl, Hint: udpFamilyHint(th, v4ok, v4n)},
|
||||||
{Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl},
|
{Key: th.T(KDiagUdpV6), Value: v6, Level: v6lvl, Hint: udpFamilyHint(th, v6ok, v6n)},
|
||||||
{
|
{
|
||||||
Key: "国内 / 境外",
|
Key: "国内 / 境外",
|
||||||
Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " +
|
Value: itoa(r.CNReachable) + "/" + itoa(r.CNTotal) + " " +
|
||||||
itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal),
|
itoa(r.IntlReachabl) + "/" + itoa(r.IntlTotal),
|
||||||
Mono: true,
|
Mono: true,
|
||||||
|
Hint: reachHint(th),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if th.Lang != LangZH {
|
if th.Lang != LangZH {
|
||||||
@@ -600,7 +762,7 @@ func (p *diagPage) udpCard(a *App, gtx C, r netdiag.UDPReport) D {
|
|||||||
if !pr.OK {
|
if !pr.OK {
|
||||||
val, level, rtt = orDash(pr.Err), LevelFail, ""
|
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...)
|
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 {
|
if !r.Divergent {
|
||||||
return D{}
|
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 layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||||
return p.callout(a, gtx, LevelWarn,
|
return p.callout(a, gtx, level, th.T(KDiagEgressDivergent), hint)
|
||||||
th.T(KDiagEgressDivergent), th.T(KDiagEgressDivergentHint))
|
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
// Geolocation first: "where do I appear to be" is the question, the
|
// 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)
|
a.notify(th.T(KError)+": "+err.Error(), LevelFail)
|
||||||
} else {
|
} else {
|
||||||
a.notify(path, LevelOK)
|
a.notify(path, LevelOK)
|
||||||
|
a.reveal(path)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if p.uploadBtn.Clicked(gtx) {
|
if p.uploadBtn.Clicked(gtx) {
|
||||||
|
|||||||
+33
-18
@@ -1,11 +1,13 @@
|
|||||||
package gui
|
package gui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/font"
|
"gioui.org/font"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
@@ -18,6 +20,8 @@ type overviewPage struct {
|
|||||||
diagBtn widget.Clickable
|
diagBtn widget.Clickable
|
||||||
peersBtn widget.Clickable
|
peersBtn widget.Clickable
|
||||||
copySelf widget.Clickable
|
copySelf widget.Clickable
|
||||||
|
// svcCopy holds one clickable per service address, allocated on demand.
|
||||||
|
svcCopy map[string]*widget.Clickable
|
||||||
}
|
}
|
||||||
|
|
||||||
func newOverviewPage() *overviewPage {
|
func newOverviewPage() *overviewPage {
|
||||||
@@ -41,19 +45,17 @@ func (p *overviewPage) Layout(a *App, gtx C, st core.State) D {
|
|||||||
if st.Peers != nil {
|
if st.Peers != nil {
|
||||||
snap = st.Peers.Snapshot()
|
snap = st.Peers.Snapshot()
|
||||||
}
|
}
|
||||||
var lanServers []core.LanServer
|
servers := buildServices(st.Config, snap)
|
||||||
if st.Lan != nil {
|
|
||||||
lanServers = st.Lan.Servers()
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.copySelf.Clicked(gtx) {
|
if p.copySelf.Clicked(gtx) {
|
||||||
a.copyToClipboard(gtx, selfAddrText(snap.Self), "")
|
a.copyToClipboard(gtx, selfAddrText(snap.Self), "")
|
||||||
}
|
}
|
||||||
|
|
||||||
items := []layout.Widget{
|
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.healthCard(a, gtx) },
|
||||||
func(gtx C) D { return p.selfCard(a, gtx, st, snap) },
|
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) },
|
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 {
|
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 {
|
if level != LevelNeutral {
|
||||||
l.Color = th.StatusColor(level)
|
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 {
|
layout.Rigid(func(gtx C) D {
|
||||||
if hint == "" {
|
if hint != "" {
|
||||||
return D{}
|
return OneLine(th.Caption(hint)).Layout(gtx)
|
||||||
}
|
}
|
||||||
return OneLine(th.Caption(hint)).Layout(gtx)
|
// Reserve the hint line even when there is no hint. These tiles
|
||||||
|
// sit in a row, and Flex does not equalise child heights, so a
|
||||||
|
// tile that skipped this line came out shorter than its
|
||||||
|
// neighbours and the row looked misaligned.
|
||||||
|
macro := op.Record(gtx.Ops)
|
||||||
|
d := OneLine(th.Caption("X")).Layout(gtx)
|
||||||
|
macro.Stop()
|
||||||
|
return D{Size: image.Pt(0, d.Size.Y)}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, lan []core.LanServer) D {
|
func (p *overviewPage) statRow(a *App, gtx C, st core.State, snap core.PeerSnapshot, servers []serviceServer) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
|
|
||||||
online, linked := 0, 0
|
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)
|
connectRules += len(rs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
selfLan := 0
|
services, broadcast := 0, 0
|
||||||
for _, s := range lan {
|
for _, srv := range servers {
|
||||||
if s.IsSelf {
|
services += len(srv.Services)
|
||||||
selfLan++
|
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 {
|
func(gtx C) D {
|
||||||
return p.statTile(a, gtx,
|
return p.statTile(a, gtx,
|
||||||
itoa(len(lan)),
|
itoa(services),
|
||||||
th.T(KOvLanServers),
|
th.T(KSvcTitle),
|
||||||
itoa(selfLan)+" "+th.T(KLanSelf),
|
itoa(broadcast)+" "+th.T(KSvcBroadcast),
|
||||||
LevelNeutral, IconServer)
|
LevelNeutral, IconServer)
|
||||||
},
|
},
|
||||||
func(gtx C) D {
|
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,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx C) D {
|
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.Font.Weight = font.Medium
|
||||||
l.MaxLines = 2
|
l.MaxLines = 2
|
||||||
return l.Layout(gtx)
|
return l.Layout(gtx)
|
||||||
|
|||||||
+62
-41
@@ -12,9 +12,12 @@ import (
|
|||||||
"tslink/core"
|
"tslink/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// chartWindow is how much latency history the graph shows. It matches the
|
// chartWindow is the most latency history the graph shows. The monitor retains
|
||||||
// monitor's default 120-sample ring at a 10s ping interval.
|
// 20 minutes, but a spike that old tells you nothing about the session you are
|
||||||
const chartWindow = 20 * time.Minute
|
// 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
|
// 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
|
// 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)
|
a.notify(th.T(KRefresh), LevelInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
linked, other := splitPeers(snap.Peers)
|
// Only nodes a config rule points at. The netmap contains every machine on
|
||||||
series := p.buildSeries(th, snap.Peers)
|
// 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.
|
// Legend clicks toggle series visibility.
|
||||||
for i := range series {
|
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]
|
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) })
|
items = append(items, func(gtx C) D { return p.chartCard(a, gtx, series) })
|
||||||
|
|
||||||
if len(linked) > 0 {
|
if len(linked) > 0 {
|
||||||
@@ -97,22 +102,21 @@ func (p *peersPage) Layout(a *App, gtx C, st core.State) D {
|
|||||||
for _, pr := range linked {
|
for _, pr := range linked {
|
||||||
items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) })
|
items = append(items, func(gtx C) D { return p.peerCard(a, gtx, st, pr) })
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
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 {
|
|
||||||
items = append(items, func(gtx C) D {
|
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
|
hint := snap.Err
|
||||||
if hint == "" {
|
if hint == "" {
|
||||||
hint = snap.BackendState
|
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 {
|
if len(pr.Samples) == 0 {
|
||||||
continue
|
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))
|
pts := make([]ChartPoint, 0, len(pr.Samples))
|
||||||
for _, s := range pr.Samples {
|
for _, s := range pr.Samples {
|
||||||
|
if s.At.Before(cutoff) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
pts = append(pts, ChartPoint{
|
pts = append(pts, ChartPoint{
|
||||||
At: s.At,
|
At: s.At,
|
||||||
Value: float64(s.Latency) / float64(time.Millisecond),
|
Value: float64(s.Latency) / float64(time.Millisecond),
|
||||||
OK: s.OK,
|
OK: s.OK,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
if len(pts) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, namedSeries{
|
out = append(out, namedSeries{
|
||||||
id: pr.ID,
|
id: pr.ID,
|
||||||
s: ChartSeries{
|
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 {
|
func (p *peersPage) chartCard(a *App, gtx C, series []namedSeries) D {
|
||||||
th := a.th
|
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 := th.Card()
|
||||||
card.Title = th.T(KGraphTitle)
|
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 {
|
card.Trailing = func(gtx C) D {
|
||||||
return th.IconButton(gtx, &p.refresh, IconRefresh, LevelNeutral)
|
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 {
|
if len(series) == 0 {
|
||||||
return th.EmptyState(gtx, IconPulse, th.T(KGraphEmpty), "")
|
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,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx C) D {
|
layout.Rigid(func(gtx C) D {
|
||||||
return p.chart.Layout(th, gtx, ChartStyle{
|
return p.chart.Layout(th, gtx, style, plot)
|
||||||
Height: 200,
|
|
||||||
Window: chartWindow,
|
|
||||||
Now: time.Now(),
|
|
||||||
Unit: "ms",
|
|
||||||
FillSingle: true,
|
|
||||||
}, plot)
|
|
||||||
}),
|
}),
|
||||||
VGap(SpaceMD),
|
VGap(SpaceMD),
|
||||||
layout.Rigid(func(gtx C) D {
|
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 {
|
func (p *peersPage) legendRow(a *App, gtx C, series []namedSeries) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
children := make([]layout.FlexChild, 0, len(series))
|
entries := make([]LegendEntry, 0, len(series))
|
||||||
for _, s := range series {
|
for _, s := range series {
|
||||||
id := s.id
|
entries = append(entries, LegendEntry{
|
||||||
entry := LegendEntry{
|
|
||||||
Name: s.s.Name,
|
Name: s.s.Name,
|
||||||
Color: s.s.Color,
|
Color: s.s.Color,
|
||||||
Hidden: p.hidden[id],
|
Hidden: p.hidden[s.id],
|
||||||
Value: lastValue(s.s.Points),
|
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 {
|
func lastValue(points []ChartPoint) string {
|
||||||
|
|||||||
+82
-16
@@ -7,6 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gioui.org/app"
|
||||||
"gioui.org/io/input"
|
"gioui.org/io/input"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
"gioui.org/op"
|
||||||
@@ -95,7 +96,6 @@ func readyState(t *testing.T) core.State {
|
|||||||
ReadyAt: time.Now().Add(-time.Hour),
|
ReadyAt: time.Now().Add(-time.Hour),
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Peers: core.NewPeerMonitor(nil, cfg.Connect, logger, core.PeerMonitorOptions{}),
|
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: 880, Y: 560}, // the declared minimum window
|
||||||
{X: 640, Y: 400}, // below minimum: compact rail, everything must still fit
|
{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 _, size := range sizes {
|
||||||
for _, page := range pages {
|
for _, page := range pages {
|
||||||
@@ -138,13 +138,32 @@ func TestSplashLayout(t *testing.T) {
|
|||||||
}
|
}
|
||||||
gtx, _ := newTestContext(size)
|
gtx, _ := newTestContext(size)
|
||||||
a.splash.Layout(a, gtx, st)
|
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 {
|
func splashTestSteps() []core.BootStep {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
return []core.BootStep{
|
return []core.BootStep{
|
||||||
@@ -184,8 +203,12 @@ func TestDiagPageWithReport(t *testing.T) {
|
|||||||
CNReachable: 3, CNTotal: 5, IntlReachabl: 1, IntlTotal: 7,
|
CNReachable: 3, CNTotal: 5, IntlReachabl: 1, IntlTotal: 7,
|
||||||
BlockedPorts: []int{19302},
|
BlockedPorts: []int{19302},
|
||||||
Probes: []netdiag.UDPProbe{
|
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")},
|
// A resolved probe: Host names the server, Target is the
|
||||||
{Target: "stun.l.google.com:19302", Region: netdiag.RegionIntl, Err: "i/o timeout"},
|
// 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{
|
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) {
|
func TestFormatHelpers(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
got, want string
|
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
|
||||||
|
}
|
||||||
+149
-129
@@ -2,28 +2,52 @@ package gui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gioui.org/f32"
|
"gioui.org/f32"
|
||||||
"gioui.org/font"
|
"gioui.org/font"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
"gioui.org/op"
|
|
||||||
"gioui.org/op/clip"
|
|
||||||
"gioui.org/op/paint"
|
|
||||||
"gioui.org/text"
|
"gioui.org/text"
|
||||||
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
|
||||||
"tslink/core"
|
"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
|
// 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
|
// 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
|
// tailscale negotiates its first connection — both slow enough that showing a
|
||||||
// bare grey rectangle would read as a hang.
|
// bare grey rectangle would read as a hang.
|
||||||
type splashView struct {
|
type splashView struct {
|
||||||
retry widget.Clickable
|
retry widget.Clickable
|
||||||
|
export widget.Clickable
|
||||||
// list keeps the panel reachable on short windows. Without it the retry
|
// 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
|
// button — the one control on this screen — falls off the bottom edge once
|
||||||
// the checklist and an error message are both showing.
|
// 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 {
|
func (s *splashView) Layout(a *App, gtx C, st core.State) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
paint.Fill(gtx.Ops, th.P.Bg)
|
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 {
|
if s.retry.Clicked(gtx) && a.opt.Supervisor != nil {
|
||||||
a.opt.Supervisor.Restart()
|
a.opt.Supervisor.Restart()
|
||||||
}
|
}
|
||||||
|
if s.export.Clicked(gtx) {
|
||||||
// The docked log sheet sits along the bottom edge, so the panel is centred
|
s.exportLogs(a)
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
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
|
||||||
// A single-element list: centred when it fits, scrollable when the
|
// too short for the checklist plus an error message.
|
||||||
// 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 material.List(th.Theme, &s.list).Layout(gtx, 1, func(gtx C, _ int) D {
|
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(400))
|
||||||
gtx.Constraints.Max.X = min(gtx.Constraints.Max.X, gtx.Dp(460))
|
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
return layout.Inset{
|
||||||
return layout.Inset{Top: SpaceLG, Bottom: SpaceLG}.Layout(gtx, func(gtx C) D {
|
Top: SpaceLG, Bottom: SpaceLG, Left: SpaceMD, Right: SpaceMD,
|
||||||
return s.panel(a, gtx, st)
|
}.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 {
|
func (s *splashView) panel(a *App, gtx C, st core.State) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
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 {
|
layout.Rigid(func(gtx C) D {
|
||||||
l := th.Text(SizeDisplay, th.P.TextPri, "tslink")
|
l := th.Text(SizeDisplay, th.P.TextPri, "tslink")
|
||||||
l.Font.Weight = font.Bold
|
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 {
|
func (s *splashView) checklist(a *App, gtx C, st core.State) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
children := make([]layout.FlexChild, 0, len(st.Steps))
|
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 {
|
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 D{}
|
||||||
}
|
}
|
||||||
return th.MonoLabel(SizeCaption, th.P.TextDim,
|
col := th.P.TextDim
|
||||||
FormatLatency(step.Elapsed())).Layout(gtx)
|
if step.State == core.StepRunning && step.Elapsed() >= stuckAfter {
|
||||||
|
col = th.P.Warn
|
||||||
|
}
|
||||||
|
return th.MonoLabel(SizeCaption, col, FormatLatency(step.Elapsed())).Layout(gtx)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -238,60 +246,72 @@ func (s *splashView) stepRow(a *App, gtx C, step core.BootStep) D {
|
|||||||
|
|
||||||
func (s *splashView) footer(a *App, gtx C, st core.State) D {
|
func (s *splashView) footer(a *App, gtx C, st core.State) D {
|
||||||
th := a.th
|
th := a.th
|
||||||
|
stuck := stalled(st)
|
||||||
|
|
||||||
return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D {
|
return layout.Inset{Top: SpaceLG}.Layout(gtx, func(gtx C) D {
|
||||||
switch st.Phase {
|
return layout.Flex{Axis: layout.Vertical, Alignment: layout.Middle}.Layout(gtx,
|
||||||
case core.PhaseError:
|
layout.Rigid(func(gtx C) D {
|
||||||
// Error text and the retry button sit side by side: stacking them
|
switch st.Phase {
|
||||||
// pushes the only control on this screen below the fold on a short
|
case core.PhaseError:
|
||||||
// window.
|
// Error text and the retry button sit side by side: stacking
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
// them pushes the only control on this screen below the fold
|
||||||
layout.Flexed(1, func(gtx C) D {
|
// on a short window.
|
||||||
l := th.Text(SizeCaption, th.P.Fail, st.Err)
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
l.MaxLines = 4
|
layout.Flexed(1, func(gtx C) D {
|
||||||
|
l := th.Text(SizeCaption, th.P.Fail, st.Err)
|
||||||
|
l.MaxLines = 4
|
||||||
|
return l.Layout(gtx)
|
||||||
|
}),
|
||||||
|
HGap(SpaceMD),
|
||||||
|
layout.Rigid(func(gtx C) D {
|
||||||
|
gtx.Constraints.Min.X = 0
|
||||||
|
return th.Button(gtx, &s.retry, ButtonStyle{
|
||||||
|
Kind: ButtonPrimary,
|
||||||
|
Text: th.T(KRetry),
|
||||||
|
Icon: IconRefresh,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
case core.PhaseRetrying:
|
||||||
|
msg := th.T(KSplashRetry)
|
||||||
|
if st.Err != "" {
|
||||||
|
msg = st.Err
|
||||||
|
}
|
||||||
|
l := th.Text(SizeCaption, th.P.Warn, msg)
|
||||||
|
l.Alignment = text.Middle
|
||||||
|
l.MaxLines = 3
|
||||||
return l.Layout(gtx)
|
return l.Layout(gtx)
|
||||||
}),
|
|
||||||
HGap(SpaceMD),
|
|
||||||
layout.Rigid(func(gtx C) D {
|
|
||||||
gtx.Constraints.Min.X = 0
|
|
||||||
return th.Button(gtx, &s.retry, ButtonStyle{
|
|
||||||
Kind: ButtonPrimary,
|
|
||||||
Text: th.T(KRetry),
|
|
||||||
Icon: IconRefresh,
|
|
||||||
})
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
case core.PhaseRetrying:
|
default:
|
||||||
msg := th.T(KSplashRetry)
|
hint, col := th.T(KSplashHint), th.P.TextDim
|
||||||
if st.Err != "" {
|
if stuck {
|
||||||
msg = st.Err
|
hint, col = th.T(KSplashStuckHint), th.P.Warn
|
||||||
}
|
}
|
||||||
l := th.Text(SizeCaption, th.P.Warn, msg)
|
l := th.Text(SizeCaption, col, hint)
|
||||||
l.Alignment = text.Middle
|
l.Alignment = text.Middle
|
||||||
l.MaxLines = 3
|
l.MaxLines = 3
|
||||||
return l.Layout(gtx)
|
return l.Layout(gtx)
|
||||||
|
}
|
||||||
default:
|
}),
|
||||||
l := th.Caption(th.T(KSplashHint))
|
VGap(SpaceMD),
|
||||||
l.Alignment = text.Middle
|
layout.Rigid(func(gtx C) D {
|
||||||
return l.Layout(gtx)
|
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.
|
// window must settle at zero frames per second, not a slow trickle.
|
||||||
const animFrame = 40 * time.Millisecond
|
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
|
// animate requests the next animation frame at the capped rate. Every animated
|
||||||
// widget in this package goes through it.
|
// widget in this package goes through it.
|
||||||
func animate(gtx C) {
|
func animate(gtx C) {
|
||||||
gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(animFrame)})
|
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.
|
// Spacer returns a fixed-size gap.
|
||||||
func Spacer(v unit.Dp) layout.Spacer { return layout.Spacer{Height: v, Width: v} }
|
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)
|
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.
|
// Divider draws a hairline separator.
|
||||||
func (t *Theme) Divider(gtx C) D {
|
func (t *Theme) Divider(gtx C) D {
|
||||||
h := max(gtx.Dp(1), 1)
|
h := max(gtx.Dp(1), 1)
|
||||||
|
|||||||
+45
-8
@@ -130,6 +130,7 @@ func ProbeEgress(ctx context.Context, stunResults []STUNResult, logger *slog.Log
|
|||||||
egSortObservations(rep.Observations)
|
egSortObservations(rep.Observations)
|
||||||
rep.UniqueIPs = egUniqueIPs(rep.Observations)
|
rep.UniqueIPs = egUniqueIPs(rep.Observations)
|
||||||
rep.Divergent = egDivergent(rep.UniqueIPs)
|
rep.Divergent = egDivergent(rep.UniqueIPs)
|
||||||
|
rep.DivergentSTUN = egDivergentSTUN(rep.Observations)
|
||||||
egFinish(&rep)
|
egFinish(&rep)
|
||||||
|
|
||||||
log.With(
|
log.With(
|
||||||
@@ -221,17 +222,26 @@ func egSortObservations(os []EgressObservation) {
|
|||||||
|
|
||||||
// egUniqueIPs returns the deduplicated, sorted set of valid addresses.
|
// egUniqueIPs returns the deduplicated, sorted set of valid addresses.
|
||||||
func egUniqueIPs(os []EgressObservation) []netip.Addr {
|
func egUniqueIPs(os []EgressObservation) []netip.Addr {
|
||||||
seen := make(map[netip.Addr]struct{}, len(os))
|
ips := make([]netip.Addr, 0, len(os))
|
||||||
var out []netip.Addr
|
|
||||||
for _, o := range 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
|
continue
|
||||||
}
|
}
|
||||||
if _, dup := seen[o.IP]; dup {
|
if _, dup := seen[ip]; dup {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[o.IP] = struct{}{}
|
seen[ip] = struct{}{}
|
||||||
out = append(out, o.IP)
|
out = append(out, ip)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 })
|
sort.Slice(out, func(i, j int) bool { return out[i].Compare(out[j]) < 0 })
|
||||||
return out
|
return out
|
||||||
@@ -261,6 +271,22 @@ func egDivergent(ips []netip.Addr) bool {
|
|||||||
return len(v4) > 1 || len(v6) > 1
|
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
|
// egFinish derives Status and the one-line Chinese Summary from the collected
|
||||||
// addresses. It is called again by [AnnotateGeo] once geolocation is known, so
|
// addresses. It is called again by [AnnotateGeo] once geolocation is known, so
|
||||||
// it must stay idempotent.
|
// it must stay idempotent.
|
||||||
@@ -270,6 +296,10 @@ func egFinish(rep *EgressReport) {
|
|||||||
switch {
|
switch {
|
||||||
case len(rep.UniqueIPs) == 0:
|
case len(rep.UniqueIPs) == 0:
|
||||||
rep.Status = StatusFail
|
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:
|
case rep.Divergent:
|
||||||
rep.Status = StatusWarn
|
rep.Status = StatusWarn
|
||||||
default:
|
default:
|
||||||
@@ -291,8 +321,15 @@ func egFinish(rep *EgressReport) {
|
|||||||
if len(v6) > 1 {
|
if len(v6) > 1 {
|
||||||
parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s)", len(v6), egJoinAddrs(v6, 4)))
|
parts = append(parts, fmt.Sprintf("IPv6 有 %d 个(%s)", len(v6), egJoinAddrs(v6, 4)))
|
||||||
}
|
}
|
||||||
fmt.Fprintf(&b, "出口 IP 不一致:%s,代理、VPN 或多线接入正在拆分流量,对端看到的地址取决于走哪条链路",
|
if rep.DivergentSTUN {
|
||||||
strings.Join(parts, ";"))
|
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:
|
default:
|
||||||
var parts []string
|
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.Egress.Status,
|
||||||
rep.Tailscale.Status,
|
rep.Tailscale.Status,
|
||||||
)
|
)
|
||||||
rep.Headline = headline(rep)
|
rep.Headline, rep.HeadlineStatus = headline(rep)
|
||||||
logger.Info("diagnostics finished",
|
logger.Info("diagnostics finished",
|
||||||
"took", rep.Duration.Round(time.Millisecond),
|
"took", rep.Duration.Round(time.Millisecond),
|
||||||
"status", rep.Status.String(),
|
"status", rep.Status.String(),
|
||||||
@@ -232,29 +232,40 @@ func stepTitle(key string) string {
|
|||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
// headline picks the single most consequential finding. The ordering is by how
|
// headline picks the single most consequential finding, together with that
|
||||||
// badly each condition breaks the thing this app exists to do — carry game
|
// sentence's own severity. The ordering is by how badly each condition breaks
|
||||||
// traffic between peers — not by section order.
|
// the thing this app exists to do — carry game traffic between peers — not by
|
||||||
func headline(r *Report) string {
|
// 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 {
|
switch {
|
||||||
case r.NAT.Type == NATUDPBlocked:
|
case r.NAT.Type == NATUDPBlocked:
|
||||||
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继"
|
return "UDP 被完全阻断,无法建立直连,所有流量都会走 DERP 中继", StatusFail
|
||||||
case !r.UDP.V4OK && !r.UDP.V6OK:
|
case !r.UDP.V4OK && !r.UDP.V6OK:
|
||||||
return "UDP 探测全部失败,请检查防火墙或网络策略"
|
return "UDP 探测全部失败,请检查防火墙或网络策略", StatusFail
|
||||||
case r.NAT.Type == NATSymmetric:
|
case r.NAT.Type == NATSymmetric:
|
||||||
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继"
|
return "对称型 NAT:与同样受限的对端难以打洞,连接多半会退回中继", StatusFail
|
||||||
case r.Overseas.Status == StatusFail:
|
case r.Overseas.Status == StatusFail:
|
||||||
return "无法访问任何外部网络"
|
return "无法访问任何外部网络", StatusFail
|
||||||
case r.Overseas.Status == StatusWarn:
|
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:
|
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:
|
case r.PortMap.Status == StatusWarn && r.NAT.Type == NATPortRestrict:
|
||||||
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般"
|
return "路由器未提供端口映射,NAT 为端口限制型,打洞成功率一般", StatusWarn
|
||||||
case r.Status == StatusOK:
|
case r.Status == StatusOK:
|
||||||
return "网络状况良好,具备直连条件"
|
return "网络状况良好,具备直连条件", StatusOK
|
||||||
default:
|
default:
|
||||||
return "诊断完成,存在若干需要注意的项目"
|
return "诊断完成,存在若干需要注意的项目", r.Status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +330,15 @@ func (r *Report) Text() string {
|
|||||||
status = "OK"
|
status = "OK"
|
||||||
detail = p.Mapped.String() + " " + p.RTT.Round(time.Millisecond).String()
|
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')
|
b.WriteByte('\n')
|
||||||
|
|
||||||
@@ -389,8 +408,10 @@ func (r *Report) Text() string {
|
|||||||
if r.Egress.Summary != "" {
|
if r.Egress.Summary != "" {
|
||||||
w("%s\n", r.Egress.Summary)
|
w("%s\n", r.Egress.Summary)
|
||||||
}
|
}
|
||||||
if r.Egress.Divergent {
|
if r.Egress.DivergentSTUN {
|
||||||
w("!! 不同探测方式得到了不同的公网 IP,通常说明有代理或分流在生效\n")
|
w("!! STUN(UDP) 本身看到多个公网 IP,直连打洞会受影响\n")
|
||||||
|
} else if r.Egress.Divergent {
|
||||||
|
w("!! 仅 HTTP 探测得到了不同的公网 IP,STUN(UDP) 出口一致,通常不影响打洞\n")
|
||||||
}
|
}
|
||||||
for _, o := range r.Egress.Observations {
|
for _, o := range r.Egress.Observations {
|
||||||
val := o.IP.String()
|
val := o.IP.String()
|
||||||
|
|||||||
+3
-1
@@ -647,6 +647,7 @@ func ProbeUDP(ctx context.Context, servers []STUNServer, logger *slog.Logger) UD
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
per[i] = []udpAttempt{{
|
per[i] = []udpAttempt{{
|
||||||
probe: UDPProbe{
|
probe: UDPProbe{
|
||||||
|
Host: srv.Host,
|
||||||
Target: srv.Host,
|
Target: srv.Host,
|
||||||
Name: srv.Name,
|
Name: srv.Name,
|
||||||
Region: srv.Region,
|
Region: srv.Region,
|
||||||
@@ -734,6 +735,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return []udpAttempt{{
|
return []udpAttempt{{
|
||||||
probe: UDPProbe{
|
probe: UDPProbe{
|
||||||
|
Host: srv.Host,
|
||||||
Target: srv.Host,
|
Target: srv.Host,
|
||||||
Name: srv.Name,
|
Name: srv.Name,
|
||||||
Region: srv.Region,
|
Region: srv.Region,
|
||||||
@@ -756,7 +758,7 @@ func stunProbeUDPServer(ctx context.Context, srv STUNServer, log *slog.Logger) [
|
|||||||
doneV4 = true
|
doneV4 = true
|
||||||
}
|
}
|
||||||
dst := netip.AddrPortFrom(a, port)
|
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)
|
pctx, cancel := context.WithTimeout(ctx, stunProbeTimeout)
|
||||||
msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval)
|
msg, _, rtt, err := stunQuery(pctx, dst, 0, stunAttempts, stunInterval)
|
||||||
cancel()
|
cancel()
|
||||||
|
|||||||
@@ -130,6 +130,11 @@ type STUNResult struct {
|
|||||||
|
|
||||||
// UDPProbe is a plain "can I send and receive UDP here" datapoint.
|
// UDPProbe is a plain "can I send and receive UDP here" datapoint.
|
||||||
type UDPProbe struct {
|
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
|
Target string
|
||||||
Name string
|
Name string
|
||||||
Region Region
|
Region Region
|
||||||
@@ -317,6 +322,15 @@ type EgressReport struct {
|
|||||||
// intercepting part of the traffic. Having both an IPv4 and an IPv6 egress
|
// intercepting part of the traffic. Having both an IPv4 and an IPv6 egress
|
||||||
// is ordinary dual stack and does not set this.
|
// is ordinary dual stack and does not set this.
|
||||||
Divergent bool
|
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 is the set of distinct countries seen, sorted.
|
||||||
Countries []string
|
Countries []string
|
||||||
Status Status
|
Status Status
|
||||||
@@ -386,6 +400,11 @@ type Report struct {
|
|||||||
|
|
||||||
// Headline is the single most important sentence about this report.
|
// Headline is the single most important sentence about this report.
|
||||||
Headline string
|
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 is the worst status across all sections.
|
||||||
Status Status
|
Status Status
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user