detached 9a8e556

feat(cli): replace routes list table with tree view

commit
9a8e556e582c4b8f486c3fd378234d4fe886e9a4
author
brice · · 2026-03-24 05:44 UTC
parent
6ce6bc8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +192 -133
M internal/adapters/in/cli/routes.go +127 -81
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "sort"
89 "strings"
910
1011 "github.com/spf13/cobra"
@@ -55,20 +56,71 @@ func truncateImage(image string, maxLen int) string {
5556 return image[:maxLen-3] + "..."
5657 }
5758
58-func truncateNetwork(network string, maxLen int) string {
59- if network == "" || network == "-" {
60- return "-"
59+const networkPrefix = "gordon-"
60+
61+// httpHealthToStatus maps an HTTP health probe result to a components.Status.
62+func httpHealthToStatus(health *remote.RouteHealth) components.Status {
63+ if health == nil {
64+ return components.StatusUnknown
6165 }
62- if maxLen <= 0 {
63- return ""
66+ if health.HTTPStatus == 0 {
67+ if health.Error != "" {
68+ return components.StatusError
69+ }
70+ return components.StatusUnknown
6471 }
65- if len(network) <= maxLen {
66- return network
72+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
73+ return components.StatusSuccess
6774 }
68- if maxLen <= 3 {
69- return network[:maxLen]
75+ return components.StatusError
76+}
77+
78+// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
79+func stripNetworkPrefix(network string) string {
80+ return strings.TrimPrefix(network, networkPrefix)
81+}
82+
83+// networkGroup holds routes that share a network.
84+type networkGroup struct {
85+ name string
86+ routes []remote.RouteInfo
87+}
88+
89+// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90+// and solo routes. Both are sorted alphabetically by domain.
91+func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
92+ byNetwork := make(map[string][]remote.RouteInfo)
93+ var networkOrder []string
94+ for _, route := range routes {
95+ if _, seen := byNetwork[route.Network]; !seen {
96+ networkOrder = append(networkOrder, route.Network)
97+ }
98+ byNetwork[route.Network] = append(byNetwork[route.Network], route)
7099 }
71- return network[:maxLen-3] + "..."
100+
101+ var groups []networkGroup
102+ var solo []remote.RouteInfo
103+
104+ for _, net := range networkOrder {
105+ members := byNetwork[net]
106+ if len(members) >= 2 {
107+ sort.Slice(members, func(i, j int) bool {
108+ return members[i].Domain < members[j].Domain
109+ })
110+ groups = append(groups, networkGroup{
111+ name: stripNetworkPrefix(net),
112+ routes: members,
113+ })
114+ } else {
115+ solo = append(solo, members[0])
116+ }
117+ }
118+
119+ sort.Slice(solo, func(i, j int) bool {
120+ return solo[i].Domain < solo[j].Domain
121+ })
122+
123+ return groups, solo
72124 }
73125
74126 // newRoutesCmd creates the routes command group.
@@ -91,27 +143,6 @@ the local Gordon configuration.`,
91143 return cmd
92144 }
93145
94-// formatHTTPStatus formats the HTTP status for display.
95-func formatHTTPStatus(health *remote.RouteHealth) string {
96- if health == nil {
97- return styles.Theme.Muted.Render("-")
98- }
99- if health.HTTPStatus == 0 {
100- if health.Error != "" {
101- return styles.Theme.BadgeError.Render("err")
102- }
103- return styles.Theme.Muted.Render("-")
104- }
105- status := fmt.Sprintf("%d", health.HTTPStatus)
106- if health.ResponseTimeMs > 0 {
107- status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
108- }
109- if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
110- return styles.Theme.BadgeSuccess.Render(status)
111- }
112- return styles.Theme.BadgeError.Render(status)
113-}
114-
115146 // newRoutesListCmd creates the routes list command.
116147 func newRoutesListCmd() *cobra.Command {
117148 var jsonOut bool
@@ -228,7 +259,6 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
228259 return fmt.Errorf("failed to list routes: %w", err)
229260 }
230261
231- // Get health status for each route (includes container status and HTTP probe)
232262 health, _ := client.GetHealth(ctx)
233263 if health == nil {
234264 health = make(map[string]*remote.RouteHealth)
@@ -243,68 +273,84 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
243273 return nil
244274 }
245275
246- const imageColWidth = 35
247- const networkColWidth = 22
248- rows := make([][]string, 0, len(routes))
249- for _, route := range routes {
250- routeHealth := health[route.Domain]
276+ groups, solo := groupRoutesByNetwork(routes)
251277
252- // Container status column
253- containerStatus := route.ContainerStatus
254- if containerStatus == "" {
255- if routeHealth != nil {
256- containerStatus = routeHealth.ContainerStatus
257- } else {
258- containerStatus = "unknown"
259- }
260- }
261- containerBadge := components.ContainerStatusBadge(containerStatus)
278+ type sortableItem struct {
279+ sortKey string
280+ group *networkGroup
281+ route *remote.RouteInfo
282+ }
262283
263- // HTTP status column
264- httpStatus := formatHTTPStatus(routeHealth)
284+ var items []sortableItem
285+ for i := range groups {
286+ items = append(items, sortableItem{
287+ sortKey: groups[i].routes[0].Domain,
288+ group: &groups[i],
289+ })
290+ }
291+ for i := range solo {
292+ items = append(items, sortableItem{
293+ sortKey: solo[i].Domain,
294+ route: &solo[i],
295+ })
296+ }
297+ sort.Slice(items, func(i, j int) bool {
298+ return items[i].sortKey < items[j].sortKey
299+ })
265300
266- displayImage := truncateImage(route.Image, imageColWidth)
267- displayNetwork := truncateNetwork(route.Network, networkColWidth)
268- rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
269-
270- for i, attachment := range route.Attachments {
271- // Unicode box-drawing tree structure for nested attachments
272- prefix := styles.IconTreeBranch + styles.IconTreeLine
273- if i == len(route.Attachments)-1 {
274- prefix = styles.IconTreeLast + styles.IconTreeLine
275- }
276- attachmentName := prefix + " " + attachment.Name
277- runes := []rune(attachmentName)
278- if len(runes) > 25 {
279- attachmentName = string(runes[:22]) + "..."
301+ tree := components.NewTree()
302+
303+ for _, item := range items {
304+ if item.group != nil {
305+ g := tree.AddGroup(item.group.name)
306+ for _, route := range item.group.routes {
307+ title := routeTitle(route, health)
308+ node := g.AddNode(title, route.Image)
309+ addAttachmentChildren(node, route)
280310 }
281- attachmentStatus := components.ContainerStatusBadge(attachment.Status)
282- attachmentImage := truncateImage(attachment.Image, imageColWidth)
283- // Display the attachment's actual network in the routes table
284- attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
285- rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
311+ } else {
312+ title := routeTitle(*item.route, health)
313+ node := tree.AddNode(title, item.route.Image)
314+ addAttachmentChildren(node, *item.route)
286315 }
287316 }
288317
289- // Render table
290- table := components.NewTable(
291- components.WithColumns([]components.TableColumn{
292- {Title: "Domain", Width: 25},
293- {Title: "Image", Width: imageColWidth},
294- {Title: "Network", Width: networkColWidth},
295- {Title: "Container", Width: 12},
296- {Title: "HTTP", Width: 14},
297- }),
298- components.WithRows(rows),
299- )
300-
301318 _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))
302319 _, _ = fmt.Fprintln(out)
303- _, _ = fmt.Fprintln(out, table.View())
320+ _, _ = fmt.Fprintln(out, tree.Render())
304321
305322 return nil
306323 }
307324
325+// routeTitle builds the pre-styled title line for a route node.
326+func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
327+ containerStatus := route.ContainerStatus
328+ if containerStatus == "" {
329+ if h := health[route.Domain]; h != nil {
330+ containerStatus = h.ContainerStatus
331+ } else {
332+ containerStatus = "unknown"
333+ }
334+ }
335+
336+ containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
337+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
338+
339+ return containerIcon + " " + httpIcon + " " + route.Domain
340+}
341+
342+// addAttachmentChildren adds attachment nodes as children of a route node.
343+func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
344+ for _, att := range route.Attachments {
345+ attStatus := att.Status
346+ if attStatus == "" {
347+ attStatus = "unknown"
348+ }
349+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
350+ node.AddChild(attIcon+" "+att.Name, att.Image)
351+ }
352+}
353+
308354 func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
309355 payload := make([]map[string]any, 0, len(routes))
310356 for _, route := range routes {
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "sort"
8 "strings"9 "strings"
9 10
10 "github.com/spf13/cobra"11 "github.com/spf13/cobra"
@@ -55,20 +56,71 @@ func truncateImage(image string, maxLen int) string {
55 return image[:maxLen-3] + "..."56 return image[:maxLen-3] + "..."
56 }57 }
57 58
58-func truncateNetwork(network string, maxLen int) string {59+const networkPrefix = "gordon-"
59- if network == "" || network == "-" {60+
60- return "-"61+// httpHealthToStatus maps an HTTP health probe result to a components.Status.
62+func httpHealthToStatus(health *remote.RouteHealth) components.Status {
63+ if health == nil {
64+ return components.StatusUnknown
61 }65 }
62- if maxLen <= 0 {66+ if health.HTTPStatus == 0 {
63- return ""67+ if health.Error != "" {
68+ return components.StatusError
69+ }
70+ return components.StatusUnknown
64 }71 }
65- if len(network) <= maxLen {72+ if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
66- return network73+ return components.StatusSuccess
67 }74 }
68- if maxLen <= 3 {75+ return components.StatusError
69- return network[:maxLen]76+}
77+
78+// stripNetworkPrefix removes the "gordon-" prefix from a network name for display.
79+func stripNetworkPrefix(network string) string {
80+ return strings.TrimPrefix(network, networkPrefix)
81+}
82+
83+// networkGroup holds routes that share a network.
84+type networkGroup struct {
85+ name string
86+ routes []remote.RouteInfo
87+}
88+
89+// groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90+// and solo routes. Both are sorted alphabetically by domain.
91+func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
92+ byNetwork := make(map[string][]remote.RouteInfo)
93+ var networkOrder []string
94+ for _, route := range routes {
95+ if _, seen := byNetwork[route.Network]; !seen {
96+ networkOrder = append(networkOrder, route.Network)
97+ }
98+ byNetwork[route.Network] = append(byNetwork[route.Network], route)
70 }99 }
71- return network[:maxLen-3] + "..."100+
101+ var groups []networkGroup
102+ var solo []remote.RouteInfo
103+
104+ for _, net := range networkOrder {
105+ members := byNetwork[net]
106+ if len(members) >= 2 {
107+ sort.Slice(members, func(i, j int) bool {
108+ return members[i].Domain < members[j].Domain
109+ })
110+ groups = append(groups, networkGroup{
111+ name: stripNetworkPrefix(net),
112+ routes: members,
113+ })
114+ } else {
115+ solo = append(solo, members[0])
116+ }
117+ }
118+
119+ sort.Slice(solo, func(i, j int) bool {
120+ return solo[i].Domain < solo[j].Domain
121+ })
122+
123+ return groups, solo
72 }124 }
73 125
74 // newRoutesCmd creates the routes command group.126 // newRoutesCmd creates the routes command group.
@@ -91,27 +143,6 @@ the local Gordon configuration.`,
91 return cmd143 return cmd
92 }144 }
93 145
94-// formatHTTPStatus formats the HTTP status for display.
95-func formatHTTPStatus(health *remote.RouteHealth) string {
96- if health == nil {
97- return styles.Theme.Muted.Render("-")
98- }
99- if health.HTTPStatus == 0 {
100- if health.Error != "" {
101- return styles.Theme.BadgeError.Render("err")
102- }
103- return styles.Theme.Muted.Render("-")
104- }
105- status := fmt.Sprintf("%d", health.HTTPStatus)
106- if health.ResponseTimeMs > 0 {
107- status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
108- }
109- if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
110- return styles.Theme.BadgeSuccess.Render(status)
111- }
112- return styles.Theme.BadgeError.Render(status)
113-}
114-
115 // newRoutesListCmd creates the routes list command.146 // newRoutesListCmd creates the routes list command.
116 func newRoutesListCmd() *cobra.Command {147 func newRoutesListCmd() *cobra.Command {
117 var jsonOut bool148 var jsonOut bool
@@ -228,7 +259,6 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
228 return fmt.Errorf("failed to list routes: %w", err)259 return fmt.Errorf("failed to list routes: %w", err)
229 }260 }
230 261
231- // Get health status for each route (includes container status and HTTP probe)
232 health, _ := client.GetHealth(ctx)262 health, _ := client.GetHealth(ctx)
233 if health == nil {263 if health == nil {
234 health = make(map[string]*remote.RouteHealth)264 health = make(map[string]*remote.RouteHealth)
@@ -243,68 +273,84 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut boo
243 return nil273 return nil
244 }274 }
245 275
246- const imageColWidth = 35276+ groups, solo := groupRoutesByNetwork(routes)
247- const networkColWidth = 22
248- rows := make([][]string, 0, len(routes))
249- for _, route := range routes {
250- routeHealth := health[route.Domain]
251 277
252- // Container status column278+ type sortableItem struct {
253- containerStatus := route.ContainerStatus279+ sortKey string
254- if containerStatus == "" {280+ group *networkGroup
255- if routeHealth != nil {281+ route *remote.RouteInfo
256- containerStatus = routeHealth.ContainerStatus282+ }
257- } else {
258- containerStatus = "unknown"
259- }
260- }
261- containerBadge := components.ContainerStatusBadge(containerStatus)
262 283
263- // HTTP status column284+ var items []sortableItem
264- httpStatus := formatHTTPStatus(routeHealth)285+ for i := range groups {
286+ items = append(items, sortableItem{
287+ sortKey: groups[i].routes[0].Domain,
288+ group: &groups[i],
289+ })
290+ }
291+ for i := range solo {
292+ items = append(items, sortableItem{
293+ sortKey: solo[i].Domain,
294+ route: &solo[i],
295+ })
296+ }
297+ sort.Slice(items, func(i, j int) bool {
298+ return items[i].sortKey < items[j].sortKey
299+ })
265 300
266- displayImage := truncateImage(route.Image, imageColWidth)301+ tree := components.NewTree()
267- displayNetwork := truncateNetwork(route.Network, networkColWidth)302+
268- rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})303+ for _, item := range items {
269-304+ if item.group != nil {
270- for i, attachment := range route.Attachments {305+ g := tree.AddGroup(item.group.name)
271- // Unicode box-drawing tree structure for nested attachments306+ for _, route := range item.group.routes {
272- prefix := styles.IconTreeBranch + styles.IconTreeLine307+ title := routeTitle(route, health)
273- if i == len(route.Attachments)-1 {308+ node := g.AddNode(title, route.Image)
274- prefix = styles.IconTreeLast + styles.IconTreeLine309+ addAttachmentChildren(node, route)
275- }
276- attachmentName := prefix + " " + attachment.Name
277- runes := []rune(attachmentName)
278- if len(runes) > 25 {
279- attachmentName = string(runes[:22]) + "..."
280 }310 }
281- attachmentStatus := components.ContainerStatusBadge(attachment.Status)311+ } else {
282- attachmentImage := truncateImage(attachment.Image, imageColWidth)312+ title := routeTitle(*item.route, health)
283- // Display the attachment's actual network in the routes table313+ node := tree.AddNode(title, item.route.Image)
284- attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)314+ addAttachmentChildren(node, *item.route)
285- rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
286 }315 }
287 }316 }
288 317
289- // Render table
290- table := components.NewTable(
291- components.WithColumns([]components.TableColumn{
292- {Title: "Domain", Width: 25},
293- {Title: "Image", Width: imageColWidth},
294- {Title: "Network", Width: networkColWidth},
295- {Title: "Container", Width: 12},
296- {Title: "HTTP", Width: 14},
297- }),
298- components.WithRows(rows),
299- )
300-
301 _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))318 _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes"))
302 _, _ = fmt.Fprintln(out)319 _, _ = fmt.Fprintln(out)
303- _, _ = fmt.Fprintln(out, table.View())320+ _, _ = fmt.Fprintln(out, tree.Render())
304 321
305 return nil322 return nil
306 }323 }
307 324
325+// routeTitle builds the pre-styled title line for a route node.
326+func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
327+ containerStatus := route.ContainerStatus
328+ if containerStatus == "" {
329+ if h := health[route.Domain]; h != nil {
330+ containerStatus = h.ContainerStatus
331+ } else {
332+ containerStatus = "unknown"
333+ }
334+ }
335+
336+ containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
337+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
338+
339+ return containerIcon + " " + httpIcon + " " + route.Domain
340+}
341+
342+// addAttachmentChildren adds attachment nodes as children of a route node.
343+func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
344+ for _, att := range route.Attachments {
345+ attStatus := att.Status
346+ if attStatus == "" {
347+ attStatus = "unknown"
348+ }
349+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
350+ node.AddChild(attIcon+" "+att.Name, att.Image)
351+ }
352+}
353+
308 func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {354 func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
309 payload := make([]map[string]any, 0, len(routes))355 payload := make([]map[string]any, 0, len(routes))
310 for _, route := range routes {356 for _, route := range routes {
M internal/adapters/in/cli/routes_test.go +65 -52
@@ -3,6 +3,8 @@ package cli
33 import (
44 "testing"
55
6+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
68 "github.com/stretchr/testify/assert"
79 )
810
@@ -102,80 +104,91 @@ func TestTruncateImage(t *testing.T) {
102104 }
103105 }
104106
105-func TestTruncateNetwork(t *testing.T) {
107+func TestHTTPHealthToStatus(t *testing.T) {
106108 tests := []struct {
107109 name string
108- network string
109- maxLen int
110- expected string
110+ health *remote.RouteHealth
111+ expected components.Status
111112 }{
112- // Basic cases
113- {
114- name: "short network fits",
115- network: "gordon-net",
116- maxLen: 20,
117- expected: "gordon-net",
118- },
119- {
120- name: "exact fit",
121- network: "gordon-net",
122- maxLen: 10,
123- expected: "gordon-net",
124- },
125-
126- // Truncation
127113 {
128- name: "truncate long network with ellipsis",
129- network: "gordon-my-very-long-network-name",
130- maxLen: 20,
131- expected: "gordon-my-very-lo...",
114+ name: "nil health returns unknown",
115+ health: nil,
116+ expected: components.StatusUnknown,
132117 },
133-
134- // Special values
135118 {
136- name: "empty returns dash",
137- network: "",
138- maxLen: 10,
139- expected: "-",
119+ name: "zero status no error returns unknown",
120+ health: &remote.RouteHealth{HTTPStatus: 0},
121+ expected: components.StatusUnknown,
140122 },
141123 {
142- name: "dash returns dash",
143- network: "-",
144- maxLen: 10,
145- expected: "-",
124+ name: "zero status with error returns error",
125+ health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
126+ expected: components.StatusError,
146127 },
147-
148- // Edge cases
149128 {
150- name: "maxLen zero returns empty",
151- network: "gordon-net",
152- maxLen: 0,
153- expected: "",
129+ name: "200 returns success",
130+ health: &remote.RouteHealth{HTTPStatus: 200},
131+ expected: components.StatusSuccess,
154132 },
155133 {
156- name: "maxLen negative returns empty",
157- network: "gordon-net",
158- maxLen: -1,
159- expected: "",
134+ name: "301 returns success",
135+ health: &remote.RouteHealth{HTTPStatus: 301},
136+ expected: components.StatusSuccess,
160137 },
161138 {
162- name: "maxLen 3 or less - no ellipsis",
163- network: "gordon-net",
164- maxLen: 3,
165- expected: "gor",
139+ name: "500 returns error",
140+ health: &remote.RouteHealth{HTTPStatus: 500},
141+ expected: components.StatusError,
166142 },
167143 {
168- name: "maxLen 4 - truncate with ellipsis",
169- network: "gordon-net",
170- maxLen: 4,
171- expected: "g...",
144+ name: "404 returns error",
145+ health: &remote.RouteHealth{HTTPStatus: 404},
146+ expected: components.StatusError,
172147 },
173148 }
174149
175150 for _, tt := range tests {
176151 t.Run(tt.name, func(t *testing.T) {
177- result := truncateNetwork(tt.network, tt.maxLen)
152+ result := httpHealthToStatus(tt.health)
178153 assert.Equal(t, tt.expected, result)
179154 })
180155 }
181156 }
157+
158+func TestGroupRoutesByNetwork(t *testing.T) {
159+ routes := []remote.RouteInfo{
160+ {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
161+ {Domain: "beta.dev", Network: "gordon-shared"},
162+ {Domain: "gamma.dev", Network: "gordon-shared"},
163+ {Domain: "delta.dev", Network: "gordon-delta-dev"},
164+ }
165+
166+ groups, solo := groupRoutesByNetwork(routes)
167+
168+ assert.Len(t, solo, 2)
169+ assert.Equal(t, "alpha.dev", solo[0].Domain)
170+ assert.Equal(t, "delta.dev", solo[1].Domain)
171+
172+ assert.Len(t, groups, 1)
173+ assert.Equal(t, "shared", groups[0].name)
174+ assert.Len(t, groups[0].routes, 2)
175+}
176+
177+func TestStripNetworkPrefix(t *testing.T) {
178+ tests := []struct {
179+ network string
180+ expected string
181+ }{
182+ {"gordon-shared-services", "shared-services"},
183+ {"gordon-my-app-dev", "my-app-dev"},
184+ {"custom-network", "custom-network"},
185+ {"gordon-", ""},
186+ {"", ""},
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.network, func(t *testing.T) {
191+ assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
192+ })
193+ }
194+}
@@ -3,6 +3,8 @@ package cli
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
6 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
7 )9 )
8 10
@@ -102,80 +104,91 @@ func TestTruncateImage(t *testing.T) {
102 }104 }
103 }105 }
104 106
105-func TestTruncateNetwork(t *testing.T) {107+func TestHTTPHealthToStatus(t *testing.T) {
106 tests := []struct {108 tests := []struct {
107 name string109 name string
108- network string110+ health *remote.RouteHealth
109- maxLen int111+ expected components.Status
110- expected string
111 }{112 }{
112- // Basic cases
113- {
114- name: "short network fits",
115- network: "gordon-net",
116- maxLen: 20,
117- expected: "gordon-net",
118- },
119- {
120- name: "exact fit",
121- network: "gordon-net",
122- maxLen: 10,
123- expected: "gordon-net",
124- },
125-
126- // Truncation
127 {113 {
128- name: "truncate long network with ellipsis",114+ name: "nil health returns unknown",
129- network: "gordon-my-very-long-network-name",115+ health: nil,
130- maxLen: 20,116+ expected: components.StatusUnknown,
131- expected: "gordon-my-very-lo...",
132 },117 },
133-
134- // Special values
135 {118 {
136- name: "empty returns dash",119+ name: "zero status no error returns unknown",
137- network: "",120+ health: &remote.RouteHealth{HTTPStatus: 0},
138- maxLen: 10,121+ expected: components.StatusUnknown,
139- expected: "-",
140 },122 },
141 {123 {
142- name: "dash returns dash",124+ name: "zero status with error returns error",
143- network: "-",125+ health: &remote.RouteHealth{HTTPStatus: 0, Error: "connection refused"},
144- maxLen: 10,126+ expected: components.StatusError,
145- expected: "-",
146 },127 },
147-
148- // Edge cases
149 {128 {
150- name: "maxLen zero returns empty",129+ name: "200 returns success",
151- network: "gordon-net",130+ health: &remote.RouteHealth{HTTPStatus: 200},
152- maxLen: 0,131+ expected: components.StatusSuccess,
153- expected: "",
154 },132 },
155 {133 {
156- name: "maxLen negative returns empty",134+ name: "301 returns success",
157- network: "gordon-net",135+ health: &remote.RouteHealth{HTTPStatus: 301},
158- maxLen: -1,136+ expected: components.StatusSuccess,
159- expected: "",
160 },137 },
161 {138 {
162- name: "maxLen 3 or less - no ellipsis",139+ name: "500 returns error",
163- network: "gordon-net",140+ health: &remote.RouteHealth{HTTPStatus: 500},
164- maxLen: 3,141+ expected: components.StatusError,
165- expected: "gor",
166 },142 },
167 {143 {
168- name: "maxLen 4 - truncate with ellipsis",144+ name: "404 returns error",
169- network: "gordon-net",145+ health: &remote.RouteHealth{HTTPStatus: 404},
170- maxLen: 4,146+ expected: components.StatusError,
171- expected: "g...",
172 },147 },
173 }148 }
174 149
175 for _, tt := range tests {150 for _, tt := range tests {
176 t.Run(tt.name, func(t *testing.T) {151 t.Run(tt.name, func(t *testing.T) {
177- result := truncateNetwork(tt.network, tt.maxLen)152+ result := httpHealthToStatus(tt.health)
178 assert.Equal(t, tt.expected, result)153 assert.Equal(t, tt.expected, result)
179 })154 })
180 }155 }
181 }156 }
157+
158+func TestGroupRoutesByNetwork(t *testing.T) {
159+ routes := []remote.RouteInfo{
160+ {Domain: "alpha.dev", Network: "gordon-alpha-dev"},
161+ {Domain: "beta.dev", Network: "gordon-shared"},
162+ {Domain: "gamma.dev", Network: "gordon-shared"},
163+ {Domain: "delta.dev", Network: "gordon-delta-dev"},
164+ }
165+
166+ groups, solo := groupRoutesByNetwork(routes)
167+
168+ assert.Len(t, solo, 2)
169+ assert.Equal(t, "alpha.dev", solo[0].Domain)
170+ assert.Equal(t, "delta.dev", solo[1].Domain)
171+
172+ assert.Len(t, groups, 1)
173+ assert.Equal(t, "shared", groups[0].name)
174+ assert.Len(t, groups[0].routes, 2)
175+}
176+
177+func TestStripNetworkPrefix(t *testing.T) {
178+ tests := []struct {
179+ network string
180+ expected string
181+ }{
182+ {"gordon-shared-services", "shared-services"},
183+ {"gordon-my-app-dev", "my-app-dev"},
184+ {"custom-network", "custom-network"},
185+ {"gordon-", ""},
186+ {"", ""},
187+ }
188+
189+ for _, tt := range tests {
190+ t.Run(tt.network, func(t *testing.T) {
191+ assert.Equal(t, tt.expected, stripNetworkPrefix(tt.network))
192+ })
193+ }
194+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close