From 7dec7284f35b5da889f3618e1a71e94d3cf12f5b Mon Sep 17 00:00:00 2001 From: iceBear67 Date: Sun, 26 Jul 2026 15:26:13 +0000 Subject: [PATCH] fix --- .gitignore | 1 + gui/services.go | 135 +++++++++++++++++++++++++++++++++++++++++-- gui/services_test.go | 101 ++++++++++++++++++++++++++++++++ gui/shot_test.go | 6 +- 4 files changed, 237 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 3f35734..a3802fb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ .gocache/** tslink config.toml +tslink-gui diff --git a/gui/services.go b/gui/services.go index a4f4f7b..870c73d 100644 --- a/gui/services.go +++ b/gui/services.go @@ -58,6 +58,60 @@ func (s serviceServer) Title() string { return s.Host } +// serviceGroup collects the servers that resolve to one tailnet peer. A homelab +// subnet router that fronts several hosts — say tsdns-homelab carrying every +// *.homelab.ice destination — appears once, with its hosts nested beneath it, +// instead of as a run of sibling rows the user has to recognise as one machine. +type serviceGroup struct { + // Peer is the node every server in the group resolved to, or nil when the + // group is a single standalone destination outside the tailnet. + Peer *core.PeerInfo + Servers []serviceServer +} + +// Online mirrors serviceServer.Online: a group is down only when its peer is a +// known, offline tailnet node. Peerless (public) groups have nothing to report. +func (g serviceGroup) Online() bool { return g.Peer == nil || g.Peer.Online } + +// Title is the group header: the peer's friendly name when it resolved, else the +// single host the group stands for. +func (g serviceGroup) Title() string { + if g.Peer != nil && g.Peer.DisplayName != "" { + return g.Peer.DisplayName + } + if len(g.Servers) > 0 { + return g.Servers[0].Title() + } + return "" +} + +// groupServices collapses the per-host servers into per-peer groups. Servers +// that resolved to the same tailnet node join one group; a server with no peer — +// an ordinary public destination — is a group of its own. +// +// The input is already sorted by buildServices (by title, then host), and +// servers behind one peer share a title, so they arrive adjacent and already +// host-ordered. First appearance fixes each group's position, so the section +// keeps the stable order buildServices established and never reshuffles between +// frames. +func groupServices(servers []serviceServer) []serviceGroup { + groups := make([]serviceGroup, 0, len(servers)) + byPeer := make(map[string]int) // peer ID -> index into groups + for _, srv := range servers { + if srv.Peer == nil { + groups = append(groups, serviceGroup{Servers: []serviceServer{srv}}) + continue + } + if idx, ok := byPeer[srv.Peer.ID]; ok { + groups[idx].Servers = append(groups[idx].Servers, srv) + continue + } + byPeer[srv.Peer.ID] = len(groups) + groups = append(groups, serviceGroup{Peer: srv.Peer, Servers: []serviceServer{srv}}) + } + return groups +} + // buildServices turns connect rules into the per-server view. // // Grouping is by destination host rather than by config tag: a server reached @@ -146,17 +200,90 @@ func (p *overviewPage) servicesCard(a *App, gtx C, servers []serviceServer) D { card.Title = th.T(KSvcTitle) card.Subtitle = th.T(KSvcSubtitle) + groups := groupServices(servers) return card.Layout(th, gtx, func(gtx C) D { - if len(servers) == 0 { + if len(groups) == 0 { return th.EmptyState(gtx, IconServer, th.T(KSvcEmpty), "") } - children := make([]layout.FlexChild, 0, len(servers)*2) - for i, srv := range servers { + children := make([]layout.FlexChild, 0, len(groups)*2) + for i, g := range groups { 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 p.serviceGroupRow(a, gtx, g) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +// serviceGroupRow renders one peer group. A group standing for a single host — +// whether a resolved peer or a bare public destination — keeps the flat +// one-server layout, so the common case looks exactly as it did before. A peer +// that fronts several hosts gets a header of its own with each host nested +// beneath it. +func (p *overviewPage) serviceGroupRow(a *App, gtx C, g serviceGroup) D { + if g.Peer == nil || len(g.Servers) == 1 { + return p.serverGroup(a, gtx, g.Servers[0]) + } + return p.peerGroup(a, gtx, g) +} + +// peerGroup renders a tailnet node and every host reached through it: one status +// dot and name for the node, then each destination host as a nested block. +func (p *overviewPage) peerGroup(a *App, gtx C, g serviceGroup) D { + th := a.th + level := LevelOK + if !g.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(g.Title())).Layout(gtx) + }), + ) + }), + } + for _, srv := range g.Servers { + children = append(children, layout.Rigid(func(gtx C) D { + return p.hostBlock(a, gtx, srv) + })) + } + return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...) + }) +} + +// hostBlock renders one destination host nested under its peer group: the host +// name, then the services pointing at it. The peer's status and name already sit +// in the group header, so only the host and its ports repeat here. +func (p *overviewPage) hostBlock(a *App, gtx C, srv serviceServer) D { + th := a.th + return layout.Inset{Top: SpaceXS, Left: SpaceLG}.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 IconServer(gtx, gtx.Dp(13), th.P.TextDim) + }), + HGap(SpaceSM), + layout.Flexed(1, func(gtx C) D { + return OneLine(th.MonoLabel(SizeCaption, th.P.TextSec, 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...) diff --git a/gui/services_test.go b/gui/services_test.go index 76b923e..7769f1d 100644 --- a/gui/services_test.go +++ b/gui/services_test.go @@ -104,3 +104,104 @@ func TestBuildServicesNilConfig(t *testing.T) { t.Errorf("want nil for a nil config, got %+v", got) } } + +// TestGroupServices pins the grouping the overview relies on: hosts fronted by +// one tailnet node collapse into a single group, a public destination stays on +// its own, and the hosts within a group keep buildServices' stable order. +func TestGroupServices(t *testing.T) { + cfg := &core.Config{ + Connect: map[string][]core.ConnectRule{ + "sfcraft": {{Protocol: "minecraft", LocalPort: 25566, DstAddr: "sfcraft.mc.homelab.ice:25565"}}, + "mayday": {{Protocol: "minecraft", LocalPort: 25571, DstAddr: "mayday.mc.homelab.ice:25565"}}, + "l4d2_tcp": {{Protocol: "tcp", LocalPort: 27015, DstAddr: "server.l4d2.homelab.ice:27015"}}, + // A public host resolves to no peer and must stand alone. + "voice": {{Protocol: "udp", LocalPort: 24454, DstAddr: "mc.lxns.net:24454"}}, + }, + } + // All three homelab hosts resolve to one subnet router. + snap := core.PeerSnapshot{Peers: []core.PeerInfo{{ + ID: "n1", DisplayName: "tsdns-homelab", Online: true, + Linked: true, LinkTags: []string{"sfcraft", "mayday", "l4d2_tcp"}, + }}} + + groups := groupServices(buildServices(cfg, snap)) + if len(groups) != 2 { + t.Fatalf("want 2 groups, got %d: %+v", len(groups), groups) + } + + var homelab, relay *serviceGroup + for i := range groups { + switch groups[i].Title() { + case "tsdns-homelab": + homelab = &groups[i] + case "mc.lxns.net": + relay = &groups[i] + } + } + + if homelab == nil { + t.Fatal("no group titled tsdns-homelab") + } + if len(homelab.Servers) != 3 { + t.Fatalf("homelab group should carry 3 hosts, got %d", len(homelab.Servers)) + } + // Hosts stay host-sorted so the group does not reshuffle between frames. + wantHosts := []string{"mayday.mc.homelab.ice", "server.l4d2.homelab.ice", "sfcraft.mc.homelab.ice"} + for i, w := range wantHosts { + if homelab.Servers[i].Host != w { + t.Errorf("homelab host[%d] = %q, want %q", i, homelab.Servers[i].Host, w) + } + } + if !homelab.Online() { + t.Error("a group behind an online peer must not render as offline") + } + + if relay == nil { + t.Fatal("no standalone group for the public relay") + } + if relay.Peer != nil { + t.Error("a public destination must not be attached to a peer") + } + if len(relay.Servers) != 1 { + t.Errorf("standalone group should carry 1 host, got %d", len(relay.Servers)) + } + + // Grouping must be deterministic: repeated builds agree, or the section + // jitters between frames. + for i := 0; i < 20; i++ { + again := groupServices(buildServices(cfg, snap)) + if len(again) != len(groups) { + t.Fatalf("group count is unstable: %d vs %d", len(again), len(groups)) + } + for j := range again { + if again[j].Title() != groups[j].Title() { + t.Fatalf("group order is unstable: %q vs %q", again[j].Title(), groups[j].Title()) + } + } + } +} + +// TestGroupServicesSeparatesPeers checks that two distinct peers do not merge: +// grouping is by node identity, not by a shared DNS suffix. +func TestGroupServicesSeparatesPeers(t *testing.T) { + cfg := &core.Config{ + Connect: map[string][]core.ConnectRule{ + "a": {{Protocol: "tcp", LocalPort: 1000, DstAddr: "a.homelab.ice:1000"}}, + "b": {{Protocol: "tcp", LocalPort: 2000, DstAddr: "b.homelab.ice:2000"}}, + }, + } + snap := core.PeerSnapshot{Peers: []core.PeerInfo{ + {ID: "n1", DisplayName: "box-a", Online: true, Linked: true, LinkTags: []string{"a"}}, + {ID: "n2", DisplayName: "box-b", Online: true, Linked: true, LinkTags: []string{"b"}}, + }} + + groups := groupServices(buildServices(cfg, snap)) + if len(groups) != 2 { + t.Fatalf("want 2 groups for 2 distinct peers, got %d", len(groups)) + } + for _, g := range groups { + if len(g.Servers) != 1 { + t.Errorf("group %q should carry 1 host, got %d", g.Title(), len(g.Servers)) + } + } +} diff --git a/gui/shot_test.go b/gui/shot_test.go index 1aedcf3..7878e90 100644 --- a/gui/shot_test.go +++ b/gui/shot_test.go @@ -203,8 +203,10 @@ func TestShots(t *testing.T) { "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"}}, + // One subnet router fronts every *.homelab.ice host, so they collapse + // under a single tsdns-homelab header with the hosts nested beneath. + {ID: "n1", DisplayName: "tsdns-homelab", Online: true, Linked: true, + LinkTags: []string{"sfcraft", "mayday", "l4d2_tcp", "l4d2_udp"}}, }} servers := buildServices(cfg, snap) shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {