detached a0c8f05

feat(cli): split routes inventory from route status

commit
a0c8f05fc5301710fed2d098e655b3ac20fe0b94
author
brice · · 2026-04-14 16:05 UTC
parent
9ef6929
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1133 -211
M docs/cli/index.md +15 -0
@@ -116,6 +116,7 @@ gordon auth internal
116116
117117 # Routes
118118 gordon routes list
119+gordon routes status
119120 gordon routes add myapp.example.com myapp:latest
120121 gordon routes remove myapp.example.com
121122 gordon routes deploy myapp.example.com
@@ -167,6 +168,20 @@ The CLI can target remote Gordon instances using client config, an active remote
167168 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
168169 when you want to bypass your saved configuration.
169170
171+`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
172+nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
173+one to force a single target.
174+
175+```bash
176+# Aggregate routes views
177+gordon routes list
178+gordon routes status
179+
180+# Single-target override
181+gordon routes list --remote prod
182+GORDON_REMOTE=prod gordon routes status
183+```
184+
170185 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
171186 If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
172187
@@ -116,6 +116,7 @@ gordon auth internal
116 116
117 # Routes117 # Routes
118 gordon routes list118 gordon routes list
119+gordon routes status
119 gordon routes add myapp.example.com myapp:latest120 gordon routes add myapp.example.com myapp:latest
120 gordon routes remove myapp.example.com121 gordon routes remove myapp.example.com
121 gordon routes deploy myapp.example.com122 gordon routes deploy myapp.example.com
@@ -167,6 +168,20 @@ The CLI can target remote Gordon instances using client config, an active remote
167 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides168 or `GORDON_REMOTE` environment variable. Use `--remote` and `--token` as global overrides
168 when you want to bypass your saved configuration.169 when you want to bypass your saved configuration.
169 170
171+`gordon routes list` and `gordon routes status` are the exceptions: when neither `--remote`
172+nor `GORDON_REMOTE` is set, they show local routes first, then every saved remote. Set either
173+one to force a single target.
174+
175+```bash
176+# Aggregate routes views
177+gordon routes list
178+gordon routes status
179+
180+# Single-target override
181+gordon routes list --remote prod
182+GORDON_REMOTE=prod gordon routes status
183+```
184+
170 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.185 **Important:** The remote URL must be the `gordon_domain` configured on the remote Gordon instance. This is the domain that serves both the container registry and the Admin API.
171 If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.186 If remote CLI gets a `404` during `/auth/token` exchange, the server likely still sets only `server.registry_domain` and needs `server.gordon_domain` configured.
172 187
M docs/cli/remotes.md +24 -11
@@ -162,12 +162,14 @@ gordon remotes use <name>
162162
163163 ### Description
164164
165-When a remote is active, it's used automatically for all remote-capable commands without needing to specify `--remote` and `--token`:
165+When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166+`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
166167
167168 ```bash
168169 gordon remotes use prod
169-gordon routes list # Uses prod remote automatically
170170 gordon secrets list app.com # Uses prod remote automatically
171+gordon routes list # Aggregates local + saved remotes
172+gordon routes status # Aggregates local + saved remotes
171173 ```
172174
173175 ### Example
@@ -242,6 +244,8 @@ token = "eyJ..."
242244
243245 When multiple sources specify remote or token, the CLI uses this priority:
244246
247+For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
248+
245249 **Remote URL:**
246250 1. `--remote` flag
247251 2. `GORDON_REMOTE` environment variable
@@ -263,8 +267,13 @@ When multiple sources specify remote or token, the CLI uses this priority:
263267 This allows overriding specific values while keeping defaults:
264268
265269 ```bash
266-# Active remote is prod, but use different token
267-gordon routes list --token $TEMPORARY_TOKEN
270+# Aggregate routes views
271+gordon routes list
272+gordon routes status
273+
274+# Force one target
275+GORDON_REMOTE=prod gordon routes list
276+GORDON_REMOTE=prod gordon routes status
268277 ```
269278
270279 ---
@@ -281,12 +290,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
281290
282291 # Work with prod
283292 gordon remotes use prod
284-gordon routes list
285293 gordon secrets list myapp.example.com
294+gordon routes list --remote prod
286295
287296 # Switch to staging
288297 gordon remotes use staging
289-gordon routes list
298+GORDON_REMOTE=staging gordon routes status
290299 ```
291300
292301 ### CI/CD Pipeline
@@ -306,13 +315,17 @@ steps:
306315 ### Compare Environments
307316
308317 ```bash
309-# Compare routes across environments
318+# Aggregate route inventory/status across local + saved remotes
319+gordon routes list
320+gordon routes status
321+
322+# Compare one target at a time
310323 gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
311-gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN
324+GORDON_REMOTE=staging gordon routes status
312325
313-# Or switch between active remotes
314-gordon remotes use prod && gordon routes list
315-gordon remotes use staging && gordon routes list
326+# Or switch between active remotes for other commands
327+gordon remotes use prod && gordon secrets list myapp.example.com
328+gordon remotes use staging && gordon secrets list myapp.example.com
316329 ```
317330
318331 ### Private Admin + Wildcard App Domains
@@ -162,12 +162,14 @@ gordon remotes use <name>
162 162
163 ### Description163 ### Description
164 164
165-When a remote is active, it's used automatically for all remote-capable commands without needing to specify `--remote` and `--token`:165+When a remote is active, it's used automatically for most remote-capable commands without needing to specify `--remote` and `--token`.
166+`gordon routes list` and `gordon routes status` are the exception: they aggregate local + saved remotes unless you set `--remote` or `GORDON_REMOTE`.
166 167
167 ```bash168 ```bash
168 gordon remotes use prod169 gordon remotes use prod
169-gordon routes list # Uses prod remote automatically
170 gordon secrets list app.com # Uses prod remote automatically170 gordon secrets list app.com # Uses prod remote automatically
171+gordon routes list # Aggregates local + saved remotes
172+gordon routes status # Aggregates local + saved remotes
171 ```173 ```
172 174
173 ### Example175 ### Example
@@ -242,6 +244,8 @@ token = "eyJ..."
242 244
243 When multiple sources specify remote or token, the CLI uses this priority:245 When multiple sources specify remote or token, the CLI uses this priority:
244 246
247+For `routes list` and `routes status`, `--remote` and `GORDON_REMOTE` are the explicit single-target selectors. Without either one, those commands aggregate local + saved remotes even when an active remote exists.
248+
245 **Remote URL:**249 **Remote URL:**
246 1. `--remote` flag250 1. `--remote` flag
247 2. `GORDON_REMOTE` environment variable251 2. `GORDON_REMOTE` environment variable
@@ -263,8 +267,13 @@ When multiple sources specify remote or token, the CLI uses this priority:
263 This allows overriding specific values while keeping defaults:267 This allows overriding specific values while keeping defaults:
264 268
265 ```bash269 ```bash
266-# Active remote is prod, but use different token270+# Aggregate routes views
267-gordon routes list --token $TEMPORARY_TOKEN271+gordon routes list
272+gordon routes status
273+
274+# Force one target
275+GORDON_REMOTE=prod gordon routes list
276+GORDON_REMOTE=prod gordon routes status
268 ```277 ```
269 278
270 ---279 ---
@@ -281,12 +290,12 @@ gordon remotes add dev https://gordon.dev.example.com --token-env DEV_TOKEN
281 290
282 # Work with prod291 # Work with prod
283 gordon remotes use prod292 gordon remotes use prod
284-gordon routes list
285 gordon secrets list myapp.example.com293 gordon secrets list myapp.example.com
294+gordon routes list --remote prod
286 295
287 # Switch to staging296 # Switch to staging
288 gordon remotes use staging297 gordon remotes use staging
289-gordon routes list298+GORDON_REMOTE=staging gordon routes status
290 ```299 ```
291 300
292 ### CI/CD Pipeline301 ### CI/CD Pipeline
@@ -306,13 +315,17 @@ steps:
306 ### Compare Environments315 ### Compare Environments
307 316
308 ```bash317 ```bash
309-# Compare routes across environments318+# Aggregate route inventory/status across local + saved remotes
319+gordon routes list
320+gordon routes status
321+
322+# Compare one target at a time
310 gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN323 gordon routes list --remote https://gordon.example.com --token $PROD_TOKEN
311-gordon routes list --remote https://gordon.staging.example.com --token $STAGING_TOKEN324+GORDON_REMOTE=staging gordon routes status
312 325
313-# Or switch between active remotes326+# Or switch between active remotes for other commands
314-gordon remotes use prod && gordon routes list327+gordon remotes use prod && gordon secrets list myapp.example.com
315-gordon remotes use staging && gordon routes list328+gordon remotes use staging && gordon secrets list myapp.example.com
316 ```329 ```
317 330
318 ### Private Admin + Wildcard App Domains331 ### Private Admin + Wildcard App Domains
M docs/cli/routes.md +119 -16
@@ -2,8 +2,10 @@
22
33 Manage routes on local or remote Gordon instances.
44
5-Remote targeting uses client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
5+Most remote-capable commands use client config or an active remote by default.
6+`gordon routes list` and `gordon routes status` are different: when neither
7+`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8+saved remote under its own heading. See [CLI Overview](./index.md).
79
810 ## gordon routes
911
@@ -11,7 +13,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
1113
1214 | Subcommand | Description |
1315 |------------|-------------|
14-| `list` | List all routes |
16+| `list` | List routes by domain and image |
17+| `status` | Show detailed route status |
1518 | `show` | Show details for a single route |
1619 | `add` | Create or update a route |
1720 | `remove` | Remove a route |
@@ -21,22 +24,34 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
2124
2225 ## gordon routes list
2326
24-List all configured routes.
27+List routes by domain and image only.
28+
29+When no target is selected, Gordon shows local routes first, then each saved
30+remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31+target only.
2532
2633 ```bash
2734 gordon routes list
2835 gordon routes list --json
2936 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37+GORDON_REMOTE=prod gordon routes list
3038 ```
3139
3240 ### Output
3341
34-```
35-Domain Image Status
36---------------------------------------------------------------------------------
37-app.example.com myapp:latest running
38-api.example.com myapi:v2.1.0 running
39-admin.example.com admin-panel:latest stopped
42+```text
43+Routes
44+
45+Local
46+ app.example.com myapp:latest
47+ api.example.com myapi:v2.1.0
48+
49+Remote: hetzner-vps
50+ gordon.example.com gordon-webapp:latest
51+
52+Remote: igor
53+ grafana.supri.xyz grafana
54+ test.supri.xyz hello-test
4055 ```
4156
4257 ### JSON Output
@@ -48,18 +63,106 @@ gordon routes list --json
4863 ```json
4964 [
5065 {
51- "domain": "app.example.com",
52- "image": "myapp:latest",
53- "status": "running"
66+ "kind": "local",
67+ "name": "local",
68+ "routes": [
69+ {
70+ "domain": "app.example.com",
71+ "image": "myapp:latest"
72+ }
73+ ]
5474 },
5575 {
56- "domain": "api.example.com",
57- "image": "myapi:v2.1.0",
58- "status": "running"
76+ "kind": "remote",
77+ "name": "igor",
78+ "url": "https://gordon.supri.xyz",
79+ "routes": [
80+ {
81+ "domain": "grafana.supri.xyz",
82+ "image": "grafana"
83+ }
84+ ]
85+ }
86+]
87+```
88+
89+Single-target mode still returns a one-element array. Sections can also include
90+an `error` field when a target is unavailable.
91+
92+### Options
93+
94+| Option | Description |
95+|--------|-------------|
96+| `--json` | Output routes as JSON |
97+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98+| `--token` | Authentication token for remote |
99+
100+---
101+
102+## gordon routes status
103+
104+Show detailed route status for each target.
105+
106+`routes status` uses the same target selection rules as `routes list`. When no
107+target is selected, Gordon shows local status first, then each saved remote
108+under its own heading.
109+
110+```bash
111+gordon routes status
112+gordon routes status --json
113+gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114+GORDON_REMOTE=prod gordon routes status
115+```
116+
117+### Output
118+
119+```text
120+Route Status
121+
122+Local
123+ <rich tree for local routes>
124+
125+Remote: hetzner-vps
126+ <rich tree for that remote>
127+
128+Remote: igor
129+ <rich tree for that remote>
130+```
131+
132+The rich view keeps network grouping, container status, HTTP probe status, and
133+attachments within each target.
134+
135+### JSON Output
136+
137+```json
138+[
139+ {
140+ "kind": "local",
141+ "name": "local",
142+ "routes": [
143+ {
144+ "domain": "app.example.com",
145+ "image": "myapp:latest",
146+ "container_id": "abcd1234",
147+ "container_status": "running",
148+ "http_status": 200,
149+ "network": "gordon-shared",
150+ "attachments": [
151+ {
152+ "name": "postgres",
153+ "image": "postgres:18",
154+ "status": "running"
155+ }
156+ ]
157+ }
158+ ]
59159 }
60160 ]
61161 ```
62162
163+Single-target mode still returns a one-element array. Sections can also include
164+an `error` field when a target is unavailable.
165+
63166 ### Options
64167
65168 | Option | Description |
@@ -2,8 +2,10 @@
2 2
3 Manage routes on local or remote Gordon instances.3 Manage routes on local or remote Gordon instances.
4 4
5-Remote targeting uses client config or an active remote by default.5+Most remote-capable commands use client config or an active remote by default.
6-Use `--remote` and `--token` to override. See [CLI Overview](./index.md).6+`gordon routes list` and `gordon routes status` are different: when neither
7+`--remote` nor `GORDON_REMOTE` is set, they show local routes first, then each
8+saved remote under its own heading. See [CLI Overview](./index.md).
7 9
8 ## gordon routes10 ## gordon routes
9 11
@@ -11,7 +13,8 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
11 13
12 | Subcommand | Description |14 | Subcommand | Description |
13 |------------|-------------|15 |------------|-------------|
14-| `list` | List all routes |16+| `list` | List routes by domain and image |
17+| `status` | Show detailed route status |
15 | `show` | Show details for a single route |18 | `show` | Show details for a single route |
16 | `add` | Create or update a route |19 | `add` | Create or update a route |
17 | `remove` | Remove a route |20 | `remove` | Remove a route |
@@ -21,22 +24,34 @@ Use `--remote` and `--token` to override. See [CLI Overview](./index.md).
21 24
22 ## gordon routes list25 ## gordon routes list
23 26
24-List all configured routes.27+List routes by domain and image only.
28+
29+When no target is selected, Gordon shows local routes first, then each saved
30+remote under its own heading. Use `--remote` or `GORDON_REMOTE` to show one
31+target only.
25 32
26 ```bash33 ```bash
27 gordon routes list34 gordon routes list
28 gordon routes list --json35 gordon routes list --json
29 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN36 gordon routes list --remote https://gordon.mydomain.com --token $TOKEN
37+GORDON_REMOTE=prod gordon routes list
30 ```38 ```
31 39
32 ### Output40 ### Output
33 41
34-```42+```text
35-Domain Image Status43+Routes
36---------------------------------------------------------------------------------44+
37-app.example.com myapp:latest running45+Local
38-api.example.com myapi:v2.1.0 running46+ app.example.com myapp:latest
39-admin.example.com admin-panel:latest stopped47+ api.example.com myapi:v2.1.0
48+
49+Remote: hetzner-vps
50+ gordon.example.com gordon-webapp:latest
51+
52+Remote: igor
53+ grafana.supri.xyz grafana
54+ test.supri.xyz hello-test
40 ```55 ```
41 56
42 ### JSON Output57 ### JSON Output
@@ -48,18 +63,106 @@ gordon routes list --json
48 ```json63 ```json
49 [64 [
50 {65 {
51- "domain": "app.example.com",66+ "kind": "local",
52- "image": "myapp:latest",67+ "name": "local",
53- "status": "running"68+ "routes": [
69+ {
70+ "domain": "app.example.com",
71+ "image": "myapp:latest"
72+ }
73+ ]
54 },74 },
55 {75 {
56- "domain": "api.example.com",76+ "kind": "remote",
57- "image": "myapi:v2.1.0",77+ "name": "igor",
58- "status": "running"78+ "url": "https://gordon.supri.xyz",
79+ "routes": [
80+ {
81+ "domain": "grafana.supri.xyz",
82+ "image": "grafana"
83+ }
84+ ]
85+ }
86+]
87+```
88+
89+Single-target mode still returns a one-element array. Sections can also include
90+an `error` field when a target is unavailable.
91+
92+### Options
93+
94+| Option | Description |
95+|--------|-------------|
96+| `--json` | Output routes as JSON |
97+| `--remote, -r` | Remote name or URL (e.g., prod, https://gordon.mydomain.com) |
98+| `--token` | Authentication token for remote |
99+
100+---
101+
102+## gordon routes status
103+
104+Show detailed route status for each target.
105+
106+`routes status` uses the same target selection rules as `routes list`. When no
107+target is selected, Gordon shows local status first, then each saved remote
108+under its own heading.
109+
110+```bash
111+gordon routes status
112+gordon routes status --json
113+gordon routes status --remote https://gordon.mydomain.com --token $TOKEN
114+GORDON_REMOTE=prod gordon routes status
115+```
116+
117+### Output
118+
119+```text
120+Route Status
121+
122+Local
123+ <rich tree for local routes>
124+
125+Remote: hetzner-vps
126+ <rich tree for that remote>
127+
128+Remote: igor
129+ <rich tree for that remote>
130+```
131+
132+The rich view keeps network grouping, container status, HTTP probe status, and
133+attachments within each target.
134+
135+### JSON Output
136+
137+```json
138+[
139+ {
140+ "kind": "local",
141+ "name": "local",
142+ "routes": [
143+ {
144+ "domain": "app.example.com",
145+ "image": "myapp:latest",
146+ "container_id": "abcd1234",
147+ "container_status": "running",
148+ "http_status": 200,
149+ "network": "gordon-shared",
150+ "attachments": [
151+ {
152+ "name": "postgres",
153+ "image": "postgres:18",
154+ "status": "running"
155+ }
156+ ]
157+ }
158+ ]
59 }159 }
60 ]160 ]
61 ```161 ```
62 162
163+Single-target mode still returns a one-element array. Sections can also include
164+an `error` field when a target is unavailable.
165+
63 ### Options166 ### Options
64 167
65 | Option | Description |168 | Option | Description |
M internal/adapters/in/cli/controlplane_resolver.go +5 -0
@@ -28,6 +28,11 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
2828 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
2929 }
3030
31+ return resolveLocalControlPlane(configPath)
32+}
33+
34+func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35+
3136 kernel, err := app.NewKernel(configPath)
3237 if err != nil {
3338 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
@@ -28,6 +28,11 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
28 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil28 return &controlPlaneHandle{plane: NewRemoteControlPlane(client), isRemote: true}, nil
29 }29 }
30 30
31+ return resolveLocalControlPlane(configPath)
32+}
33+
34+func resolveLocalControlPlane(configPath string) (*controlPlaneHandle, error) {
35+
31 kernel, err := app.NewKernel(configPath)36 kernel, err := app.NewKernel(configPath)
32 if err != nil {37 if err != nil {
33 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)38 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
M internal/adapters/in/cli/json_parity_test.go +1 -0
@@ -13,6 +13,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
1313 builder func() *cobra.Command
1414 }{
1515 {"routes list", newRoutesListCmd},
16+ {"routes status", newRoutesStatusCmd},
1617 {"attachments list", newAttachmentsListCmd},
1718 {"secrets list", newSecretsListCmd},
1819 {"images list", newImagesListCmd},
@@ -13,6 +13,7 @@ func TestAllListCommands_AcceptJSONFlag(t *testing.T) {
13 builder func() *cobra.Command13 builder func() *cobra.Command
14 }{14 }{
15 {"routes list", newRoutesListCmd},15 {"routes list", newRoutesListCmd},
16+ {"routes status", newRoutesStatusCmd},
16 {"attachments list", newAttachmentsListCmd},17 {"attachments list", newAttachmentsListCmd},
17 {"secrets list", newSecretsListCmd},18 {"secrets list", newSecretsListCmd},
18 {"images list", newImagesListCmd},19 {"images list", newImagesListCmd},
M internal/adapters/in/cli/json_test.go +59 -0
@@ -266,6 +266,65 @@ func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
266266 assert.Equal(t, payload[0], got[0])
267267 }
268268
269+func TestRoutesStatus_JSONFlag_Accepted(t *testing.T) {
270+ cmd := newRoutesStatusCmd()
271+ f := cmd.Flags().Lookup("json")
272+ assert.NotNil(t, f)
273+ if f != nil {
274+ assert.Equal(t, "false", f.DefValue)
275+ }
276+}
277+
278+func TestRoutesList_JSONShape_RoundTripsSections(t *testing.T) {
279+ payload := []routeListSection{{
280+ Kind: "local",
281+ Name: "local",
282+ Routes: []routeListItem{{
283+ Domain: "app.local",
284+ Image: "myapp:latest",
285+ }},
286+ }}
287+
288+ encoded, err := json.Marshal(payload)
289+ require.NoError(t, err)
290+
291+ var got []routeListSection
292+ require.NoError(t, json.Unmarshal(encoded, &got))
293+ require.Len(t, got, 1)
294+ assert.Equal(t, "local", got[0].Kind)
295+ assert.Equal(t, "app.local", got[0].Routes[0].Domain)
296+}
297+
298+func TestRoutesStatus_JSONShape_RoundTripsSections(t *testing.T) {
299+ payload := []routeStatusSection{{
300+ Kind: "remote",
301+ Name: "igor",
302+ URL: "https://gordon.supri.xyz",
303+ Routes: []routeStatusItem{{
304+ Domain: "grafana.supri.xyz",
305+ Image: "grafana",
306+ ContainerStatus: "running",
307+ HTTPStatus: 200,
308+ Network: "gordon-shared",
309+ Attachments: []routeStatusAttachment{{
310+ Name: "prometheus",
311+ Image: "prometheus:v5",
312+ Status: "running",
313+ }},
314+ }},
315+ }}
316+
317+ encoded, err := json.Marshal(payload)
318+ require.NoError(t, err)
319+
320+ var got []routeStatusSection
321+ require.NoError(t, json.Unmarshal(encoded, &got))
322+ require.Len(t, got, 1)
323+ assert.Equal(t, "igor", got[0].Name)
324+ assert.Equal(t, 200, got[0].Routes[0].HTTPStatus)
325+ assert.Equal(t, "prometheus", got[0].Routes[0].Attachments[0].Name)
326+}
327+
269328 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
270329 var out bytes.Buffer
271330 err := writeJSON(&out, map[string]any{"ok": true})
@@ -266,6 +266,65 @@ func TestRoutesList_JSONShape_RoundTripsLocalPayload(t *testing.T) {
266 assert.Equal(t, payload[0], got[0])266 assert.Equal(t, payload[0], got[0])
267 }267 }
268 268
269+func TestRoutesStatus_JSONFlag_Accepted(t *testing.T) {
270+ cmd := newRoutesStatusCmd()
271+ f := cmd.Flags().Lookup("json")
272+ assert.NotNil(t, f)
273+ if f != nil {
274+ assert.Equal(t, "false", f.DefValue)
275+ }
276+}
277+
278+func TestRoutesList_JSONShape_RoundTripsSections(t *testing.T) {
279+ payload := []routeListSection{{
280+ Kind: "local",
281+ Name: "local",
282+ Routes: []routeListItem{{
283+ Domain: "app.local",
284+ Image: "myapp:latest",
285+ }},
286+ }}
287+
288+ encoded, err := json.Marshal(payload)
289+ require.NoError(t, err)
290+
291+ var got []routeListSection
292+ require.NoError(t, json.Unmarshal(encoded, &got))
293+ require.Len(t, got, 1)
294+ assert.Equal(t, "local", got[0].Kind)
295+ assert.Equal(t, "app.local", got[0].Routes[0].Domain)
296+}
297+
298+func TestRoutesStatus_JSONShape_RoundTripsSections(t *testing.T) {
299+ payload := []routeStatusSection{{
300+ Kind: "remote",
301+ Name: "igor",
302+ URL: "https://gordon.supri.xyz",
303+ Routes: []routeStatusItem{{
304+ Domain: "grafana.supri.xyz",
305+ Image: "grafana",
306+ ContainerStatus: "running",
307+ HTTPStatus: 200,
308+ Network: "gordon-shared",
309+ Attachments: []routeStatusAttachment{{
310+ Name: "prometheus",
311+ Image: "prometheus:v5",
312+ Status: "running",
313+ }},
314+ }},
315+ }}
316+
317+ encoded, err := json.Marshal(payload)
318+ require.NoError(t, err)
319+
320+ var got []routeStatusSection
321+ require.NoError(t, json.Unmarshal(encoded, &got))
322+ require.Len(t, got, 1)
323+ assert.Equal(t, "igor", got[0].Name)
324+ assert.Equal(t, 200, got[0].Routes[0].HTTPStatus)
325+ assert.Equal(t, "prometheus", got[0].Routes[0].Attachments[0].Name)
326+}
327+
269 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {328 func TestWriteJSON_ProducesValidIndentedJSON(t *testing.T) {
270 var out bytes.Buffer329 var out bytes.Buffer
271 err := writeJSON(&out, map[string]any{"ok": true})330 err := writeJSON(&out, map[string]any{"ok": true})
M internal/adapters/in/cli/routes.go +695 -184
@@ -5,8 +5,11 @@ import (
55 "errors"
66 "fmt"
77 "io"
8+ "os"
89 "sort"
10+ "strconv"
911 "strings"
12+ "sync"
1013
1114 "github.com/spf13/cobra"
1215
@@ -86,6 +89,58 @@ type networkGroup struct {
8689 routes []remote.RouteInfo
8790 }
8891
92+type routeListItem struct {
93+ Domain string `json:"domain"`
94+ Image string `json:"image"`
95+}
96+
97+type routeListSection struct {
98+ Kind string `json:"kind"`
99+ Name string `json:"name"`
100+ URL string `json:"url,omitempty"`
101+ Error string `json:"error,omitempty"`
102+ Routes []routeListItem `json:"routes,omitempty"`
103+}
104+
105+type routeStatusAttachment struct {
106+ Name string `json:"name"`
107+ Image string `json:"image"`
108+ Status string `json:"status"`
109+}
110+
111+type routeStatusItem struct {
112+ Domain string `json:"domain"`
113+ Image string `json:"image"`
114+ ContainerID string `json:"container_id,omitempty"`
115+ ContainerStatus string `json:"container_status"`
116+ HTTPStatus int `json:"http_status"`
117+ HealthError string `json:"health_error,omitempty"`
118+ Network string `json:"network"`
119+ Attachments []routeStatusAttachment `json:"attachments,omitempty"`
120+}
121+
122+type routeStatusSection struct {
123+ Kind string `json:"kind"`
124+ Name string `json:"name"`
125+ URL string `json:"url,omitempty"`
126+ Error string `json:"error,omitempty"`
127+ Routes []routeStatusItem `json:"routes,omitempty"`
128+}
129+
130+type routesListDeps struct {
131+ explicitRemote func() (*remote.ResolvedRemote, bool)
132+ loadLocal func(context.Context, string) (routeListSection, error)
133+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
134+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
135+}
136+
137+type routesStatusDeps struct {
138+ explicitRemote func() (*remote.ResolvedRemote, bool)
139+ loadLocal func(context.Context, string) (routeStatusSection, error)
140+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
141+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
142+}
143+
89144 // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90145 // and solo routes. Both are sorted alphabetically by domain.
91146 func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
@@ -136,12 +191,7 @@ the local Gordon configuration.`,
136191 }
137192
138193 cmd.AddCommand(newRoutesListCmd())
139-
140- // "status" is an alias for "list"
141- statusCmd := newRoutesListCmd()
142- statusCmd.Use = "status"
143- statusCmd.Short = "Show status of all routes (alias for list)"
144- cmd.AddCommand(statusCmd)
194+ cmd.AddCommand(newRoutesStatusCmd())
145195
146196 cmd.AddCommand(newRoutesShowCmd())
147197 cmd.AddCommand(newRoutesAddCmd())
@@ -158,13 +208,16 @@ func newRoutesListCmd() *cobra.Command {
158208 Use: "list",
159209 Short: "List all routes",
160210 RunE: func(cmd *cobra.Command, args []string) error {
161- ctx := cmd.Context()
211+ sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
212+ if err != nil {
213+ return err
214+ }
162215
163- client, isRemote := GetRemoteClient()
164- if isRemote {
165- return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
216+ if jsonOut {
217+ return writeJSON(cmd.OutOrStdout(), sections)
166218 }
167- return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())
219+
220+ return renderRoutesListSections(cmd.OutOrStdout(), sections)
168221 },
169222 }
170223
@@ -173,28 +226,23 @@ func newRoutesListCmd() *cobra.Command {
173226 return cmd
174227 }
175228
176-func newRoutesShowCmd() *cobra.Command {
229+func newRoutesStatusCmd() *cobra.Command {
177230 var jsonOut bool
178231
179232 cmd := &cobra.Command{
180- Use: "show <domain>",
181- Short: "Show details for a single route",
182- Long: `Display detailed information about a specific route including its image,
183-container status, and health.
184-
185-Examples:
186- gordon routes show app.mydomain.com
187- gordon routes show app.mydomain.com --json
188- gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
189- Args: cobra.ExactArgs(1),
233+ Use: "status",
234+ Short: "Show detailed route status",
190235 RunE: func(cmd *cobra.Command, args []string) error {
191- ctx := cmd.Context()
192- handle, err := resolveControlPlane(configPath)
236+ sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
193237 if err != nil {
194238 return err
195239 }
196- defer handle.close()
197- return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
240+
241+ if jsonOut {
242+ return writeJSON(cmd.OutOrStdout(), sections)
243+ }
244+
245+ return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
198246 },
199247 }
200248
@@ -203,248 +251,711 @@ Examples:
203251 return cmd
204252 }
205253
206-func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
207- route, err := cp.GetRoute(ctx, routeDomain)
208- if err != nil {
209- if errors.Is(err, domain.ErrRouteNotFound) {
210- return fmt.Errorf("route %q not found", routeDomain)
211- }
212- return fmt.Errorf("failed to get route: %w", err)
254+func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
255+ if deps.explicitRemote == nil {
256+ deps.explicitRemote = resolveRoutesExplicitRemote
257+ }
258+ if deps.loadLocal == nil {
259+ deps.loadLocal = loadRoutesListLocalSection
260+ }
261+ if deps.listRemotes == nil {
262+ deps.listRemotes = remote.ListRemotes
263+ }
264+ if deps.loadRemote == nil {
265+ deps.loadRemote = loadRoutesListRemoteSection
213266 }
214267
215- health, _ := cp.GetHealth(ctx)
216- var routeHealth *remote.RouteHealth
217- if health != nil {
218- routeHealth = health[routeDomain]
268+ if resolved, ok := deps.explicitRemote(); ok {
269+ section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
270+ return []routeListSection{section}, nil
219271 }
220272
221- containerStatus := "unknown"
222- httpStatus := 0
223- if routeHealth != nil {
224- containerStatus = routeHealth.ContainerStatus
225- httpStatus = routeHealth.HTTPStatus
273+ return collectRoutesListAggregateSections(ctx, cfgPath, deps)
274+}
275+
276+func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
277+ section, _ := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
278+ return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
279+}
280+
281+func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
282+
283+ type localResult struct {
284+ section routeListSection
285+ }
286+ type remotesResult struct {
287+ entries map[string]remote.RemoteEntry
288+ err error
226289 }
227290
228- if jsonOut {
229- payload := map[string]any{
230- "domain": route.Domain,
231- "image": route.Image,
232- "container_status": containerStatus,
233- "http_status": httpStatus,
234- }
235- return writeJSON(out, payload)
291+ localCh := make(chan localResult, 1)
292+ remotesCh := make(chan remotesResult, 1)
293+
294+ go func() {
295+ section, _ := deps.loadLocal(ctx, cfgPath)
296+ localCh <- localResult{section: section}
297+ }()
298+ go func() {
299+ entries, _, err := deps.listRemotes()
300+ remotesCh <- remotesResult{entries: entries, err: err}
301+ }()
302+
303+ local := (<-localCh).section
304+ remotes := <-remotesCh
305+
306+ sections := make([]routeListSection, 0, 1)
307+ sections = append(sections, normalizeRouteListLocalSection(local))
308+
309+ if remotes.err != nil {
310+ sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
311+ return sections, nil
236312 }
237313
238- if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
314+ names := sortedRemoteNames(remotes.entries)
315+ remoteSections := make([]routeListSection, len(names))
316+ var wg sync.WaitGroup
317+ for i, name := range names {
318+ i, name := i, name
319+ entry := remotes.entries[name]
320+ wg.Add(1)
321+ go func() {
322+ defer wg.Done()
323+ section, _ := deps.loadRemote(ctx, name, entry)
324+ remoteSections[i] = normalizeRouteListRemoteSection(section, name, entry)
325+ }()
326+ }
327+ wg.Wait()
328+
329+ sections = append(sections, remoteSections...)
330+ return sections, nil
331+}
332+
333+func normalizeRouteListLocalSection(section routeListSection) routeListSection {
334+ if section.Kind == "" {
335+ section.Kind = "local"
336+ }
337+ if section.Name == "" {
338+ section.Name = "local"
339+ }
340+ return section
341+}
342+
343+func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
344+ if section.Kind == "" {
345+ section.Kind = "remote"
346+ }
347+ if section.Name == "" {
348+ section.Name = name
349+ }
350+ if section.URL == "" {
351+ section.URL = entry.URL
352+ }
353+ return section
354+}
355+
356+func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
357+ if deps.explicitRemote == nil {
358+ deps.explicitRemote = resolveRoutesExplicitRemote
359+ }
360+ if deps.loadLocal == nil {
361+ deps.loadLocal = loadRoutesStatusLocalSection
362+ }
363+ if deps.listRemotes == nil {
364+ deps.listRemotes = remote.ListRemotes
365+ }
366+ if deps.loadRemote == nil {
367+ deps.loadRemote = loadRoutesStatusRemoteSection
368+ }
369+
370+ if resolved, ok := deps.explicitRemote(); ok {
371+ section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
372+ return []routeStatusSection{section}, nil
373+ }
374+
375+ return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
376+}
377+
378+func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
379+ section, _ := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
380+ return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
381+}
382+
383+func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
384+
385+ type localResult struct {
386+ section routeStatusSection
387+ }
388+ type remotesResult struct {
389+ entries map[string]remote.RemoteEntry
390+ err error
391+ }
392+
393+ localCh := make(chan localResult, 1)
394+ remotesCh := make(chan remotesResult, 1)
395+
396+ go func() {
397+ section, _ := deps.loadLocal(ctx, cfgPath)
398+ localCh <- localResult{section: section}
399+ }()
400+ go func() {
401+ entries, _, err := deps.listRemotes()
402+ remotesCh <- remotesResult{entries: entries, err: err}
403+ }()
404+
405+ local := (<-localCh).section
406+ remotes := <-remotesCh
407+
408+ sections := make([]routeStatusSection, 0, 1)
409+ sections = append(sections, normalizeRouteStatusLocalSection(local))
410+
411+ if remotes.err != nil {
412+ sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
413+ return sections, nil
414+ }
415+
416+ names := sortedRemoteNames(remotes.entries)
417+ remoteSections := make([]routeStatusSection, len(names))
418+ var wg sync.WaitGroup
419+ for i, name := range names {
420+ i, name := i, name
421+ entry := remotes.entries[name]
422+ wg.Add(1)
423+ go func() {
424+ defer wg.Done()
425+ section, _ := deps.loadRemote(ctx, name, entry)
426+ remoteSections[i] = normalizeRouteStatusRemoteSection(section, name, entry)
427+ }()
428+ }
429+ wg.Wait()
430+
431+ sections = append(sections, remoteSections...)
432+ return sections, nil
433+}
434+
435+func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
436+ if section.Kind == "" {
437+ section.Kind = "local"
438+ }
439+ if section.Name == "" {
440+ section.Name = "local"
441+ }
442+ return section
443+}
444+
445+func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
446+ if section.Kind == "" {
447+ section.Kind = "remote"
448+ }
449+ if section.Name == "" {
450+ section.Name = name
451+ }
452+ if section.URL == "" {
453+ section.URL = entry.URL
454+ }
455+ return section
456+}
457+
458+func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
459+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
239460 return err
240461 }
241462 if err := cliWriteLine(out, ""); err != nil {
242463 return err
243464 }
244- if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
465+
466+ for i, section := range sections {
467+ if i > 0 {
468+ if err := cliWriteLine(out, ""); err != nil {
469+ return err
470+ }
471+ }
472+ if err := renderRoutesListSection(out, section); err != nil {
473+ return err
474+ }
475+ }
476+
477+ return nil
478+}
479+
480+func renderRoutesListSection(out io.Writer, section routeListSection) error {
481+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
245482 return err
246483 }
247- if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
484+
485+ if len(section.Routes) > 0 {
486+ const imageColWidth = 45
487+ rows := make([][]string, 0, len(section.Routes))
488+ for _, route := range section.Routes {
489+ rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
490+ }
491+
492+ table := components.NewTable(
493+ components.WithColumns([]components.TableColumn{
494+ {Title: "Domain", Width: 30},
495+ {Title: "Image", Width: imageColWidth},
496+ }),
497+ components.WithRows(rows),
498+ )
499+
500+ if err := cliWriteLine(out, table.View()); err != nil {
501+ return err
502+ }
503+ }
504+
505+ if section.Error != "" {
506+ return cliWriteLine(out, cliRenderWarning(section.Error))
507+ }
508+
509+ if len(section.Routes) == 0 {
510+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
511+ }
512+
513+ return nil
514+}
515+
516+func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
517+ if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
248518 return err
249519 }
250- if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
520+ if err := cliWriteLine(out, ""); err != nil {
251521 return err
252522 }
253- if httpStatus > 0 {
254- if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
523+
524+ for i, section := range sections {
525+ if i > 0 {
526+ if err := cliWriteLine(out, ""); err != nil {
527+ return err
528+ }
529+ }
530+ if err := renderRoutesStatusSection(out, section); err != nil {
531+ return err
532+ }
533+ }
534+
535+ return nil
536+}
537+
538+func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
539+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
540+ return err
541+ }
542+
543+ if len(section.Routes) > 0 {
544+ tree := buildRouteStatusTree(section.Routes)
545+ if err := cliWriteLine(out, tree.Render()); err != nil {
255546 return err
256547 }
257548 }
258549
550+ if section.Error != "" {
551+ return cliWriteLine(out, cliRenderWarning(section.Error))
552+ }
553+
554+ if len(section.Routes) == 0 {
555+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
556+ }
557+
259558 return nil
260559 }
261560
262-// runRoutesListRemote lists routes from a remote Gordon instance.
263-func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {
561+func routeSectionHeading(kind, name string) string {
562+ switch kind {
563+ case "local":
564+ return "Local"
565+ case "remote":
566+ if name != "" {
567+ return "Remote: " + name
568+ }
569+ return "Remote"
570+ default:
571+ if name != "" {
572+ return capitalizeKind(kind) + ": " + name
573+ }
574+ return capitalizeKind(kind)
575+ }
576+}
577+
578+func capitalizeKind(kind string) string {
579+ if kind == "" {
580+ return ""
581+ }
582+ if len(kind) == 1 {
583+ return strings.ToUpper(kind)
584+ }
585+ return strings.ToUpper(kind[:1]) + kind[1:]
586+}
587+
588+func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool) {
589+ target, ok := resolveRoutesExplicitTarget()
590+ if !ok {
591+ return nil, false
592+ }
593+
594+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
595+ return &remote.ResolvedRemote{
596+ URL: target,
597+ Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
598+ InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
599+ }, true
600+ }
601+
602+ remotes, err := remote.LoadRemotes("")
603+ if err != nil {
604+ return nil, false
605+ }
606+
607+ if remotes != nil {
608+ if entry, found := remotes.Remotes[target]; found {
609+ return &remote.ResolvedRemote{
610+ Name: target,
611+ URL: entry.URL,
612+ Token: resolveRoutesTokenForTarget(target, entry),
613+ InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
614+ }, true
615+ }
616+ }
617+
618+ return nil, false
619+}
620+
621+func resolveRoutesExplicitTarget() (string, bool) {
622+ if target := strings.TrimSpace(remoteFlag); target != "" {
623+ return target, true
624+ }
625+ if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
626+ return target, true
627+ }
628+ return "", false
629+}
630+
631+func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
632+ if token := strings.TrimSpace(tokenFlag); token != "" {
633+ return token
634+ }
635+ if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
636+ return token
637+ }
638+ if name != "" {
639+ return remote.ResolveTokenForRemote(name, entry)
640+ }
641+ return ""
642+}
643+
644+func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
645+ if insecureTLSFlag {
646+ return true
647+ }
648+ if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
649+ if value, err := strconv.ParseBool(env); err == nil {
650+ return value
651+ }
652+ }
653+ if name != "" {
654+ return entry.InsecureTLS
655+ }
656+ return false
657+}
658+
659+func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
660+ return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
661+}
662+
663+func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
664+ local, err := GetLocalServices(cfgPath)
665+ if err != nil {
666+ return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
667+ }
668+
669+ routes := local.GetConfigService().GetRoutes(ctx)
670+ section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
671+ for _, route := range routes {
672+ section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
673+ }
674+
675+ return section, nil
676+}
677+
678+func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
679+ section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
680+ client := newRoutesTargetClient(name, entry)
264681 routes, err := client.ListRoutesWithDetails(ctx)
265682 if err != nil {
266- return fmt.Errorf("failed to list routes: %w", err)
683+ section.Error = err.Error()
684+ return section, nil
267685 }
268686
269- health, _ := client.GetHealth(ctx)
270- if health == nil {
271- health = make(map[string]*remote.RouteHealth)
687+ section.Routes = routeListItemsFromInfos(routes)
688+ return section, nil
689+}
690+
691+func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
692+ section := routeStatusSection{Kind: "local", Name: "local"}
693+ handle, err := resolveLocalControlPlane(cfgPath)
694+ if err != nil {
695+ section.Error = err.Error()
696+ return section, nil
272697 }
698+ defer handle.close()
273699
274- if jsonOut {
275- return routesListJSON(out, routes, health)
700+ routes, err := handle.plane.ListRoutesWithDetails(ctx)
701+ if err != nil {
702+ section.Error = err.Error()
703+ return section, nil
276704 }
277705
278- if len(routes) == 0 {
279- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
280- return nil
706+ health, err := handle.plane.GetHealth(ctx)
707+ if err != nil {
708+ section.Error = err.Error()
709+ health = nil
281710 }
282711
283- groups, solo := groupRoutesByNetwork(routes)
712+ section.Routes = routeStatusItemsFromInfos(routes, health)
713+ return section, nil
714+}
715+
716+func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
717+ section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
718+ client := newRoutesTargetClient(name, entry)
719+ cp := NewRemoteControlPlane(client)
284720
285- type sortableItem struct {
286- sortKey string
287- group *networkGroup
288- route *remote.RouteInfo
721+ routes, err := cp.ListRoutesWithDetails(ctx)
722+ if err != nil {
723+ section.Error = err.Error()
724+ return section, nil
289725 }
290726
291- var items []sortableItem
292- for i := range groups {
293- items = append(items, sortableItem{
294- sortKey: groups[i].routes[0].Domain,
295- group: &groups[i],
296- })
727+ health, err := cp.GetHealth(ctx)
728+ if err != nil {
729+ section.Error = err.Error()
730+ health = nil
297731 }
298- for i := range solo {
299- items = append(items, sortableItem{
300- sortKey: solo[i].Domain,
301- route: &solo[i],
302- })
732+
733+ section.Routes = routeStatusItemsFromInfos(routes, health)
734+ return section, nil
735+}
736+
737+func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
738+ items := make([]routeStatusItem, 0, len(routes))
739+ for _, route := range routes {
740+ item := routeStatusItem{
741+ Domain: route.Domain,
742+ Image: route.Image,
743+ ContainerID: route.ContainerID,
744+ ContainerStatus: route.ContainerStatus,
745+ Network: route.Network,
746+ }
747+ if item.ContainerStatus == "" {
748+ if routeHealth := health[route.Domain]; routeHealth != nil {
749+ item.ContainerStatus = routeHealth.ContainerStatus
750+ item.HTTPStatus = routeHealth.HTTPStatus
751+ item.HealthError = routeHealth.Error
752+ }
753+ }
754+ if item.ContainerStatus == "" {
755+ item.ContainerStatus = "unknown"
756+ }
757+ if item.HTTPStatus == 0 {
758+ if routeHealth := health[route.Domain]; routeHealth != nil {
759+ item.HTTPStatus = routeHealth.HTTPStatus
760+ item.HealthError = routeHealth.Error
761+ }
762+ }
763+ if item.HealthError == "" {
764+ if routeHealth := health[route.Domain]; routeHealth != nil {
765+ item.HealthError = routeHealth.Error
766+ }
767+ }
768+ item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
769+ for _, attachment := range route.Attachments {
770+ status := attachment.Status
771+ if status == "" {
772+ status = "unknown"
773+ }
774+ item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
775+ }
776+ items = append(items, item)
303777 }
304- sort.Slice(items, func(i, j int) bool {
305- return items[i].sortKey < items[j].sortKey
778+ return items
779+}
780+
781+func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
782+ items := make([]routeListItem, 0, len(routes))
783+ for _, route := range routes {
784+ items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
785+ }
786+ return items
787+}
788+
789+func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
790+ sortedRoutes := make([]routeStatusItem, len(routes))
791+ copy(sortedRoutes, routes)
792+ sort.SliceStable(sortedRoutes, func(i, j int) bool {
793+ if sortedRoutes[i].Network != sortedRoutes[j].Network {
794+ return sortedRoutes[i].Network < sortedRoutes[j].Network
795+ }
796+ if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
797+ return sortedRoutes[i].Domain < sortedRoutes[j].Domain
798+ }
799+ if sortedRoutes[i].Image != sortedRoutes[j].Image {
800+ return sortedRoutes[i].Image < sortedRoutes[j].Image
801+ }
802+ if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
803+ return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
804+ }
805+ if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
806+ return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
807+ }
808+ return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
306809 })
307810
811+ infos := make([]remote.RouteInfo, 0, len(routes))
812+ itemsByDomain := make(map[string]routeStatusItem, len(routes))
813+ for _, route := range sortedRoutes {
814+ infos = append(infos, remote.RouteInfo{
815+ Domain: route.Domain,
816+ Image: route.Image,
817+ ContainerID: route.ContainerID,
818+ ContainerStatus: route.ContainerStatus,
819+ Network: route.Network,
820+ Attachments: routeStatusAttachmentsToRemote(route.Attachments),
821+ })
822+ itemsByDomain[route.Domain] = route
823+ }
824+
825+ groups, solo := groupRoutesByNetwork(infos)
308826 tree := components.NewTree()
309827
310- for _, item := range items {
311- if item.group != nil {
312- g := tree.AddGroup(item.group.name)
313- for _, route := range item.group.routes {
314- title := routeTitle(route, health)
315- node := g.AddNode(title, route.Image)
316- addAttachmentChildren(node, route)
317- }
318- } else {
319- title := routeTitle(*item.route, health)
320- node := tree.AddNode(title, item.route.Image)
321- addAttachmentChildren(node, *item.route)
828+ for _, group := range groups {
829+ g := tree.AddGroup(group.name)
830+ for _, route := range group.routes {
831+ item := itemsByDomain[route.Domain]
832+ node := g.AddNode(routeStatusTitle(item), item.Image)
833+ addRouteStatusAttachmentChildren(node, item)
322834 }
323835 }
324836
325- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
326- return err
327- }
328- if err := cliWriteLine(out, ""); err != nil {
329- return err
837+ for _, route := range solo {
838+ item := itemsByDomain[route.Domain]
839+ node := tree.AddNode(routeStatusTitle(item), item.Image)
840+ addRouteStatusAttachmentChildren(node, item)
330841 }
331- return cliWriteLine(out, tree.Render())
842+
843+ return tree
332844 }
333845
334-// routeTitle builds the pre-styled title line for a route node.
335-func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
846+func routeStatusTitle(route routeStatusItem) string {
336847 containerStatus := route.ContainerStatus
337848 if containerStatus == "" {
338- if h := health[route.Domain]; h != nil {
339- containerStatus = h.ContainerStatus
340- } else {
341- containerStatus = "unknown"
342- }
849+ containerStatus = "unknown"
343850 }
344851
345- httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))
852+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
346853 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
347854
348855 return httpIcon + " " + containerIcon + " " + route.Domain
349856 }
350857
351-// addAttachmentChildren adds attachment nodes as children of a route node.
352-func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
858+func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
353859 for _, att := range route.Attachments {
354- attStatus := att.Status
355- if attStatus == "" {
356- attStatus = "unknown"
860+ status := att.Status
861+ if status == "" {
862+ status = "unknown"
357863 }
358- attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))
864+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
359865 node.AddChild(attIcon+" "+att.Name, att.Image)
360866 }
361867 }
362868
363-func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {
364- payload := make([]map[string]any, 0, len(routes))
365- for _, route := range routes {
366- routeHealth := health[route.Domain]
367- containerStatus := route.ContainerStatus
368- if containerStatus == "" {
369- if routeHealth != nil {
370- containerStatus = routeHealth.ContainerStatus
371- } else {
372- containerStatus = "unknown"
373- }
374- }
869+func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
870+ result := make([]remote.Attachment, 0, len(attachments))
871+ for _, att := range attachments {
872+ result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
873+ }
874+ return result
875+}
375876
376- httpStatus := 0
377- if routeHealth != nil {
378- httpStatus = routeHealth.HTTPStatus
379- }
877+func newRoutesShowCmd() *cobra.Command {
878+ var jsonOut bool
380879
381- attachments := make([]map[string]string, 0, len(route.Attachments))
382- for _, attachment := range route.Attachments {
383- attachments = append(attachments, map[string]string{
384- "name": attachment.Name,
385- "image": attachment.Image,
386- "status": attachment.Status,
387- })
388- }
880+ cmd := &cobra.Command{
881+ Use: "show <domain>",
882+ Short: "Show details for a single route",
883+ Long: `Display detailed information about a specific route including its image,
884+container status, and health.
389885
390- payload = append(payload, map[string]any{
391- "domain": route.Domain,
392- "image": route.Image,
393- "container_status": containerStatus,
394- "network": route.Network,
395- "http_status": httpStatus,
396- "attachments": attachments,
397- })
886+Examples:
887+ gordon routes show app.mydomain.com
888+ gordon routes show app.mydomain.com --json
889+ gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
890+ Args: cobra.ExactArgs(1),
891+ RunE: func(cmd *cobra.Command, args []string) error {
892+ ctx := cmd.Context()
893+ handle, err := resolveControlPlane(configPath)
894+ if err != nil {
895+ return err
896+ }
897+ defer handle.close()
898+ return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
899+ },
398900 }
399901
400- return writeJSON(out, payload)
902+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
903+
904+ return cmd
401905 }
402906
403-// runRoutesListLocal lists routes from local configuration.
404-func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {
405- local, err := GetLocalServices(cfgPath)
907+func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
908+ route, err := cp.GetRoute(ctx, routeDomain)
406909 if err != nil {
407- return fmt.Errorf("failed to initialize local services: %w", err)
910+ if errors.Is(err, domain.ErrRouteNotFound) {
911+ return fmt.Errorf("route %q not found", routeDomain)
912+ }
913+ return fmt.Errorf("failed to get route: %w", err)
408914 }
409915
410- routes := local.GetConfigService().GetRoutes(ctx)
916+ health, _ := cp.GetHealth(ctx)
917+ var routeHealth *remote.RouteHealth
918+ if health != nil {
919+ routeHealth = health[routeDomain]
920+ }
921+
922+ containerStatus := "unknown"
923+ httpStatus := 0
924+ if routeHealth != nil {
925+ containerStatus = routeHealth.ContainerStatus
926+ httpStatus = routeHealth.HTTPStatus
927+ }
411928
412929 if jsonOut {
413- payload := make([]map[string]string, 0, len(routes))
414- for _, route := range routes {
415- payload = append(payload, map[string]string{
416- "domain": route.Domain,
417- "image": route.Image,
418- })
930+ payload := map[string]any{
931+ "domain": route.Domain,
932+ "image": route.Image,
933+ "container_status": containerStatus,
934+ "http_status": httpStatus,
419935 }
420936 return writeJSON(out, payload)
421937 }
422938
423- if len(routes) == 0 {
424- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))
425- return nil
939+ if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
940+ return err
426941 }
427-
428- // Build table rows (no health info in local mode)
429- const imageColWidth = 45
430- rows := make([][]string, len(routes))
431- for i, route := range routes {
432- displayImage := truncateImage(route.Image, imageColWidth)
433- rows[i] = []string{route.Domain, displayImage}
942+ if err := cliWriteLine(out, ""); err != nil {
943+ return err
944+ }
945+ if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
946+ return err
947+ }
948+ if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
949+ return err
950+ }
951+ if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
952+ return err
953+ }
954+ if httpStatus > 0 {
955+ if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
956+ return err
957+ }
434958 }
435-
436- // Render table
437- table := components.NewTable(
438- components.WithColumns([]components.TableColumn{
439- {Title: "Domain", Width: 30},
440- {Title: "Image", Width: imageColWidth},
441- }),
442- components.WithRows(rows),
443- )
444-
445- _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
446- _, _ = fmt.Fprintln(out)
447- _, _ = fmt.Fprintln(out, table.View())
448959
449960 return nil
450961 }
@@ -5,8 +5,11 @@ import (
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7 "io"7 "io"
8+ "os"
8 "sort"9 "sort"
10+ "strconv"
9 "strings"11 "strings"
12+ "sync"
10 13
11 "github.com/spf13/cobra"14 "github.com/spf13/cobra"
12 15
@@ -86,6 +89,58 @@ type networkGroup struct {
86 routes []remote.RouteInfo89 routes []remote.RouteInfo
87 }90 }
88 91
92+type routeListItem struct {
93+ Domain string `json:"domain"`
94+ Image string `json:"image"`
95+}
96+
97+type routeListSection struct {
98+ Kind string `json:"kind"`
99+ Name string `json:"name"`
100+ URL string `json:"url,omitempty"`
101+ Error string `json:"error,omitempty"`
102+ Routes []routeListItem `json:"routes,omitempty"`
103+}
104+
105+type routeStatusAttachment struct {
106+ Name string `json:"name"`
107+ Image string `json:"image"`
108+ Status string `json:"status"`
109+}
110+
111+type routeStatusItem struct {
112+ Domain string `json:"domain"`
113+ Image string `json:"image"`
114+ ContainerID string `json:"container_id,omitempty"`
115+ ContainerStatus string `json:"container_status"`
116+ HTTPStatus int `json:"http_status"`
117+ HealthError string `json:"health_error,omitempty"`
118+ Network string `json:"network"`
119+ Attachments []routeStatusAttachment `json:"attachments,omitempty"`
120+}
121+
122+type routeStatusSection struct {
123+ Kind string `json:"kind"`
124+ Name string `json:"name"`
125+ URL string `json:"url,omitempty"`
126+ Error string `json:"error,omitempty"`
127+ Routes []routeStatusItem `json:"routes,omitempty"`
128+}
129+
130+type routesListDeps struct {
131+ explicitRemote func() (*remote.ResolvedRemote, bool)
132+ loadLocal func(context.Context, string) (routeListSection, error)
133+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
134+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeListSection, error)
135+}
136+
137+type routesStatusDeps struct {
138+ explicitRemote func() (*remote.ResolvedRemote, bool)
139+ loadLocal func(context.Context, string) (routeStatusSection, error)
140+ listRemotes func() (map[string]remote.RemoteEntry, string, error)
141+ loadRemote func(context.Context, string, remote.RemoteEntry) (routeStatusSection, error)
142+}
143+
89 // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)144 // groupRoutesByNetwork separates routes into network groups (2+ routes sharing a network)
90 // and solo routes. Both are sorted alphabetically by domain.145 // and solo routes. Both are sorted alphabetically by domain.
91 func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {146 func groupRoutesByNetwork(routes []remote.RouteInfo) ([]networkGroup, []remote.RouteInfo) {
@@ -136,12 +191,7 @@ the local Gordon configuration.`,
136 }191 }
137 192
138 cmd.AddCommand(newRoutesListCmd())193 cmd.AddCommand(newRoutesListCmd())
139-194+ cmd.AddCommand(newRoutesStatusCmd())
140- // "status" is an alias for "list"
141- statusCmd := newRoutesListCmd()
142- statusCmd.Use = "status"
143- statusCmd.Short = "Show status of all routes (alias for list)"
144- cmd.AddCommand(statusCmd)
145 195
146 cmd.AddCommand(newRoutesShowCmd())196 cmd.AddCommand(newRoutesShowCmd())
147 cmd.AddCommand(newRoutesAddCmd())197 cmd.AddCommand(newRoutesAddCmd())
@@ -158,13 +208,16 @@ func newRoutesListCmd() *cobra.Command {
158 Use: "list",208 Use: "list",
159 Short: "List all routes",209 Short: "List all routes",
160 RunE: func(cmd *cobra.Command, args []string) error {210 RunE: func(cmd *cobra.Command, args []string) error {
161- ctx := cmd.Context()211+ sections, err := collectRoutesListSections(cmd.Context(), configPath, routesListDeps{})
212+ if err != nil {
213+ return err
214+ }
162 215
163- client, isRemote := GetRemoteClient()216+ if jsonOut {
164- if isRemote {217+ return writeJSON(cmd.OutOrStdout(), sections)
165- return runRoutesListRemote(ctx, client, jsonOut, cmd.OutOrStdout())
166 }218 }
167- return runRoutesListLocal(ctx, configPath, jsonOut, cmd.OutOrStdout())219+
220+ return renderRoutesListSections(cmd.OutOrStdout(), sections)
168 },221 },
169 }222 }
170 223
@@ -173,28 +226,23 @@ func newRoutesListCmd() *cobra.Command {
173 return cmd226 return cmd
174 }227 }
175 228
176-func newRoutesShowCmd() *cobra.Command {229+func newRoutesStatusCmd() *cobra.Command {
177 var jsonOut bool230 var jsonOut bool
178 231
179 cmd := &cobra.Command{232 cmd := &cobra.Command{
180- Use: "show <domain>",233+ Use: "status",
181- Short: "Show details for a single route",234+ Short: "Show detailed route status",
182- Long: `Display detailed information about a specific route including its image,
183-container status, and health.
184-
185-Examples:
186- gordon routes show app.mydomain.com
187- gordon routes show app.mydomain.com --json
188- gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
189- Args: cobra.ExactArgs(1),
190 RunE: func(cmd *cobra.Command, args []string) error {235 RunE: func(cmd *cobra.Command, args []string) error {
191- ctx := cmd.Context()236+ sections, err := collectRoutesStatusSections(cmd.Context(), configPath, routesStatusDeps{})
192- handle, err := resolveControlPlane(configPath)
193 if err != nil {237 if err != nil {
194 return err238 return err
195 }239 }
196- defer handle.close()240+
197- return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)241+ if jsonOut {
242+ return writeJSON(cmd.OutOrStdout(), sections)
243+ }
244+
245+ return renderRoutesStatusSections(cmd.OutOrStdout(), sections)
198 },246 },
199 }247 }
200 248
@@ -203,248 +251,711 @@ Examples:
203 return cmd251 return cmd
204 }252 }
205 253
206-func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {254+func collectRoutesListSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
207- route, err := cp.GetRoute(ctx, routeDomain)255+ if deps.explicitRemote == nil {
208- if err != nil {256+ deps.explicitRemote = resolveRoutesExplicitRemote
209- if errors.Is(err, domain.ErrRouteNotFound) {257+ }
210- return fmt.Errorf("route %q not found", routeDomain)258+ if deps.loadLocal == nil {
211- }259+ deps.loadLocal = loadRoutesListLocalSection
212- return fmt.Errorf("failed to get route: %w", err)260+ }
261+ if deps.listRemotes == nil {
262+ deps.listRemotes = remote.ListRemotes
263+ }
264+ if deps.loadRemote == nil {
265+ deps.loadRemote = loadRoutesListRemoteSection
213 }266 }
214 267
215- health, _ := cp.GetHealth(ctx)268+ if resolved, ok := deps.explicitRemote(); ok {
216- var routeHealth *remote.RouteHealth269+ section := loadRoutesListExplicitRemoteSection(ctx, deps, resolved)
217- if health != nil {270+ return []routeListSection{section}, nil
218- routeHealth = health[routeDomain]
219 }271 }
220 272
221- containerStatus := "unknown"273+ return collectRoutesListAggregateSections(ctx, cfgPath, deps)
222- httpStatus := 0274+}
223- if routeHealth != nil {275+
224- containerStatus = routeHealth.ContainerStatus276+func loadRoutesListExplicitRemoteSection(ctx context.Context, deps routesListDeps, resolved *remote.ResolvedRemote) routeListSection {
225- httpStatus = routeHealth.HTTPStatus277+ section, _ := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
278+ return normalizeRouteListRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
279+}
280+
281+func collectRoutesListAggregateSections(ctx context.Context, cfgPath string, deps routesListDeps) ([]routeListSection, error) {
282+
283+ type localResult struct {
284+ section routeListSection
285+ }
286+ type remotesResult struct {
287+ entries map[string]remote.RemoteEntry
288+ err error
226 }289 }
227 290
228- if jsonOut {291+ localCh := make(chan localResult, 1)
229- payload := map[string]any{292+ remotesCh := make(chan remotesResult, 1)
230- "domain": route.Domain,293+
231- "image": route.Image,294+ go func() {
232- "container_status": containerStatus,295+ section, _ := deps.loadLocal(ctx, cfgPath)
233- "http_status": httpStatus,296+ localCh <- localResult{section: section}
234- }297+ }()
235- return writeJSON(out, payload)298+ go func() {
299+ entries, _, err := deps.listRemotes()
300+ remotesCh <- remotesResult{entries: entries, err: err}
301+ }()
302+
303+ local := (<-localCh).section
304+ remotes := <-remotesCh
305+
306+ sections := make([]routeListSection, 0, 1)
307+ sections = append(sections, normalizeRouteListLocalSection(local))
308+
309+ if remotes.err != nil {
310+ sections = append(sections, routeListSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
311+ return sections, nil
236 }312 }
237 313
238- if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {314+ names := sortedRemoteNames(remotes.entries)
315+ remoteSections := make([]routeListSection, len(names))
316+ var wg sync.WaitGroup
317+ for i, name := range names {
318+ i, name := i, name
319+ entry := remotes.entries[name]
320+ wg.Add(1)
321+ go func() {
322+ defer wg.Done()
323+ section, _ := deps.loadRemote(ctx, name, entry)
324+ remoteSections[i] = normalizeRouteListRemoteSection(section, name, entry)
325+ }()
326+ }
327+ wg.Wait()
328+
329+ sections = append(sections, remoteSections...)
330+ return sections, nil
331+}
332+
333+func normalizeRouteListLocalSection(section routeListSection) routeListSection {
334+ if section.Kind == "" {
335+ section.Kind = "local"
336+ }
337+ if section.Name == "" {
338+ section.Name = "local"
339+ }
340+ return section
341+}
342+
343+func normalizeRouteListRemoteSection(section routeListSection, name string, entry remote.RemoteEntry) routeListSection {
344+ if section.Kind == "" {
345+ section.Kind = "remote"
346+ }
347+ if section.Name == "" {
348+ section.Name = name
349+ }
350+ if section.URL == "" {
351+ section.URL = entry.URL
352+ }
353+ return section
354+}
355+
356+func collectRoutesStatusSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
357+ if deps.explicitRemote == nil {
358+ deps.explicitRemote = resolveRoutesExplicitRemote
359+ }
360+ if deps.loadLocal == nil {
361+ deps.loadLocal = loadRoutesStatusLocalSection
362+ }
363+ if deps.listRemotes == nil {
364+ deps.listRemotes = remote.ListRemotes
365+ }
366+ if deps.loadRemote == nil {
367+ deps.loadRemote = loadRoutesStatusRemoteSection
368+ }
369+
370+ if resolved, ok := deps.explicitRemote(); ok {
371+ section := loadRoutesStatusExplicitRemoteSection(ctx, deps, resolved)
372+ return []routeStatusSection{section}, nil
373+ }
374+
375+ return collectRoutesStatusAggregateSections(ctx, cfgPath, deps)
376+}
377+
378+func loadRoutesStatusExplicitRemoteSection(ctx context.Context, deps routesStatusDeps, resolved *remote.ResolvedRemote) routeStatusSection {
379+ section, _ := deps.loadRemote(ctx, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
380+ return normalizeRouteStatusRemoteSection(section, resolved.DisplayName(), remote.RemoteEntry{URL: resolved.URL, Token: resolved.Token, InsecureTLS: resolved.InsecureTLS})
381+}
382+
383+func collectRoutesStatusAggregateSections(ctx context.Context, cfgPath string, deps routesStatusDeps) ([]routeStatusSection, error) {
384+
385+ type localResult struct {
386+ section routeStatusSection
387+ }
388+ type remotesResult struct {
389+ entries map[string]remote.RemoteEntry
390+ err error
391+ }
392+
393+ localCh := make(chan localResult, 1)
394+ remotesCh := make(chan remotesResult, 1)
395+
396+ go func() {
397+ section, _ := deps.loadLocal(ctx, cfgPath)
398+ localCh <- localResult{section: section}
399+ }()
400+ go func() {
401+ entries, _, err := deps.listRemotes()
402+ remotesCh <- remotesResult{entries: entries, err: err}
403+ }()
404+
405+ local := (<-localCh).section
406+ remotes := <-remotesCh
407+
408+ sections := make([]routeStatusSection, 0, 1)
409+ sections = append(sections, normalizeRouteStatusLocalSection(local))
410+
411+ if remotes.err != nil {
412+ sections = append(sections, routeStatusSection{Kind: "remote", Name: "remotes", Error: remotes.err.Error()})
413+ return sections, nil
414+ }
415+
416+ names := sortedRemoteNames(remotes.entries)
417+ remoteSections := make([]routeStatusSection, len(names))
418+ var wg sync.WaitGroup
419+ for i, name := range names {
420+ i, name := i, name
421+ entry := remotes.entries[name]
422+ wg.Add(1)
423+ go func() {
424+ defer wg.Done()
425+ section, _ := deps.loadRemote(ctx, name, entry)
426+ remoteSections[i] = normalizeRouteStatusRemoteSection(section, name, entry)
427+ }()
428+ }
429+ wg.Wait()
430+
431+ sections = append(sections, remoteSections...)
432+ return sections, nil
433+}
434+
435+func normalizeRouteStatusLocalSection(section routeStatusSection) routeStatusSection {
436+ if section.Kind == "" {
437+ section.Kind = "local"
438+ }
439+ if section.Name == "" {
440+ section.Name = "local"
441+ }
442+ return section
443+}
444+
445+func normalizeRouteStatusRemoteSection(section routeStatusSection, name string, entry remote.RemoteEntry) routeStatusSection {
446+ if section.Kind == "" {
447+ section.Kind = "remote"
448+ }
449+ if section.Name == "" {
450+ section.Name = name
451+ }
452+ if section.URL == "" {
453+ section.URL = entry.URL
454+ }
455+ return section
456+}
457+
458+func renderRoutesListSections(out io.Writer, sections []routeListSection) error {
459+ if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {
239 return err460 return err
240 }461 }
241 if err := cliWriteLine(out, ""); err != nil {462 if err := cliWriteLine(out, ""); err != nil {
242 return err463 return err
243 }464 }
244- if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {465+
466+ for i, section := range sections {
467+ if i > 0 {
468+ if err := cliWriteLine(out, ""); err != nil {
469+ return err
470+ }
471+ }
472+ if err := renderRoutesListSection(out, section); err != nil {
473+ return err
474+ }
475+ }
476+
477+ return nil
478+}
479+
480+func renderRoutesListSection(out io.Writer, section routeListSection) error {
481+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
245 return err482 return err
246 }483 }
247- if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {484+
485+ if len(section.Routes) > 0 {
486+ const imageColWidth = 45
487+ rows := make([][]string, 0, len(section.Routes))
488+ for _, route := range section.Routes {
489+ rows = append(rows, []string{route.Domain, truncateImage(route.Image, imageColWidth)})
490+ }
491+
492+ table := components.NewTable(
493+ components.WithColumns([]components.TableColumn{
494+ {Title: "Domain", Width: 30},
495+ {Title: "Image", Width: imageColWidth},
496+ }),
497+ components.WithRows(rows),
498+ )
499+
500+ if err := cliWriteLine(out, table.View()); err != nil {
501+ return err
502+ }
503+ }
504+
505+ if section.Error != "" {
506+ return cliWriteLine(out, cliRenderWarning(section.Error))
507+ }
508+
509+ if len(section.Routes) == 0 {
510+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
511+ }
512+
513+ return nil
514+}
515+
516+func renderRoutesStatusSections(out io.Writer, sections []routeStatusSection) error {
517+ if err := cliWriteLine(out, cliRenderTitle("Route Status")); err != nil {
248 return err518 return err
249 }519 }
250- if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {520+ if err := cliWriteLine(out, ""); err != nil {
251 return err521 return err
252 }522 }
253- if httpStatus > 0 {523+
254- if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {524+ for i, section := range sections {
525+ if i > 0 {
526+ if err := cliWriteLine(out, ""); err != nil {
527+ return err
528+ }
529+ }
530+ if err := renderRoutesStatusSection(out, section); err != nil {
531+ return err
532+ }
533+ }
534+
535+ return nil
536+}
537+
538+func renderRoutesStatusSection(out io.Writer, section routeStatusSection) error {
539+ if err := cliWriteLine(out, routeSectionHeading(section.Kind, section.Name)); err != nil {
540+ return err
541+ }
542+
543+ if len(section.Routes) > 0 {
544+ tree := buildRouteStatusTree(section.Routes)
545+ if err := cliWriteLine(out, tree.Render()); err != nil {
255 return err546 return err
256 }547 }
257 }548 }
258 549
550+ if section.Error != "" {
551+ return cliWriteLine(out, cliRenderWarning(section.Error))
552+ }
553+
554+ if len(section.Routes) == 0 {
555+ return cliWriteLine(out, cliRenderMuted("No routes configured"))
556+ }
557+
259 return nil558 return nil
260 }559 }
261 560
262-// runRoutesListRemote lists routes from a remote Gordon instance.561+func routeSectionHeading(kind, name string) string {
263-func runRoutesListRemote(ctx context.Context, client *remote.Client, jsonOut bool, out io.Writer) error {562+ switch kind {
563+ case "local":
564+ return "Local"
565+ case "remote":
566+ if name != "" {
567+ return "Remote: " + name
568+ }
569+ return "Remote"
570+ default:
571+ if name != "" {
572+ return capitalizeKind(kind) + ": " + name
573+ }
574+ return capitalizeKind(kind)
575+ }
576+}
577+
578+func capitalizeKind(kind string) string {
579+ if kind == "" {
580+ return ""
581+ }
582+ if len(kind) == 1 {
583+ return strings.ToUpper(kind)
584+ }
585+ return strings.ToUpper(kind[:1]) + kind[1:]
586+}
587+
588+func resolveRoutesExplicitRemote() (*remote.ResolvedRemote, bool) {
589+ target, ok := resolveRoutesExplicitTarget()
590+ if !ok {
591+ return nil, false
592+ }
593+
594+ if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
595+ return &remote.ResolvedRemote{
596+ URL: target,
597+ Token: resolveRoutesTokenForTarget("", remote.RemoteEntry{}),
598+ InsecureTLS: resolveRoutesInsecureForTarget("", remote.RemoteEntry{}),
599+ }, true
600+ }
601+
602+ remotes, err := remote.LoadRemotes("")
603+ if err != nil {
604+ return nil, false
605+ }
606+
607+ if remotes != nil {
608+ if entry, found := remotes.Remotes[target]; found {
609+ return &remote.ResolvedRemote{
610+ Name: target,
611+ URL: entry.URL,
612+ Token: resolveRoutesTokenForTarget(target, entry),
613+ InsecureTLS: resolveRoutesInsecureForTarget(target, entry),
614+ }, true
615+ }
616+ }
617+
618+ return nil, false
619+}
620+
621+func resolveRoutesExplicitTarget() (string, bool) {
622+ if target := strings.TrimSpace(remoteFlag); target != "" {
623+ return target, true
624+ }
625+ if target := strings.TrimSpace(os.Getenv("GORDON_REMOTE")); target != "" {
626+ return target, true
627+ }
628+ return "", false
629+}
630+
631+func resolveRoutesTokenForTarget(name string, entry remote.RemoteEntry) string {
632+ if token := strings.TrimSpace(tokenFlag); token != "" {
633+ return token
634+ }
635+ if token := strings.TrimSpace(os.Getenv("GORDON_TOKEN")); token != "" {
636+ return token
637+ }
638+ if name != "" {
639+ return remote.ResolveTokenForRemote(name, entry)
640+ }
641+ return ""
642+}
643+
644+func resolveRoutesInsecureForTarget(name string, entry remote.RemoteEntry) bool {
645+ if insecureTLSFlag {
646+ return true
647+ }
648+ if env := strings.TrimSpace(os.Getenv("GORDON_INSECURE")); env != "" {
649+ if value, err := strconv.ParseBool(env); err == nil {
650+ return value
651+ }
652+ }
653+ if name != "" {
654+ return entry.InsecureTLS
655+ }
656+ return false
657+}
658+
659+func newRoutesTargetClient(name string, entry remote.RemoteEntry) *remote.Client {
660+ return remote.NewClient(entry.URL, remoteClientOptions(resolveRoutesTokenForTarget(name, entry), resolveRoutesInsecureForTarget(name, entry))...)
661+}
662+
663+func loadRoutesListLocalSection(ctx context.Context, cfgPath string) (routeListSection, error) {
664+ local, err := GetLocalServices(cfgPath)
665+ if err != nil {
666+ return routeListSection{Kind: "local", Name: "local", Error: err.Error()}, nil
667+ }
668+
669+ routes := local.GetConfigService().GetRoutes(ctx)
670+ section := routeListSection{Kind: "local", Name: "local", Routes: make([]routeListItem, 0, len(routes))}
671+ for _, route := range routes {
672+ section.Routes = append(section.Routes, routeListItem{Domain: route.Domain, Image: route.Image})
673+ }
674+
675+ return section, nil
676+}
677+
678+func loadRoutesListRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
679+ section := routeListSection{Kind: "remote", Name: name, URL: entry.URL}
680+ client := newRoutesTargetClient(name, entry)
264 routes, err := client.ListRoutesWithDetails(ctx)681 routes, err := client.ListRoutesWithDetails(ctx)
265 if err != nil {682 if err != nil {
266- return fmt.Errorf("failed to list routes: %w", err)683+ section.Error = err.Error()
684+ return section, nil
267 }685 }
268 686
269- health, _ := client.GetHealth(ctx)687+ section.Routes = routeListItemsFromInfos(routes)
270- if health == nil {688+ return section, nil
271- health = make(map[string]*remote.RouteHealth)689+}
690+
691+func loadRoutesStatusLocalSection(ctx context.Context, cfgPath string) (routeStatusSection, error) {
692+ section := routeStatusSection{Kind: "local", Name: "local"}
693+ handle, err := resolveLocalControlPlane(cfgPath)
694+ if err != nil {
695+ section.Error = err.Error()
696+ return section, nil
272 }697 }
698+ defer handle.close()
273 699
274- if jsonOut {700+ routes, err := handle.plane.ListRoutesWithDetails(ctx)
275- return routesListJSON(out, routes, health)701+ if err != nil {
702+ section.Error = err.Error()
703+ return section, nil
276 }704 }
277 705
278- if len(routes) == 0 {706+ health, err := handle.plane.GetHealth(ctx)
279- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))707+ if err != nil {
280- return nil708+ section.Error = err.Error()
709+ health = nil
281 }710 }
282 711
283- groups, solo := groupRoutesByNetwork(routes)712+ section.Routes = routeStatusItemsFromInfos(routes, health)
713+ return section, nil
714+}
715+
716+func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remote.RemoteEntry) (routeStatusSection, error) {
717+ section := routeStatusSection{Kind: "remote", Name: name, URL: entry.URL}
718+ client := newRoutesTargetClient(name, entry)
719+ cp := NewRemoteControlPlane(client)
284 720
285- type sortableItem struct {721+ routes, err := cp.ListRoutesWithDetails(ctx)
286- sortKey string722+ if err != nil {
287- group *networkGroup723+ section.Error = err.Error()
288- route *remote.RouteInfo724+ return section, nil
289 }725 }
290 726
291- var items []sortableItem727+ health, err := cp.GetHealth(ctx)
292- for i := range groups {728+ if err != nil {
293- items = append(items, sortableItem{729+ section.Error = err.Error()
294- sortKey: groups[i].routes[0].Domain,730+ health = nil
295- group: &groups[i],
296- })
297 }731 }
298- for i := range solo {732+
299- items = append(items, sortableItem{733+ section.Routes = routeStatusItemsFromInfos(routes, health)
300- sortKey: solo[i].Domain,734+ return section, nil
301- route: &solo[i],735+}
302- })736+
737+func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
738+ items := make([]routeStatusItem, 0, len(routes))
739+ for _, route := range routes {
740+ item := routeStatusItem{
741+ Domain: route.Domain,
742+ Image: route.Image,
743+ ContainerID: route.ContainerID,
744+ ContainerStatus: route.ContainerStatus,
745+ Network: route.Network,
746+ }
747+ if item.ContainerStatus == "" {
748+ if routeHealth := health[route.Domain]; routeHealth != nil {
749+ item.ContainerStatus = routeHealth.ContainerStatus
750+ item.HTTPStatus = routeHealth.HTTPStatus
751+ item.HealthError = routeHealth.Error
752+ }
753+ }
754+ if item.ContainerStatus == "" {
755+ item.ContainerStatus = "unknown"
756+ }
757+ if item.HTTPStatus == 0 {
758+ if routeHealth := health[route.Domain]; routeHealth != nil {
759+ item.HTTPStatus = routeHealth.HTTPStatus
760+ item.HealthError = routeHealth.Error
761+ }
762+ }
763+ if item.HealthError == "" {
764+ if routeHealth := health[route.Domain]; routeHealth != nil {
765+ item.HealthError = routeHealth.Error
766+ }
767+ }
768+ item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
769+ for _, attachment := range route.Attachments {
770+ status := attachment.Status
771+ if status == "" {
772+ status = "unknown"
773+ }
774+ item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
775+ }
776+ items = append(items, item)
303 }777 }
304- sort.Slice(items, func(i, j int) bool {778+ return items
305- return items[i].sortKey < items[j].sortKey779+}
780+
781+func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
782+ items := make([]routeListItem, 0, len(routes))
783+ for _, route := range routes {
784+ items = append(items, routeListItem{Domain: route.Domain, Image: route.Image})
785+ }
786+ return items
787+}
788+
789+func buildRouteStatusTree(routes []routeStatusItem) *components.Tree {
790+ sortedRoutes := make([]routeStatusItem, len(routes))
791+ copy(sortedRoutes, routes)
792+ sort.SliceStable(sortedRoutes, func(i, j int) bool {
793+ if sortedRoutes[i].Network != sortedRoutes[j].Network {
794+ return sortedRoutes[i].Network < sortedRoutes[j].Network
795+ }
796+ if sortedRoutes[i].Domain != sortedRoutes[j].Domain {
797+ return sortedRoutes[i].Domain < sortedRoutes[j].Domain
798+ }
799+ if sortedRoutes[i].Image != sortedRoutes[j].Image {
800+ return sortedRoutes[i].Image < sortedRoutes[j].Image
801+ }
802+ if sortedRoutes[i].ContainerID != sortedRoutes[j].ContainerID {
803+ return sortedRoutes[i].ContainerID < sortedRoutes[j].ContainerID
804+ }
805+ if sortedRoutes[i].ContainerStatus != sortedRoutes[j].ContainerStatus {
806+ return sortedRoutes[i].ContainerStatus < sortedRoutes[j].ContainerStatus
807+ }
808+ return sortedRoutes[i].HTTPStatus < sortedRoutes[j].HTTPStatus
306 })809 })
307 810
811+ infos := make([]remote.RouteInfo, 0, len(routes))
812+ itemsByDomain := make(map[string]routeStatusItem, len(routes))
813+ for _, route := range sortedRoutes {
814+ infos = append(infos, remote.RouteInfo{
815+ Domain: route.Domain,
816+ Image: route.Image,
817+ ContainerID: route.ContainerID,
818+ ContainerStatus: route.ContainerStatus,
819+ Network: route.Network,
820+ Attachments: routeStatusAttachmentsToRemote(route.Attachments),
821+ })
822+ itemsByDomain[route.Domain] = route
823+ }
824+
825+ groups, solo := groupRoutesByNetwork(infos)
308 tree := components.NewTree()826 tree := components.NewTree()
309 827
310- for _, item := range items {828+ for _, group := range groups {
311- if item.group != nil {829+ g := tree.AddGroup(group.name)
312- g := tree.AddGroup(item.group.name)830+ for _, route := range group.routes {
313- for _, route := range item.group.routes {831+ item := itemsByDomain[route.Domain]
314- title := routeTitle(route, health)832+ node := g.AddNode(routeStatusTitle(item), item.Image)
315- node := g.AddNode(title, route.Image)833+ addRouteStatusAttachmentChildren(node, item)
316- addAttachmentChildren(node, route)
317- }
318- } else {
319- title := routeTitle(*item.route, health)
320- node := tree.AddNode(title, item.route.Image)
321- addAttachmentChildren(node, *item.route)
322 }834 }
323 }835 }
324 836
325- if err := cliWriteLine(out, cliRenderTitle("Routes")); err != nil {837+ for _, route := range solo {
326- return err838+ item := itemsByDomain[route.Domain]
327- }839+ node := tree.AddNode(routeStatusTitle(item), item.Image)
328- if err := cliWriteLine(out, ""); err != nil {840+ addRouteStatusAttachmentChildren(node, item)
329- return err
330 }841 }
331- return cliWriteLine(out, tree.Render())842+
843+ return tree
332 }844 }
333 845
334-// routeTitle builds the pre-styled title line for a route node.846+func routeStatusTitle(route routeStatusItem) string {
335-func routeTitle(route remote.RouteInfo, health map[string]*remote.RouteHealth) string {
336 containerStatus := route.ContainerStatus847 containerStatus := route.ContainerStatus
337 if containerStatus == "" {848 if containerStatus == "" {
338- if h := health[route.Domain]; h != nil {849+ containerStatus = "unknown"
339- containerStatus = h.ContainerStatus
340- } else {
341- containerStatus = "unknown"
342- }
343 }850 }
344 851
345- httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(health[route.Domain]))852+ httpIcon := components.StatusIcon(styles.IconHTTPStatus, httpHealthToStatus(&remote.RouteHealth{HTTPStatus: route.HTTPStatus, Error: route.HealthError}))
346 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))853 containerIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(containerStatus))
347 854
348 return httpIcon + " " + containerIcon + " " + route.Domain855 return httpIcon + " " + containerIcon + " " + route.Domain
349 }856 }
350 857
351-// addAttachmentChildren adds attachment nodes as children of a route node.858+func addRouteStatusAttachmentChildren(node *components.Node, route routeStatusItem) {
352-func addAttachmentChildren(node *components.Node, route remote.RouteInfo) {
353 for _, att := range route.Attachments {859 for _, att := range route.Attachments {
354- attStatus := att.Status860+ status := att.Status
355- if attStatus == "" {861+ if status == "" {
356- attStatus = "unknown"862+ status = "unknown"
357 }863 }
358- attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(attStatus))864+ attIcon := components.StatusIcon(styles.IconContainerStatus, components.ParseStatus(status))
359 node.AddChild(attIcon+" "+att.Name, att.Image)865 node.AddChild(attIcon+" "+att.Name, att.Image)
360 }866 }
361 }867 }
362 868
363-func routesListJSON(out io.Writer, routes []remote.RouteInfo, health map[string]*remote.RouteHealth) error {869+func routeStatusAttachmentsToRemote(attachments []routeStatusAttachment) []remote.Attachment {
364- payload := make([]map[string]any, 0, len(routes))870+ result := make([]remote.Attachment, 0, len(attachments))
365- for _, route := range routes {871+ for _, att := range attachments {
366- routeHealth := health[route.Domain]872+ result = append(result, remote.Attachment{Name: att.Name, Image: att.Image, Status: att.Status})
367- containerStatus := route.ContainerStatus873+ }
368- if containerStatus == "" {874+ return result
369- if routeHealth != nil {875+}
370- containerStatus = routeHealth.ContainerStatus
371- } else {
372- containerStatus = "unknown"
373- }
374- }
375 876
376- httpStatus := 0877+func newRoutesShowCmd() *cobra.Command {
377- if routeHealth != nil {878+ var jsonOut bool
378- httpStatus = routeHealth.HTTPStatus
379- }
380 879
381- attachments := make([]map[string]string, 0, len(route.Attachments))880+ cmd := &cobra.Command{
382- for _, attachment := range route.Attachments {881+ Use: "show <domain>",
383- attachments = append(attachments, map[string]string{882+ Short: "Show details for a single route",
384- "name": attachment.Name,883+ Long: `Display detailed information about a specific route including its image,
385- "image": attachment.Image,884+container status, and health.
386- "status": attachment.Status,
387- })
388- }
389 885
390- payload = append(payload, map[string]any{886+Examples:
391- "domain": route.Domain,887+ gordon routes show app.mydomain.com
392- "image": route.Image,888+ gordon routes show app.mydomain.com --json
393- "container_status": containerStatus,889+ gordon routes show app.mydomain.com --remote https://gordon.mydomain.com`,
394- "network": route.Network,890+ Args: cobra.ExactArgs(1),
395- "http_status": httpStatus,891+ RunE: func(cmd *cobra.Command, args []string) error {
396- "attachments": attachments,892+ ctx := cmd.Context()
397- })893+ handle, err := resolveControlPlane(configPath)
894+ if err != nil {
895+ return err
896+ }
897+ defer handle.close()
898+ return runRoutesShow(ctx, handle.plane, cmd.OutOrStdout(), args[0], jsonOut)
899+ },
398 }900 }
399 901
400- return writeJSON(out, payload)902+ cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON")
903+
904+ return cmd
401 }905 }
402 906
403-// runRoutesListLocal lists routes from local configuration.907+func runRoutesShow(ctx context.Context, cp ControlPlane, out io.Writer, routeDomain string, jsonOut bool) error {
404-func runRoutesListLocal(ctx context.Context, cfgPath string, jsonOut bool, out io.Writer) error {908+ route, err := cp.GetRoute(ctx, routeDomain)
405- local, err := GetLocalServices(cfgPath)
406 if err != nil {909 if err != nil {
407- return fmt.Errorf("failed to initialize local services: %w", err)910+ if errors.Is(err, domain.ErrRouteNotFound) {
911+ return fmt.Errorf("route %q not found", routeDomain)
912+ }
913+ return fmt.Errorf("failed to get route: %w", err)
408 }914 }
409 915
410- routes := local.GetConfigService().GetRoutes(ctx)916+ health, _ := cp.GetHealth(ctx)
917+ var routeHealth *remote.RouteHealth
918+ if health != nil {
919+ routeHealth = health[routeDomain]
920+ }
921+
922+ containerStatus := "unknown"
923+ httpStatus := 0
924+ if routeHealth != nil {
925+ containerStatus = routeHealth.ContainerStatus
926+ httpStatus = routeHealth.HTTPStatus
927+ }
411 928
412 if jsonOut {929 if jsonOut {
413- payload := make([]map[string]string, 0, len(routes))930+ payload := map[string]any{
414- for _, route := range routes {931+ "domain": route.Domain,
415- payload = append(payload, map[string]string{932+ "image": route.Image,
416- "domain": route.Domain,933+ "container_status": containerStatus,
417- "image": route.Image,934+ "http_status": httpStatus,
418- })
419 }935 }
420 return writeJSON(out, payload)936 return writeJSON(out, payload)
421 }937 }
422 938
423- if len(routes) == 0 {939+ if err := cliWriteLine(out, cliRenderTitle("Route: "+route.Domain)); err != nil {
424- _, _ = fmt.Fprintln(out, styles.Theme.Muted.Render("No routes configured"))940+ return err
425- return nil
426 }941 }
427-942+ if err := cliWriteLine(out, ""); err != nil {
428- // Build table rows (no health info in local mode)943+ return err
429- const imageColWidth = 45944+ }
430- rows := make([][]string, len(routes))945+ if err := cliWriteLine(out, cliRenderMeta("Domain:", route.Domain)); err != nil {
431- for i, route := range routes {946+ return err
432- displayImage := truncateImage(route.Image, imageColWidth)947+ }
433- rows[i] = []string{route.Domain, displayImage}948+ if err := cliWriteLine(out, cliRenderMeta("Image:", route.Image)); err != nil {
949+ return err
950+ }
951+ if err := cliWriteLine(out, cliRenderMeta("Container:", containerStatus)); err != nil {
952+ return err
953+ }
954+ if httpStatus > 0 {
955+ if err := cliWriteLine(out, cliRenderMeta("HTTP Status:", fmt.Sprintf("%d", httpStatus))); err != nil {
956+ return err
957+ }
434 }958 }
435-
436- // Render table
437- table := components.NewTable(
438- components.WithColumns([]components.TableColumn{
439- {Title: "Domain", Width: 30},
440- {Title: "Image", Width: imageColWidth},
441- }),
442- components.WithRows(rows),
443- )
444-
445- _, _ = fmt.Fprintln(out, styles.Theme.Title.Render("Routes (local)"))
446- _, _ = fmt.Fprintln(out)
447- _, _ = fmt.Fprintln(out, table.View())
448 959
449 return nil960 return nil
450 }961 }
M internal/adapters/in/cli/routes_test.go +215 -0
@@ -1,11 +1,18 @@
11 package cli
22
33 import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
49 "testing"
510
611 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
712 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
13+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
814 "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
916 )
1017
1118 func TestTruncateImage(t *testing.T) {
@@ -197,3 +204,211 @@ func TestStripNetworkPrefix(t *testing.T) {
197204 })
198205 }
199206 }
207+
208+func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
209+ originalRemoteFlag := remoteFlag
210+ originalTokenFlag := tokenFlag
211+ originalInsecureTLSFlag := insecureTLSFlag
212+ t.Cleanup(func() {
213+ remoteFlag = originalRemoteFlag
214+ tokenFlag = originalTokenFlag
215+ insecureTLSFlag = originalInsecureTLSFlag
216+ })
217+
218+ remoteFlag = ""
219+ tokenFlag = ""
220+ insecureTLSFlag = false
221+
222+ configHome := t.TempDir()
223+ t.Setenv("XDG_CONFIG_HOME", configHome)
224+ t.Setenv("HOME", t.TempDir())
225+ t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
226+
227+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
228+ require.NoError(t, os.MkdirAll(configPath, 0o755))
229+
230+ resolved, ok := resolveRoutesExplicitRemote()
231+ require.True(t, ok)
232+ require.NotNil(t, resolved)
233+ assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
234+ assert.Empty(t, resolved.Name)
235+}
236+
237+func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
238+ item := routeStatusItem{
239+ Domain: "app.example.com",
240+ Image: "app:latest",
241+ ContainerStatus: "running",
242+ HTTPStatus: 0,
243+ HealthError: "connection refused",
244+ }
245+
246+ expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
247+ components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
248+ " app.example.com"
249+
250+ assert.Equal(t, expected, routeStatusTitle(item))
251+}
252+
253+func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
254+ testsDeps := routesListDeps{
255+ explicitRemote: func() (*remote.ResolvedRemote, bool) {
256+ return nil, false
257+ },
258+ loadLocal: func(context.Context, string) (routeListSection, error) {
259+ return routeListSection{
260+ Kind: "local",
261+ Name: "local",
262+ Routes: []routeListItem{{
263+ Domain: "app.local",
264+ Image: "myapp:latest",
265+ }},
266+ }, nil
267+ },
268+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
269+ return map[string]remote.RemoteEntry{
270+ "igor": {URL: "https://gordon.supri.xyz"},
271+ "hetzner-vps": {URL: "https://reg.bnema.dev"},
272+ }, "igor", nil
273+ },
274+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
275+ if name == "hetzner-vps" {
276+ return routeListSection{
277+ Kind: "remote",
278+ Name: name,
279+ URL: entry.URL,
280+ Error: "remote unavailable",
281+ }, nil
282+ }
283+ return routeListSection{
284+ Kind: "remote",
285+ Name: name,
286+ URL: entry.URL,
287+ Routes: []routeListItem{{
288+ Domain: "grafana.supri.xyz",
289+ Image: "grafana",
290+ }},
291+ }, nil
292+ },
293+ }
294+
295+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
296+
297+ require.NoError(t, err)
298+ require.Len(t, sections, 3)
299+ assert.Equal(t, "local", sections[0].Name)
300+ assert.Equal(t, "hetzner-vps", sections[1].Name)
301+ assert.Equal(t, "igor", sections[2].Name)
302+ assert.Equal(t, "remote unavailable", sections[1].Error)
303+ assert.Equal(t, "grafana.supri.xyz", sections[2].Routes[0].Domain)
304+}
305+
306+func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
307+ testsDeps := routesListDeps{
308+ explicitRemote: func() (*remote.ResolvedRemote, bool) {
309+ return &remote.ResolvedRemote{
310+ Name: "igor",
311+ URL: "https://gordon.supri.xyz",
312+ }, true
313+ },
314+ loadLocal: func(context.Context, string) (routeListSection, error) {
315+ t.Fatal("loadLocal should not be called when an explicit remote is selected")
316+ return routeListSection{}, nil
317+ },
318+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
319+ t.Fatal("listRemotes should not be called when an explicit remote is selected")
320+ return nil, "", nil
321+ },
322+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
323+ return routeListSection{
324+ Kind: "remote",
325+ Name: name,
326+ URL: entry.URL,
327+ Routes: []routeListItem{{
328+ Domain: "test.supri.xyz",
329+ Image: "hello-test",
330+ }},
331+ }, nil
332+ },
333+ }
334+
335+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
336+
337+ require.NoError(t, err)
338+ require.Len(t, sections, 1)
339+ assert.Equal(t, "igor", sections[0].Name)
340+ assert.Equal(t, "test.supri.xyz", sections[0].Routes[0].Domain)
341+}
342+
343+func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
344+ sections := []routeStatusSection{
345+ {
346+ Kind: "local",
347+ Name: "local",
348+ Routes: []routeStatusItem{{
349+ Domain: "app.local",
350+ Image: "myapp:latest",
351+ ContainerStatus: "running",
352+ }},
353+ },
354+ {
355+ Kind: "remote",
356+ Name: "igor",
357+ URL: "https://gordon.supri.xyz",
358+ Error: "dial tcp timeout",
359+ },
360+ }
361+
362+ var out bytes.Buffer
363+ err := renderRoutesStatusSections(&out, sections)
364+
365+ require.NoError(t, err)
366+ rendered := stripANSI(out.String())
367+ lines := strings.Split(rendered, "\n")
368+
369+ lineIndex := func(substr string) int {
370+ for i, line := range lines {
371+ if strings.Contains(line, substr) {
372+ return i
373+ }
374+ }
375+ return -1
376+ }
377+
378+ titleIdx := lineIndex("Route Status")
379+ require.NotEqual(t, -1, titleIdx)
380+
381+ localIdx := lineIndex("Local")
382+ require.NotEqual(t, -1, localIdx)
383+
384+ routeIdx := lineIndex("app.local")
385+ require.NotEqual(t, -1, routeIdx)
386+
387+ remoteIdx := lineIndex("Remote: igor")
388+ require.NotEqual(t, -1, remoteIdx)
389+
390+ errorIdx := lineIndex("dial tcp timeout")
391+ require.NotEqual(t, -1, errorIdx)
392+
393+ assert.Less(t, titleIdx, localIdx)
394+ assert.Less(t, localIdx, routeIdx)
395+ assert.Less(t, routeIdx, remoteIdx)
396+ assert.Less(t, remoteIdx, errorIdx)
397+}
398+
399+func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
400+ routes := []routeStatusItem{
401+ {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
402+ {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
403+ {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
404+ }
405+
406+ var outA bytes.Buffer
407+ _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
408+
409+ var outB bytes.Buffer
410+ reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
411+ _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
412+
413+ assert.Equal(t, outA.String(), outB.String())
414+}
@@ -1,11 +1,18 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
6+ "os"
7+ "path/filepath"
8+ "strings"
4 "testing"9 "testing"
5 10
6 "github.com/bnema/gordon/internal/adapters/in/cli/remote"11 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
7 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"12 "github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
13+ "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
8 "github.com/stretchr/testify/assert"14 "github.com/stretchr/testify/assert"
15+ "github.com/stretchr/testify/require"
9 )16 )
10 17
11 func TestTruncateImage(t *testing.T) {18 func TestTruncateImage(t *testing.T) {
@@ -197,3 +204,211 @@ func TestStripNetworkPrefix(t *testing.T) {
197 })204 })
198 }205 }
199 }206 }
207+
208+func TestResolveRoutesExplicitRemote_AllowsAdHocURLWhenSavedRemotesConfigUnreadable(t *testing.T) {
209+ originalRemoteFlag := remoteFlag
210+ originalTokenFlag := tokenFlag
211+ originalInsecureTLSFlag := insecureTLSFlag
212+ t.Cleanup(func() {
213+ remoteFlag = originalRemoteFlag
214+ tokenFlag = originalTokenFlag
215+ insecureTLSFlag = originalInsecureTLSFlag
216+ })
217+
218+ remoteFlag = ""
219+ tokenFlag = ""
220+ insecureTLSFlag = false
221+
222+ configHome := t.TempDir()
223+ t.Setenv("XDG_CONFIG_HOME", configHome)
224+ t.Setenv("HOME", t.TempDir())
225+ t.Setenv("GORDON_REMOTE", "https://ad-hoc.example.com")
226+
227+ configPath := filepath.Join(configHome, "gordon", "remotes.toml")
228+ require.NoError(t, os.MkdirAll(configPath, 0o755))
229+
230+ resolved, ok := resolveRoutesExplicitRemote()
231+ require.True(t, ok)
232+ require.NotNil(t, resolved)
233+ assert.Equal(t, "https://ad-hoc.example.com", resolved.URL)
234+ assert.Empty(t, resolved.Name)
235+}
236+
237+func TestRouteStatusTitle_PreservesProbeFailureError(t *testing.T) {
238+ item := routeStatusItem{
239+ Domain: "app.example.com",
240+ Image: "app:latest",
241+ ContainerStatus: "running",
242+ HTTPStatus: 0,
243+ HealthError: "connection refused",
244+ }
245+
246+ expected := components.StatusIcon(styles.IconHTTPStatus, components.StatusError) + " " +
247+ components.StatusIcon(styles.IconContainerStatus, components.ParseStatus("running")) +
248+ " app.example.com"
249+
250+ assert.Equal(t, expected, routeStatusTitle(item))
251+}
252+
253+func TestCollectRoutesListSections_DefaultModeIncludesLocalThenSortedRemotes(t *testing.T) {
254+ testsDeps := routesListDeps{
255+ explicitRemote: func() (*remote.ResolvedRemote, bool) {
256+ return nil, false
257+ },
258+ loadLocal: func(context.Context, string) (routeListSection, error) {
259+ return routeListSection{
260+ Kind: "local",
261+ Name: "local",
262+ Routes: []routeListItem{{
263+ Domain: "app.local",
264+ Image: "myapp:latest",
265+ }},
266+ }, nil
267+ },
268+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
269+ return map[string]remote.RemoteEntry{
270+ "igor": {URL: "https://gordon.supri.xyz"},
271+ "hetzner-vps": {URL: "https://reg.bnema.dev"},
272+ }, "igor", nil
273+ },
274+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
275+ if name == "hetzner-vps" {
276+ return routeListSection{
277+ Kind: "remote",
278+ Name: name,
279+ URL: entry.URL,
280+ Error: "remote unavailable",
281+ }, nil
282+ }
283+ return routeListSection{
284+ Kind: "remote",
285+ Name: name,
286+ URL: entry.URL,
287+ Routes: []routeListItem{{
288+ Domain: "grafana.supri.xyz",
289+ Image: "grafana",
290+ }},
291+ }, nil
292+ },
293+ }
294+
295+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
296+
297+ require.NoError(t, err)
298+ require.Len(t, sections, 3)
299+ assert.Equal(t, "local", sections[0].Name)
300+ assert.Equal(t, "hetzner-vps", sections[1].Name)
301+ assert.Equal(t, "igor", sections[2].Name)
302+ assert.Equal(t, "remote unavailable", sections[1].Error)
303+ assert.Equal(t, "grafana.supri.xyz", sections[2].Routes[0].Domain)
304+}
305+
306+func TestCollectRoutesListSections_ExplicitRemoteSkipsAggregate(t *testing.T) {
307+ testsDeps := routesListDeps{
308+ explicitRemote: func() (*remote.ResolvedRemote, bool) {
309+ return &remote.ResolvedRemote{
310+ Name: "igor",
311+ URL: "https://gordon.supri.xyz",
312+ }, true
313+ },
314+ loadLocal: func(context.Context, string) (routeListSection, error) {
315+ t.Fatal("loadLocal should not be called when an explicit remote is selected")
316+ return routeListSection{}, nil
317+ },
318+ listRemotes: func() (map[string]remote.RemoteEntry, string, error) {
319+ t.Fatal("listRemotes should not be called when an explicit remote is selected")
320+ return nil, "", nil
321+ },
322+ loadRemote: func(_ context.Context, name string, entry remote.RemoteEntry) (routeListSection, error) {
323+ return routeListSection{
324+ Kind: "remote",
325+ Name: name,
326+ URL: entry.URL,
327+ Routes: []routeListItem{{
328+ Domain: "test.supri.xyz",
329+ Image: "hello-test",
330+ }},
331+ }, nil
332+ },
333+ }
334+
335+ sections, err := collectRoutesListSections(context.Background(), "", testsDeps)
336+
337+ require.NoError(t, err)
338+ require.Len(t, sections, 1)
339+ assert.Equal(t, "igor", sections[0].Name)
340+ assert.Equal(t, "test.supri.xyz", sections[0].Routes[0].Domain)
341+}
342+
343+func TestRenderRoutesStatusSections_IncludesSectionHeadingsAndErrors(t *testing.T) {
344+ sections := []routeStatusSection{
345+ {
346+ Kind: "local",
347+ Name: "local",
348+ Routes: []routeStatusItem{{
349+ Domain: "app.local",
350+ Image: "myapp:latest",
351+ ContainerStatus: "running",
352+ }},
353+ },
354+ {
355+ Kind: "remote",
356+ Name: "igor",
357+ URL: "https://gordon.supri.xyz",
358+ Error: "dial tcp timeout",
359+ },
360+ }
361+
362+ var out bytes.Buffer
363+ err := renderRoutesStatusSections(&out, sections)
364+
365+ require.NoError(t, err)
366+ rendered := stripANSI(out.String())
367+ lines := strings.Split(rendered, "\n")
368+
369+ lineIndex := func(substr string) int {
370+ for i, line := range lines {
371+ if strings.Contains(line, substr) {
372+ return i
373+ }
374+ }
375+ return -1
376+ }
377+
378+ titleIdx := lineIndex("Route Status")
379+ require.NotEqual(t, -1, titleIdx)
380+
381+ localIdx := lineIndex("Local")
382+ require.NotEqual(t, -1, localIdx)
383+
384+ routeIdx := lineIndex("app.local")
385+ require.NotEqual(t, -1, routeIdx)
386+
387+ remoteIdx := lineIndex("Remote: igor")
388+ require.NotEqual(t, -1, remoteIdx)
389+
390+ errorIdx := lineIndex("dial tcp timeout")
391+ require.NotEqual(t, -1, errorIdx)
392+
393+ assert.Less(t, titleIdx, localIdx)
394+ assert.Less(t, localIdx, routeIdx)
395+ assert.Less(t, routeIdx, remoteIdx)
396+ assert.Less(t, remoteIdx, errorIdx)
397+}
398+
399+func TestBuildRouteStatusTree_DeterministicAcrossInputOrder(t *testing.T) {
400+ routes := []routeStatusItem{
401+ {Domain: "zulu.example", Image: "zulu:v1", Network: "gordon-zulu", ContainerStatus: "running"},
402+ {Domain: "alpha.example", Image: "alpha:v1", Network: "gordon-alpha", ContainerStatus: "running"},
403+ {Domain: "mike.example", Image: "solo:v1", ContainerStatus: "running"},
404+ }
405+
406+ var outA bytes.Buffer
407+ _, _ = outA.WriteString(stripANSI(buildRouteStatusTree(routes).Render()))
408+
409+ var outB bytes.Buffer
410+ reordered := []routeStatusItem{routes[2], routes[0], routes[1]}
411+ _, _ = outB.WriteString(stripANSI(buildRouteStatusTree(reordered).Render()))
412+
413+ assert.Equal(t, outA.String(), outB.String())
414+}

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