fix
This commit is contained in:
@@ -4,3 +4,4 @@
|
|||||||
.gocache/**
|
.gocache/**
|
||||||
tslink
|
tslink
|
||||||
config.toml
|
config.toml
|
||||||
|
tslink-gui
|
||||||
|
|||||||
+131
-4
@@ -58,6 +58,60 @@ func (s serviceServer) Title() string {
|
|||||||
return s.Host
|
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.
|
// buildServices turns connect rules into the per-server view.
|
||||||
//
|
//
|
||||||
// Grouping is by destination host rather than by config tag: a server reached
|
// 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.Title = th.T(KSvcTitle)
|
||||||
card.Subtitle = th.T(KSvcSubtitle)
|
card.Subtitle = th.T(KSvcSubtitle)
|
||||||
|
|
||||||
|
groups := groupServices(servers)
|
||||||
return card.Layout(th, gtx, func(gtx C) D {
|
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), "")
|
return th.EmptyState(gtx, IconServer, th.T(KSvcEmpty), "")
|
||||||
}
|
}
|
||||||
children := make([]layout.FlexChild, 0, len(servers)*2)
|
children := make([]layout.FlexChild, 0, len(groups)*2)
|
||||||
for i, srv := range servers {
|
for i, g := range groups {
|
||||||
if i > 0 {
|
if i > 0 {
|
||||||
children = append(children, layout.Rigid(th.Divider))
|
children = append(children, layout.Rigid(th.Divider))
|
||||||
}
|
}
|
||||||
children = append(children, layout.Rigid(func(gtx C) D {
|
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...)
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||||
|
|||||||
@@ -104,3 +104,104 @@ func TestBuildServicesNilConfig(t *testing.T) {
|
|||||||
t.Errorf("want nil for a nil config, got %+v", got)
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+4
-2
@@ -203,8 +203,10 @@ func TestShots(t *testing.T) {
|
|||||||
"l4d2_udp": {{Protocol: "udp", 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{
|
snap := core.PeerSnapshot{Peers: []core.PeerInfo{
|
||||||
{ID: "n1", DisplayName: "homelab-mc", Online: true, Linked: true, LinkTags: []string{"sfcraft"}},
|
// One subnet router fronts every *.homelab.ice host, so they collapse
|
||||||
{ID: "n2", DisplayName: "l4d2-box", Online: false, Linked: true, LinkTags: []string{"l4d2_tcp", "l4d2_udp"}},
|
// 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)
|
servers := buildServices(cfg, snap)
|
||||||
shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {
|
shoot(t, th, "services", image.Pt(760, 480), func(gtx C) D {
|
||||||
|
|||||||
Reference in New Issue
Block a user