v2.4.2 7da87b2

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

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