detached 4648a7b

fix(cli): surface route health probe errors

commit
4648a7bbc9a806fe7be4bb36e1dd8b2464cace18
author
brice · · 2026-04-14 18:37 UTC
parent
3567896
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +93 -4
M internal/adapters/in/cli/routes.go +21 -4
@@ -931,7 +931,12 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom
931931 return fmt.Errorf("failed to get route: %w", err)
932932 }
933933
934- health, _ := cp.GetHealth(ctx)
934+ health, err := cp.GetHealth(ctx)
935+ healthErr := ""
936+ if err != nil {
937+ healthErr = err.Error()
938+ health = nil
939+ }
935940 var routeHealth *remote.RouteHealth
936941 if health != nil {
937942 routeHealth = health[routeDomain]
@@ -951,19 +956,26 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom
951956 "container_status": containerStatus,
952957 "http_status": httpStatus,
953958 }
959+ if healthErr != "" {
960+ payload["health_error"] = healthErr
961+ }
954962 return writeJSON(out, payload)
955963 }
956964
957- if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
965+ return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
966+}
967+
968+func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
969+ if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
958970 return err
959971 }
960972 if err := cliWriteLine(out, ""); err != nil {
961973 return err
962974 }
963- if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
975+ if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
964976 return err
965977 }
966- if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
978+ if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
967979 return err
968980 }
969981 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
@@ -974,6 +986,11 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom
974986 return err
975987 }
976988 }
989+ if healthErr != "" {
990+ if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
991+ return err
992+ }
993+ }
977994
978995 return nil
979996 }
@@ -931,7 +931,12 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom
931 return fmt.Errorf("failed to get route: %w", err)931 return fmt.Errorf("failed to get route: %w", err)
932 }932 }
933 933
934- health, _ := cp.GetHealth(ctx)934+ health, err := cp.GetHealth(ctx)
935+ healthErr := ""
936+ if err != nil {
937+ healthErr = err.Error()
938+ health = nil
939+ }
935 var routeHealth *remote.RouteHealth940 var routeHealth *remote.RouteHealth
936 if health != nil {941 if health != nil {
937 routeHealth = health[routeDomain]942 routeHealth = health[routeDomain]
@@ -951,19 +956,26 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom
951 "container_status": containerStatus,956 "container_status": containerStatus,
952 "http_status": httpStatus,957 "http_status": httpStatus,
953 }958 }
959+ if healthErr != "" {
960+ payload["health_error"] = healthErr
961+ }
954 return writeJSON(out, payload)962 return writeJSON(out, payload)
955 }963 }
956 964
957- if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {965+ return writeRouteShowText(out, route.Domain, route.Image, containerStatus, httpStatus, healthErr)
966+}
967+
968+func writeRouteShowText(out io.Writer, domainName, image, containerStatus string, httpStatus int, healthErr string) error {
969+ if err := cliWriteLine(out, cliRenderTitle("Route: "+domainName)); err != nil {
958 return err970 return err
959 }971 }
960 if err := cliWriteLine(out, ""); err != nil {972 if err := cliWriteLine(out, ""); err != nil {
961 return err973 return err
962 }974 }
963- if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {975+ if err := cliWriteLine(out, cliRenderMeta("Domain:", domainName)); err != nil {
964 return err976 return err
965 }977 }
966- if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {978+ if err := cliWriteLine(out, cliRenderMeta("Image:", image)); err != nil {
967 return err979 return err
968 }980 }
969 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {981 if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
@@ -974,6 +986,11 @@ func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDom
974 return err986 return err
975 }987 }
976 }988 }
989+ if healthErr != "" {
990+ if err := cliWriteLine(out, cliRenderWarning(healthErr)); err != nil {
991+ return err
992+ }
993+ }
977 994
978 return nil995 return nil
979 }996 }
M internal/adapters/in/cli/routes_test.go +72 -0
@@ -3,6 +3,8 @@ package cli
33 import (
44 "bytes"
55 "context"
6+ "encoding/json"
7+ "errors"
68 "os"
79 "path/filepath"
810 "strings"
@@ -12,10 +14,29 @@ import (
1214 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
1315 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
1416 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
17+ "github.com/bnema/gordon/internal/domain"
1518 "github.com/stretchr/testify/assert"
1619 "github.com/stretchr/testify/require"
1720 )
1821
22+type routesShowTestControlPlane struct {
23+ resolveFromImageTestControlPlane
24+ getHealth func(context.Context) (map[string]*remote.RouteHealth, error)
25+}
26+
27+var _ ControlPlane = (*routesShowTestControlPlane)(nil)
28+
29+func (c *routesShowTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
30+ panic("unexpected call")
31+}
32+
33+func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
34+ if c.getHealth != nil {
35+ return c.getHealth(ctx)
36+ }
37+ panic("unexpected call")
38+}
39+
1940 func TestTruncateImage(t *testing.T) {
2041 tests := []struct {
2142 name string
@@ -251,6 +272,57 @@ func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
251272 assert.Equal(t, expected, routeStatusTitle(item))
252273 }
253274
275+func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
276+ fake := &routesShowTestControlPlane{
277+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
278+ getRoute: func(context.Context, string) (*domain.Route, error) {
279+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
280+ },
281+ },
282+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
283+ return nil, errors.New("probe failed")
284+ },
285+ }
286+
287+ var out bytes.Buffer
288+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true)
289+
290+ require.NoError(t, err)
291+
292+ var got map[string]any
293+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
294+ assert.Equal(t, "app.example.com", got["domain"])
295+ assert.Equal(t, "app:latest", got["image"])
296+ assert.Equal(t, "unknown", got["container_status"])
297+ assert.Equal(t, float64(0), got["http_status"])
298+ assert.Equal(t, "probe failed", got["health_error"])
299+}
300+
301+func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
302+ fake := &routesShowTestControlPlane{
303+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
304+ getRoute: func(context.Context, string) (*domain.Route, error) {
305+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
306+ },
307+ },
308+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
309+ return nil, errors.New("probe failed")
310+ },
311+ }
312+
313+ var out bytes.Buffer
314+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false)
315+
316+ require.NoError(t, err)
317+
318+ text := stripANSI(out.String())
319+ assert.Contains(t, text, "Route: app.example.com")
320+ assert.Contains(t, text, "Domain:")
321+ assert.Contains(t, text, "Image:")
322+ assert.Contains(t, text, "Container:")
323+ assert.Contains(t, text, "probe failed")
324+}
325+
254326 func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
255327 testsDeps := routesListDeps{
256328 explicitRemote: func() (*remote.ResolvedRemote, bool) {
@@ -3,6 +3,8 @@ package cli
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "encoding/json"
7+ "errors"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
8 "strings"10 "strings"
@@ -12,10 +14,29 @@ import (
12 "github.com/bnema/gordon/internal/adapters/in/cli/remote"14 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
13 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"15 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
14 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"16 "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
17+ "github.com/bnema/gordon/internal/domain"
15 "github.com/stretchr/testify/assert"18 "github.com/stretchr/testify/assert"
16 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
17 )20 )
18 21
22+type routesShowTestControlPlane struct {
23+ resolveFromImageTestControlPlane
24+ getHealth func(context.Context) (map[string]*remote.RouteHealth, error)
25+}
26+
27+var _ ControlPlane = (*routesShowTestControlPlane)(nil)
28+
29+func (c *routesShowTestControlPlane) ListRoutesWithDetails(context.Context) ([]remote.RouteInfo, error) {
30+ panic("unexpected call")
31+}
32+
33+func (c *routesShowTestControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
34+ if c.getHealth != nil {
35+ return c.getHealth(ctx)
36+ }
37+ panic("unexpected call")
38+}
39+
19 func TestTruncateImage(t *testing.T) {40 func TestTruncateImage(t *testing.T) {
20 tests := []struct {41 tests := []struct {
21 name string42 name string
@@ -251,6 +272,57 @@ func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
251 assert.Equal(t, expected, routeStatusTitle(item))272 assert.Equal(t, expected, routeStatusTitle(item))
252 }273 }
253 274
275+func TestRunRoutesShow_JSONIncludesHealthError(t *testing.T) {
276+ fake := &routesShowTestControlPlane{
277+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
278+ getRoute: func(context.Context, string) (*domain.Route, error) {
279+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
280+ },
281+ },
282+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
283+ return nil, errors.New("probe failed")
284+ },
285+ }
286+
287+ var out bytes.Buffer
288+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", true)
289+
290+ require.NoError(t, err)
291+
292+ var got map[string]any
293+ require.NoError(t, json.Unmarshal(out.Bytes(), &got))
294+ assert.Equal(t, "app.example.com", got["domain"])
295+ assert.Equal(t, "app:latest", got["image"])
296+ assert.Equal(t, "unknown", got["container_status"])
297+ assert.Equal(t, float64(0), got["http_status"])
298+ assert.Equal(t, "probe failed", got["health_error"])
299+}
300+
301+func TestRunRoutesShow_TextIncludesHealthWarning(t *testing.T) {
302+ fake := &routesShowTestControlPlane{
303+ resolveFromImageTestControlPlane: resolveFromImageTestControlPlane{
304+ getRoute: func(context.Context, string) (*domain.Route, error) {
305+ return &domain.Route{Domain: "app.example.com", Image: "app:latest"}, nil
306+ },
307+ },
308+ getHealth: func(context.Context) (map[string]*remote.RouteHealth, error) {
309+ return nil, errors.New("probe failed")
310+ },
311+ }
312+
313+ var out bytes.Buffer
314+ err := runRoutesShow(context.Background(), fake, &out, "app.example.com", false)
315+
316+ require.NoError(t, err)
317+
318+ text := stripANSI(out.String())
319+ assert.Contains(t, text, "Route: app.example.com")
320+ assert.Contains(t, text, "Domain:")
321+ assert.Contains(t, text, "Image:")
322+ assert.Contains(t, text, "Container:")
323+ assert.Contains(t, text, "probe failed")
324+}
325+
254 func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {326 func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
255 testsDeps := routesListDeps{327 testsDeps := routesListDeps{
256 explicitRemote: func() (*remote.ResolvedRemote, bool) {328 explicitRemote: func() (*remote.ResolvedRemote, bool) {

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