v2.16.0 ebd2ab3

routes.go RawHistoryFolder

1package cli
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
9	"github.com/bnema/gordon/internal/adapters/in/cli/ui/components"
10	"github.com/bnema/gordon/internal/adapters/in/cli/ui/styles"
11	"github.com/bnema/gordon/internal/domain"
12
13	"github.com/spf13/cobra"
14)
15
16// configPath for local operations. If empty, config is auto-discovered
17// from standard locations (/etc/gordon/gordon.toml, ~/.config/gordon/gordon.toml, ./gordon.toml).
18var configPath string
19
20// truncateImage shortens long image references for display.
21// For digests (image@sha256:...), shows first 12 chars of digest.
22// For tags, truncates to maxLen with ellipsis if needed.
23func truncateImage(image string, maxLen int) string {
24	// Handle digest references: image@sha256:abc123...
25	if maxLen <= 0 {
26		return ""
27	}
28
29	if idx := strings.Index(image, "@sha256:"); idx != -1 {
30		name := image[:idx]
31		digest := image[idx+8:] // Skip "@sha256:"
32		if len(digest) > 12 {
33			digest = digest[:12]
34		}
35		short := fmt.Sprintf("%s@sha256:%s", name, digest)
36		if len(short) <= maxLen {
37			return short
38		}
39		// Truncate from the end to maintain valid reference format
40		if maxLen <= 3 {
41			return short[:maxLen]
42		}
43		return short[:maxLen-3] + "..."
44	}
45
46	// Regular tag: truncate if needed
47	if len(image) <= maxLen {
48		return image
49	}
50	if maxLen <= 3 {
51		return image[:maxLen]
52	}
53	return image[:maxLen-3] + "..."
54}
55
56func truncateNetwork(network string, maxLen int) string {
57	if network == "" || network == "-" {
58		return "-"
59	}
60	if maxLen <= 0 {
61		return ""
62	}
63	if len(network) <= maxLen {
64		return network
65	}
66	if maxLen <= 3 {
67		return network[:maxLen]
68	}
69	return network[:maxLen-3] + "..."
70}
71
72// newRoutesCmd creates the routes command group.
73func newRoutesCmd() *cobra.Command {
74	cmd := &cobra.Command{
75		Use:   "routes",
76		Short: "Manage routes",
77		Long: `Manage Gordon routes. Routes map domains to container images.
78
79When targeting a remote Gordon instance (via --remote flag or GORDON_REMOTE env var),
80these commands operate on the remote server. Otherwise, they require access to
81the local Gordon configuration.`,
82	}
83
84	cmd.AddCommand(newRoutesListCmd())
85	cmd.AddCommand(newRoutesAddCmd())
86	cmd.AddCommand(newRoutesRemoveCmd())
87
88	return cmd
89}
90
91// formatHTTPStatus formats the HTTP status for display.
92func formatHTTPStatus(health *remote.RouteHealth) string {
93	if health == nil {
94		return styles.Theme.Muted.Render("-")
95	}
96	if health.HTTPStatus == 0 {
97		if health.Error != "" {
98			return styles.Theme.BadgeError.Render("err")
99		}
100		return styles.Theme.Muted.Render("-")
101	}
102	status := fmt.Sprintf("%d", health.HTTPStatus)
103	if health.ResponseTimeMs > 0 {
104		status = fmt.Sprintf("%d (%dms)", health.HTTPStatus, health.ResponseTimeMs)
105	}
106	if health.HTTPStatus >= 200 && health.HTTPStatus < 400 {
107		return styles.Theme.BadgeSuccess.Render(status)
108	}
109	return styles.Theme.BadgeError.Render(status)
110}
111
112// newRoutesListCmd creates the routes list command.
113func newRoutesListCmd() *cobra.Command {
114	return &cobra.Command{
115		Use:   "list",
116		Short: "List all routes",
117		RunE: func(cmd *cobra.Command, args []string) error {
118			ctx := context.Background()
119
120			client, isRemote := GetRemoteClient()
121			if isRemote {
122				return runRoutesListRemote(ctx, client)
123			}
124			return runRoutesListLocal(ctx, configPath)
125		},
126	}
127}
128
129// runRoutesListRemote lists routes from a remote Gordon instance.
130func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
131	routes, err := client.ListRoutesWithDetails(ctx)
132	if err != nil {
133		return fmt.Errorf("failed to list routes: %w", err)
134	}
135
136	if len(routes) == 0 {
137		fmt.Println(styles.Theme.Muted.Render("No routes configured"))
138		return nil
139	}
140
141	// Get health status for each route (includes container status and HTTP probe)
142	health, _ := client.GetHealth(ctx)
143	if health == nil {
144		health = make(map[string]*remote.RouteHealth)
145	}
146
147	const imageColWidth = 35
148	const networkColWidth = 22
149	rows := make([][]string, 0, len(routes))
150	for _, route := range routes {
151		routeHealth := health[route.Domain]
152
153		// Container status column
154		containerStatus := route.ContainerStatus
155		if containerStatus == "" {
156			if routeHealth != nil {
157				containerStatus = routeHealth.ContainerStatus
158			} else {
159				containerStatus = "unknown"
160			}
161		}
162		containerBadge := components.ContainerStatusBadge(containerStatus)
163
164		// HTTP status column
165		httpStatus := formatHTTPStatus(routeHealth)
166
167		displayImage := truncateImage(route.Image, imageColWidth)
168		displayNetwork := truncateNetwork(route.Network, networkColWidth)
169		rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
170
171		for i, attachment := range route.Attachments {
172			// Unicode box-drawing tree structure for nested attachments
173			prefix := styles.IconTreeBranch + styles.IconTreeLine
174			if i == len(route.Attachments)-1 {
175				prefix = styles.IconTreeLast + styles.IconTreeLine
176			}
177			attachmentName := prefix + " " + attachment.Name
178			runes := []rune(attachmentName)
179			if len(runes) > 25 {
180				attachmentName = string(runes[:22]) + "..."
181			}
182			attachmentStatus := components.ContainerStatusBadge(attachment.Status)
183			attachmentImage := truncateImage(attachment.Image, imageColWidth)
184			// Display the attachment's actual network in the routes table
185			attachmentNetwork := truncateNetwork(attachment.Network, networkColWidth)
186			rows = append(rows, []string{attachmentName, attachmentImage, attachmentNetwork, attachmentStatus, "-"})
187		}
188	}
189
190	// Render table
191	table := components.NewTable(
192		components.WithColumns([]components.TableColumn{
193			{Title: "Domain", Width: 25},
194			{Title: "Image", Width: imageColWidth},
195			{Title: "Network", Width: networkColWidth},
196			{Title: "Container", Width: 12},
197			{Title: "HTTP", Width: 14},
198		}),
199		components.WithRows(rows),
200	)
201
202	fmt.Println(styles.Theme.Title.Render("Routes"))
203	fmt.Println()
204	fmt.Println(table.View())
205
206	return nil
207}
208
209// runRoutesListLocal lists routes from local configuration.
210func runRoutesListLocal(ctx context.Context, cfgPath string) error {
211	local, err := GetLocalServices(cfgPath)
212	if err != nil {
213		return fmt.Errorf("failed to initialize local services: %w", err)
214	}
215
216	routes := local.GetConfigService().GetRoutes(ctx)
217
218	if len(routes) == 0 {
219		fmt.Println(styles.Theme.Muted.Render("No routes configured"))
220		return nil
221	}
222
223	// Build table rows (no health info in local mode)
224	const imageColWidth = 45
225	rows := make([][]string, len(routes))
226	for i, route := range routes {
227		displayImage := truncateImage(route.Image, imageColWidth)
228		rows[i] = []string{route.Domain, displayImage}
229	}
230
231	// Render table
232	table := components.NewTable(
233		components.WithColumns([]components.TableColumn{
234			{Title: "Domain", Width: 30},
235			{Title: "Image", Width: imageColWidth},
236		}),
237		components.WithRows(rows),
238	)
239
240	fmt.Println(styles.Theme.Title.Render("Routes (local)"))
241	fmt.Println()
242	fmt.Println(table.View())
243
244	return nil
245}
246
247// newRoutesAddCmd creates the routes add command.
248func newRoutesAddCmd() *cobra.Command {
249	var image string
250
251	cmd := &cobra.Command{
252		Use:   "add <domain> <image>",
253		Short: "Add a new route",
254		Long: `Add a new route mapping a domain to a container image.
255
256Examples:
257  gordon routes add app.mydomain.com myapp:latest
258  gordon --remote https://gordon.mydomain.com routes add api.mydomain.com api:v2`,
259		Args: cobra.RangeArgs(1, 2),
260		RunE: func(cmd *cobra.Command, args []string) error {
261			ctx := context.Background()
262			routeDomain := args[0]
263
264			// Resolve image from positional argument or flag
265			hasPositionalImage := len(args) > 1
266			hasFlagImage := image != ""
267
268			// Check for conflicts: both positional and flag provided
269			if hasPositionalImage && hasFlagImage {
270				return fmt.Errorf("error: cannot use both positional image argument and --image flag")
271			}
272
273			// Check that image is provided via either method
274			if !hasPositionalImage && !hasFlagImage {
275				return fmt.Errorf("error: image is required (use positional argument or --image flag)")
276			}
277
278			if hasPositionalImage {
279				image = args[1]
280			}
281
282			route := domain.Route{
283				Domain: routeDomain,
284				Image:  image,
285			}
286
287			client, isRemote := GetRemoteClient()
288			if isRemote {
289				if err := client.AddRoute(ctx, route); err != nil {
290					return fmt.Errorf("failed to add route: %w", err)
291				}
292			} else {
293				local, err := GetLocalServices(configPath)
294				if err != nil {
295					return fmt.Errorf("failed to initialize local services: %w", err)
296				}
297				if err := local.GetConfigService().AddRoute(ctx, route); err != nil {
298					return fmt.Errorf("failed to add route: %w", err)
299				}
300			}
301
302			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route added: %s -> %s", routeDomain, image)))
303			return nil
304		},
305	}
306
307	cmd.Flags().StringVarP(&image, "image", "i", "", "Container image")
308
309	return cmd
310}
311
312// newRoutesRemoveCmd creates the routes remove command.
313func newRoutesRemoveCmd() *cobra.Command {
314	var force bool
315
316	cmd := &cobra.Command{
317		Use:   "remove <domain>",
318		Short: "Remove a route",
319		Long: `Remove a route by its domain name.
320
321Examples:
322  gordon routes remove app.mydomain.com
323  gordon routes remove app.mydomain.com --force`,
324		Args: cobra.ExactArgs(1),
325		RunE: func(cmd *cobra.Command, args []string) error {
326			ctx := context.Background()
327			routeDomain := args[0]
328
329			// Confirm unless --force
330			if !force {
331				confirmed, err := components.RunConfirm(
332					fmt.Sprintf("Remove route '%s'?", routeDomain),
333					components.WithDescription("This will stop and remove the associated container."),
334				)
335				if err != nil {
336					return err
337				}
338				if !confirmed {
339					fmt.Println(styles.Theme.Muted.Render("Cancelled"))
340					return nil
341				}
342			}
343
344			client, isRemote := GetRemoteClient()
345			if isRemote {
346				if err := client.RemoveRoute(ctx, routeDomain); err != nil {
347					return fmt.Errorf("failed to remove route: %w", err)
348				}
349			} else {
350				local, err := GetLocalServices(configPath)
351				if err != nil {
352					return fmt.Errorf("failed to initialize local services: %w", err)
353				}
354				if err := local.GetConfigService().RemoveRoute(ctx, routeDomain); err != nil {
355					return fmt.Errorf("failed to remove route: %w", err)
356				}
357			}
358
359			fmt.Println(styles.RenderSuccess(fmt.Sprintf("Route removed: %s", routeDomain)))
360			return nil
361		},
362	}
363
364	cmd.Flags().BoolVarP(&force, "force", "f", false, "Skip confirmation")
365
366	return cmd
367}
368
369// newStatusCmd creates the status command.
370func newStatusCmd() *cobra.Command {
371	return &cobra.Command{
372		Use:   "status",
373		Short: "Show Gordon server status",
374		RunE: func(cmd *cobra.Command, args []string) error {
375			ctx := context.Background()
376
377			handle, err := resolveControlPlane(configPath)
378			if err != nil {
379				return err
380			}
381			defer handle.close()
382
383			status, err := handle.plane.GetStatus(ctx)
384			if err != nil {
385				return fmt.Errorf("failed to get status: %w", err)
386			}
387
388			// Display status
389			fmt.Println(styles.Theme.Title.Render("Gordon Status"))
390			fmt.Println()
391
392			fmt.Printf("%s %s\n", styles.Theme.Bold.Render("Domain:"), status.RegistryDomain)
393			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Registry Port:"), status.RegistryPort)
394			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Server Port:"), status.ServerPort)
395			fmt.Printf("%s %d\n", styles.Theme.Bold.Render("Routes:"), status.Routes)
396			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Auto-Route:"), status.AutoRoute)
397			fmt.Printf("%s %v\n", styles.Theme.Bold.Render("Network Isolation:"), status.NetworkIsolation)
398
399			if len(status.ContainerStatus) > 0 {
400				fmt.Println()
401				fmt.Println(styles.Theme.Bold.Render("Container Status:"))
402				for domain, containerStatus := range status.ContainerStatus {
403					badge := components.ContainerStatusBadge(containerStatus)
404					fmt.Printf("  %s: %s\n", domain, badge)
405				}
406			}
407
408			return nil
409		},
410	}
411}

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