v2.7.0 fac7705

routes.go RawHistoryFolder

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

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