detached bdeaa00

Merge pull request #31 from bnema/fix/cli-routes-display

fix: improve CLI routes list display
commit
bdeaa00730bf27dc9ba57fb56a3fe35f5c005544
author
bnema · · 2026-01-17 14:52 UTC
committer
GitHub · · 2026-01-17 14:52 UTC
merge of
9e79967 + 55fbe86 · diff against the first parent
signature
unsigned
10 files · +231 -74
M internal/adapters/dto/admin_route.go +1 -0
@@ -24,6 +24,7 @@ type Attachment struct {
2424 Image string `json:"image"`
2525 ContainerID string `json:"container_id"`
2626 Status string `json:"status"`
27+ Network string `json:"network"`
2728 }
2829
2930 // RoutesResponse represents a list of routes.
@@ -24,6 +24,7 @@ type Attachment struct {
24 Image string `json:"image"`24 Image string `json:"image"`
25 ContainerID string `json:"container_id"`25 ContainerID string `json:"container_id"`
26 Status string `json:"status"`26 Status string `json:"status"`
27+ Network string `json:"network"`
27 }28 }
28 29
29 // RoutesResponse represents a list of routes.30 // RoutesResponse represents a list of routes.
M internal/adapters/in/cli/routes.go +9 -5
@@ -170,17 +170,21 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
170170 rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
171171
172172 for i, attachment := range route.Attachments {
173- prefix := "|-"
173+ // Unicode box-drawing tree structure for nested attachments
174+ prefix := styles.IconTreeBranch + styles.IconTreeLine
174175 if i == len(route.Attachments)-1 {
175- prefix = "`-"
176+ prefix = styles.IconTreeLast + styles.IconTreeLine
176177 }
177178 attachmentName := prefix + " " + attachment.Name
178- if len(attachmentName) > 25 {
179- attachmentName = attachmentName[:22] + "..."
179+ runes := []rune(attachmentName)
180+ if len(runes) > 25 {
181+ attachmentName = string(runes[:22]) + "..."
180182 }
181183 attachmentStatus := components.ContainerStatusBadge(attachment.Status)
182184 attachmentImage := truncateImage(attachment.Image, imageColWidth)
183- rows = append(rows, []string{attachmentName, attachmentImage, "-", attachmentStatus, "-"})
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, "-"})
184188 }
185189 }
186190
@@ -170,17 +170,21 @@ func runRoutesListRemote(ctx context.Context, client *remote.Client) error {
170 rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})170 rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus})
171 171
172 for i, attachment := range route.Attachments {172 for i, attachment := range route.Attachments {
173- prefix := "|-"173+ // Unicode box-drawing tree structure for nested attachments
174+ prefix := styles.IconTreeBranch + styles.IconTreeLine
174 if i == len(route.Attachments)-1 {175 if i == len(route.Attachments)-1 {
175- prefix = "`-"176+ prefix = styles.IconTreeLast + styles.IconTreeLine
176 }177 }
177 attachmentName := prefix + " " + attachment.Name178 attachmentName := prefix + " " + attachment.Name
178- if len(attachmentName) > 25 {179+ runes := []rune(attachmentName)
179- attachmentName = attachmentName[:22] + "..."180+ if len(runes) > 25 {
181+ attachmentName = string(runes[:22]) + "..."
180 }182 }
181 attachmentStatus := components.ContainerStatusBadge(attachment.Status)183 attachmentStatus := components.ContainerStatusBadge(attachment.Status)
182 attachmentImage := truncateImage(attachment.Image, imageColWidth)184 attachmentImage := truncateImage(attachment.Image, imageColWidth)
183- rows = append(rows, []string{attachmentName, attachmentImage, "-", attachmentStatus, "-"})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, "-"})
184 }188 }
185 }189 }
186 190
M internal/adapters/in/cli/ui/components/status.go +44 -12
@@ -19,6 +19,10 @@ const (
1919 StatusPending
2020 StatusRunning
2121 StatusStopped
22+ StatusPaused
23+ StatusRestarting
24+ StatusExited
25+ StatusUnknown
2226 )
2327
2428 // StatusConfig holds configuration for status rendering.
@@ -54,13 +58,29 @@ var DefaultStatusConfigs = map[Status]StatusConfig{
5458 Style: styles.Theme.Muted,
5559 },
5660 StatusRunning: {
57- Icon: styles.IconSuccess,
61+ Icon: styles.IconRunning,
5862 Style: styles.Theme.Success,
5963 },
6064 StatusStopped: {
61- Icon: styles.IconError,
65+ Icon: styles.IconStopped,
66+ Style: styles.Theme.Error,
67+ },
68+ StatusPaused: {
69+ Icon: styles.IconPaused,
70+ Style: styles.Theme.Warning,
71+ },
72+ StatusRestarting: {
73+ Icon: styles.IconRestarting,
74+ Style: styles.Theme.Warning,
75+ },
76+ StatusExited: {
77+ Icon: styles.IconExited,
6278 Style: styles.Theme.Error,
6379 },
80+ StatusUnknown: {
81+ Icon: styles.IconUnknown,
82+ Style: styles.Theme.Muted,
83+ },
6484 }
6585
6686 // RenderStatus renders a status with icon and optional label.
@@ -72,22 +92,26 @@ func RenderStatus(status Status, label string) string {
7292 return config.Style.Render(config.Icon + " " + label)
7393 }
7494
75-// RenderStatusBadge renders a status as a badge with background.
76-func RenderStatusBadge(status Status, label string) string {
95+// RenderStatusBadge renders a status as a compact icon badge with background.
96+// It displays only the icon (no label) for minimal width in table columns.
97+func RenderStatusBadge(status Status) string {
98+ config := DefaultStatusConfigs[status]
7799 var badgeStyle lipgloss.Style
78100 switch status {
79101 case StatusSuccess, StatusRunning:
80102 badgeStyle = styles.Theme.BadgeSuccess
81- case StatusError, StatusStopped:
103+ case StatusError, StatusStopped, StatusExited:
82104 badgeStyle = styles.Theme.BadgeError
83- case StatusWarning:
105+ case StatusWarning, StatusPaused, StatusRestarting:
84106 badgeStyle = styles.Theme.BadgeWarning
85107 case StatusPending:
86108 badgeStyle = styles.Theme.BadgePending
109+ case StatusUnknown:
110+ badgeStyle = styles.Theme.Muted
87111 default:
88112 badgeStyle = styles.Theme.BadgeInfo
89113 }
90- return badgeStyle.Render(label)
114+ return badgeStyle.Render(config.Icon)
91115 }
92116
93117 // ParseStatus converts a string status to Status type.
@@ -101,14 +125,22 @@ func ParseStatus(s string) Status {
101125 return StatusWarning
102126 case "info":
103127 return StatusInfo
104- case "pending", "starting", "waiting":
128+ case "pending", "starting", "waiting", "created":
105129 return StatusPending
106130 case "running", "up":
107131 return StatusRunning
108- case "stopped", "down", "exited":
132+ case "stopped", "down":
109133 return StatusStopped
134+ case "paused":
135+ return StatusPaused
136+ case "restarting":
137+ return StatusRestarting
138+ case "exited", "dead":
139+ return StatusExited
140+ case "unknown":
141+ return StatusUnknown
110142 default:
111- return StatusInfo
143+ return StatusUnknown
112144 }
113145 }
114146
@@ -140,7 +172,7 @@ func NewStatusBadge(status Status, label string) StatusIndicator {
140172 // View renders the status indicator.
141173 func (s StatusIndicator) View() string {
142174 if s.Badge {
143- return RenderStatusBadge(s.Status, s.Label)
175+ return RenderStatusBadge(s.Status)
144176 }
145177 return RenderStatus(s.Status, s.Label)
146178 }
@@ -154,5 +186,5 @@ func ContainerStatusIndicator(status string) string {
154186 // ContainerStatusBadge renders container status as a badge.
155187 func ContainerStatusBadge(status string) string {
156188 parsed := ParseStatus(status)
157- return RenderStatusBadge(parsed, status)
189+ return RenderStatusBadge(parsed)
158190 }
@@ -19,6 +19,10 @@ const (
19 StatusPending19 StatusPending
20 StatusRunning20 StatusRunning
21 StatusStopped21 StatusStopped
22+ StatusPaused
23+ StatusRestarting
24+ StatusExited
25+ StatusUnknown
22 )26 )
23 27
24 // StatusConfig holds configuration for status rendering.28 // StatusConfig holds configuration for status rendering.
@@ -54,13 +58,29 @@ var DefaultStatusConfigs = map[Status]StatusConfig{
54 Style: styles.Theme.Muted,58 Style: styles.Theme.Muted,
55 },59 },
56 StatusRunning: {60 StatusRunning: {
57- Icon: styles.IconSuccess,61+ Icon: styles.IconRunning,
58 Style: styles.Theme.Success,62 Style: styles.Theme.Success,
59 },63 },
60 StatusStopped: {64 StatusStopped: {
61- Icon: styles.IconError,65+ Icon: styles.IconStopped,
66+ Style: styles.Theme.Error,
67+ },
68+ StatusPaused: {
69+ Icon: styles.IconPaused,
70+ Style: styles.Theme.Warning,
71+ },
72+ StatusRestarting: {
73+ Icon: styles.IconRestarting,
74+ Style: styles.Theme.Warning,
75+ },
76+ StatusExited: {
77+ Icon: styles.IconExited,
62 Style: styles.Theme.Error,78 Style: styles.Theme.Error,
63 },79 },
80+ StatusUnknown: {
81+ Icon: styles.IconUnknown,
82+ Style: styles.Theme.Muted,
83+ },
64 }84 }
65 85
66 // RenderStatus renders a status with icon and optional label.86 // RenderStatus renders a status with icon and optional label.
@@ -72,22 +92,26 @@ func RenderStatus(status Status, label string) string {
72 return config.Style.Render(config.Icon + " " + label)92 return config.Style.Render(config.Icon + " " + label)
73 }93 }
74 94
75-// RenderStatusBadge renders a status as a badge with background.95+// RenderStatusBadge renders a status as a compact icon badge with background.
76-func RenderStatusBadge(status Status, label string) string {96+// It displays only the icon (no label) for minimal width in table columns.
97+func RenderStatusBadge(status Status) string {
98+ config := DefaultStatusConfigs[status]
77 var badgeStyle lipgloss.Style99 var badgeStyle lipgloss.Style
78 switch status {100 switch status {
79 case StatusSuccess, StatusRunning:101 case StatusSuccess, StatusRunning:
80 badgeStyle = styles.Theme.BadgeSuccess102 badgeStyle = styles.Theme.BadgeSuccess
81- case StatusError, StatusStopped:103+ case StatusError, StatusStopped, StatusExited:
82 badgeStyle = styles.Theme.BadgeError104 badgeStyle = styles.Theme.BadgeError
83- case StatusWarning:105+ case StatusWarning, StatusPaused, StatusRestarting:
84 badgeStyle = styles.Theme.BadgeWarning106 badgeStyle = styles.Theme.BadgeWarning
85 case StatusPending:107 case StatusPending:
86 badgeStyle = styles.Theme.BadgePending108 badgeStyle = styles.Theme.BadgePending
109+ case StatusUnknown:
110+ badgeStyle = styles.Theme.Muted
87 default:111 default:
88 badgeStyle = styles.Theme.BadgeInfo112 badgeStyle = styles.Theme.BadgeInfo
89 }113 }
90- return badgeStyle.Render(label)114+ return badgeStyle.Render(config.Icon)
91 }115 }
92 116
93 // ParseStatus converts a string status to Status type.117 // ParseStatus converts a string status to Status type.
@@ -101,14 +125,22 @@ func ParseStatus(s string) Status {
101 return StatusWarning125 return StatusWarning
102 case "info":126 case "info":
103 return StatusInfo127 return StatusInfo
104- case "pending", "starting", "waiting":128+ case "pending", "starting", "waiting", "created":
105 return StatusPending129 return StatusPending
106 case "running", "up":130 case "running", "up":
107 return StatusRunning131 return StatusRunning
108- case "stopped", "down", "exited":132+ case "stopped", "down":
109 return StatusStopped133 return StatusStopped
134+ case "paused":
135+ return StatusPaused
136+ case "restarting":
137+ return StatusRestarting
138+ case "exited", "dead":
139+ return StatusExited
140+ case "unknown":
141+ return StatusUnknown
110 default:142 default:
111- return StatusInfo143+ return StatusUnknown
112 }144 }
113 }145 }
114 146
@@ -140,7 +172,7 @@ func NewStatusBadge(status Status, label string) StatusIndicator {
140 // View renders the status indicator.172 // View renders the status indicator.
141 func (s StatusIndicator) View() string {173 func (s StatusIndicator) View() string {
142 if s.Badge {174 if s.Badge {
143- return RenderStatusBadge(s.Status, s.Label)175+ return RenderStatusBadge(s.Status)
144 }176 }
145 return RenderStatus(s.Status, s.Label)177 return RenderStatus(s.Status, s.Label)
146 }178 }
@@ -154,5 +186,5 @@ func ContainerStatusIndicator(status string) string {
154 // ContainerStatusBadge renders container status as a badge.186 // ContainerStatusBadge renders container status as a badge.
155 func ContainerStatusBadge(status string) string {187 func ContainerStatusBadge(status string) string {
156 parsed := ParseStatus(status)188 parsed := ParseStatus(status)
157- return RenderStatusBadge(parsed, status)189+ return RenderStatusBadge(parsed)
158 }190 }
M internal/adapters/in/cli/ui/components/table.go +1 -1
@@ -45,7 +45,7 @@ func NewTable(opts ...TableOption) *TableModel {
4545 Foreground(styles.ColorText).
4646 Padding(0, 1),
4747 evenStyle: lipgloss.NewStyle().
48- Foreground(styles.ColorTextMuted).
48+ Foreground(styles.ColorText).
4949 Padding(0, 1),
5050 }
5151
@@ -45,7 +45,7 @@ func NewTable(opts ...TableOption) *TableModel {
45 Foreground(styles.ColorText).45 Foreground(styles.ColorText).
46 Padding(0, 1),46 Padding(0, 1),
47 evenStyle: lipgloss.NewStyle().47 evenStyle: lipgloss.NewStyle().
48- Foreground(styles.ColorTextMuted).48+ Foreground(styles.ColorText).
49 Padding(0, 1),49 Padding(0, 1),
50 }50 }
51 51
M internal/adapters/in/cli/ui/styles/icons.go +75 -52
@@ -2,78 +2,101 @@ package styles
22
33 // Nerd Font icons for terminal UI.
44 // These icons require a Nerd Font compatible terminal font.
5+// All icons are defined using Unicode escape sequences for portability.
56 // Fallback ASCII alternatives are provided where practical.
67 const (
78 // Status indicators
8- IconSuccess = "" // nf-fa-check (U+F00C)
9- IconError = "" // nf-fa-times (U+F00D)
10- IconWarning = "" // nf-fa-exclamation_triangle (U+F071)
11- IconInfo = "" // nf-fa-info_circle (U+F05A)
12- IconPending = "" // nf-fa-clock_o (U+F017)
9+ IconSuccess = "\uf00c" // nf-fa-check
10+ IconError = "\uf00d" // nf-fa-times
11+ IconWarning = "\uf071" // nf-fa-exclamation_triangle
12+ IconInfo = "\uf05a" // nf-fa-info_circle
13+ IconPending = "\uf017" // nf-fa-clock
14+
15+ // Container status icons
16+ IconRunning = "\uf00c" // nf-fa-check (green)
17+ IconStopped = "\uf04d" // nf-fa-stop
18+ IconExited = "\uf00d" // nf-fa-times
19+ IconPaused = "\uf04c" // nf-fa-pause
20+ IconRestarting = "\uf021" // nf-fa-refresh
21+ IconUnknown = "\uf071" // nf-fa-exclamation_triangle
1322
1423 // Objects
15- IconRoute = "" // nf-fa-sitemap (U+F0E8)
16- IconSecret = "" // nf-fa-lock (U+F023)
17- IconToken = "" // nf-fa-key (U+F084)
18- IconContainer = "" // nf-oct-container (U+F489)
19- IconServer = "" // nf-fa-server (U+F233)
20- IconNetwork = "󰛳" // nf-md-lan (U+F06F3)
21- IconVolume = "" // nf-fa-database (U+F1C0)
22- IconImage = "" // nf-fa-archive (U+F187)
24+ IconRoute = "\uf0e8" // nf-fa-sitemap
25+ IconSecret = "\uf023" // nf-fa-lock
26+ IconToken = "\uf084" // nf-fa-key
27+ IconContainer = "\uf489" // nf-oct-container (U+F489)
28+ IconServer = "\uf233" // nf-fa-server
29+ IconNetwork = "\U000f06f3" // nf-md-lan (U+F06F3)
30+ IconVolume = "\uf1c0" // nf-fa-database
31+ IconImage = "\uf187" // nf-fa-archive
32+ IconDocker = "\ue7b0" // nf-dev-docker
2333
2434 // Actions
25- IconAdd = "" // nf-fa-plus (U+F067)
26- IconRemove = "" // nf-fa-minus (U+F068)
27- IconEdit = "" // nf-fa-pencil (U+F040)
28- IconRefresh = "" // nf-fa-refresh (U+F021)
29- IconUpload = "" // nf-fa-cloud_upload (U+F0EE)
30- IconDelete = "" // nf-fa-trash (U+F1F8)
35+ IconAdd = "\uf067" // nf-fa-plus
36+ IconRemove = "\uf068" // nf-fa-minus
37+ IconEdit = "\uf040" // nf-fa-pencil
38+ IconRefresh = "\uf021" // nf-fa-refresh
39+ IconUpload = "\uf0ee" // nf-fa-cloud_upload
40+ IconDelete = "\uf1f8" // nf-fa-trash
3141
3242 // Navigation
33- IconArrowRight = "" // nf-fa-arrow_right (U+F061)
34- IconArrowLeft = "" // nf-fa-arrow_left (U+F060)
35- IconArrowUp = "" // nf-fa-arrow_up (U+F062)
36- IconArrowDown = "" // nf-fa-arrow_down (U+F063)
37- IconChevron = "" // nf-fa-chevron_right (U+F054)
43+ IconArrowRight = "\uf061" // nf-fa-arrow_right
44+ IconArrowLeft = "\uf060" // nf-fa-arrow_left
45+ IconArrowUp = "\uf062" // nf-fa-arrow_up
46+ IconArrowDown = "\uf063" // nf-fa-arrow_down
47+ IconChevron = "\uf054" // nf-fa-chevron_right
3848
3949 // UI elements
40- IconBullet = "▸" // Simple triangle bullet
41- IconDot = "●" // Filled circle
42- IconDotEmpty = "○" // Empty circle
43- IconCheckbox = "" // nf-fa-square_o (U+F096)
44- IconChecked = "" // nf-fa-check_square_o (U+F046)
45- IconRadio = "" // nf-fa-circle_o (U+F10C)
46- IconRadioCheck = "" // nf-fa-dot_circle_o (U+F192)
50+ IconBullet = "\u25b8" // ▸ Simple triangle bullet
51+ IconDot = "\u25cf" // ● Filled circle
52+ IconDotEmpty = "\u25cb" // ○ Empty circle
53+ IconCheckbox = "\uf096" // nf-fa-square_o
54+ IconChecked = "\uf046" // nf-fa-check_square_o
55+ IconRadio = "\uf10c" // nf-fa-circle_o
56+ IconRadioCheck = "\uf192" // nf-fa-dot_circle_o
57+ IconPlay = "\uf04b" // nf-fa-play
58+ IconStop = "\uf04d" // nf-fa-stop
59+ IconPause = "\uf04c" // nf-fa-pause
60+
61+ // Tree structure
62+ IconTreeBranch = "\u251c" // ├
63+ IconTreeLast = "\u2514" // └
64+ IconTreeLine = "\u2500" // ─
65+ IconTreeVert = "\u2502" // │
4766
4867 // Spinners (individual frames)
49- SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷"
68+ SpinnerDot = "\u28fe\u28fd\u28fb\u28bf\u287f\u28df\u28ef\u28f7"
5069 SpinnerLine = "|/-\\"
51- SpinnerCircle = "◐◓◑◒"
52- SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
70+ SpinnerCircle = "\u25d0\u25d3\u25d1\u25d2"
71+ SpinnerBrail = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
5372
5473 // Borders and decorations
55- BorderHorizontal = "─"
56- BorderVertical = "│"
57- BorderCornerTL = "┌"
58- BorderCornerTR = "┐"
59- BorderCornerBL = "└"
60- BorderCornerBR = "┘"
61- BorderT = "┬"
62- BorderB = "┴"
63- BorderL = "├"
64- BorderR = "┤"
65- BorderCross = "┼"
74+ BorderHorizontal = "\u2500" // ─
75+ BorderVertical = "\u2502" // │
76+ BorderCornerTL = "\u250c" // ┌
77+ BorderCornerTR = "\u2510" // ┐
78+ BorderCornerBL = "\u2514" // └
79+ BorderCornerBR = "\u2518" // ┘
80+ BorderT = "\u252c" // ┬
81+ BorderB = "\u2534" // ┴
82+ BorderL = "\u251c" // ├
83+ BorderR = "\u2524" // ┤
84+ BorderCross = "\u253c" // ┼
6685
6786 // Gordon branding
68- IconGordon = "" // nf-fa-cube (U+F1B2)
87+ IconGordon = "\uf1b2" // nf-fa-cube
6988 )
7089
7190 // ASCII fallback alternatives for terminals without Nerd Fonts.
7291 const (
73- AsciiSuccess = "[OK]"
74- AsciiError = "[X]"
75- AsciiWarning = "[!]"
76- AsciiInfo = "[i]"
77- AsciiBullet = ">"
78- AsciiDot = "*"
92+ AsciiSuccess = "[OK]"
93+ AsciiError = "[X]"
94+ AsciiWarning = "[!]"
95+ AsciiInfo = "[i]"
96+ AsciiBullet = ">"
97+ AsciiDot = "*"
98+ AsciiRunning = "[R]"
99+ AsciiStopped = "[S]"
100+ AsciiTreeBranch = "|-"
101+ AsciiTreeLast = "`-"
79102 )
@@ -2,78 +2,101 @@ package styles
2 2
3 // Nerd Font icons for terminal UI.3 // Nerd Font icons for terminal UI.
4 // These icons require a Nerd Font compatible terminal font.4 // These icons require a Nerd Font compatible terminal font.
5+// All icons are defined using Unicode escape sequences for portability.
5 // Fallback ASCII alternatives are provided where practical.6 // Fallback ASCII alternatives are provided where practical.
6 const (7 const (
7 // Status indicators8 // Status indicators
8- IconSuccess = "" // nf-fa-check (U+F00C)9+ IconSuccess = "\uf00c" // nf-fa-check
9- IconError = "" // nf-fa-times (U+F00D)10+ IconError = "\uf00d" // nf-fa-times
10- IconWarning = "" // nf-fa-exclamation_triangle (U+F071)11+ IconWarning = "\uf071" // nf-fa-exclamation_triangle
11- IconInfo = "" // nf-fa-info_circle (U+F05A)12+ IconInfo = "\uf05a" // nf-fa-info_circle
12- IconPending = "" // nf-fa-clock_o (U+F017)13+ IconPending = "\uf017" // nf-fa-clock
14+
15+ // Container status icons
16+ IconRunning = "\uf00c" // nf-fa-check (green)
17+ IconStopped = "\uf04d" // nf-fa-stop
18+ IconExited = "\uf00d" // nf-fa-times
19+ IconPaused = "\uf04c" // nf-fa-pause
20+ IconRestarting = "\uf021" // nf-fa-refresh
21+ IconUnknown = "\uf071" // nf-fa-exclamation_triangle
13 22
14 // Objects23 // Objects
15- IconRoute = "" // nf-fa-sitemap (U+F0E8)24+ IconRoute = "\uf0e8" // nf-fa-sitemap
16- IconSecret = "" // nf-fa-lock (U+F023)25+ IconSecret = "\uf023" // nf-fa-lock
17- IconToken = "" // nf-fa-key (U+F084)26+ IconToken = "\uf084" // nf-fa-key
18- IconContainer = "" // nf-oct-container (U+F489)27+ IconContainer = "\uf489" // nf-oct-container (U+F489)
19- IconServer = "" // nf-fa-server (U+F233)28+ IconServer = "\uf233" // nf-fa-server
20- IconNetwork = "󰛳" // nf-md-lan (U+F06F3)29+ IconNetwork = "\U000f06f3" // nf-md-lan (U+F06F3)
21- IconVolume = "" // nf-fa-database (U+F1C0)30+ IconVolume = "\uf1c0" // nf-fa-database
22- IconImage = "" // nf-fa-archive (U+F187)31+ IconImage = "\uf187" // nf-fa-archive
32+ IconDocker = "\ue7b0" // nf-dev-docker
23 33
24 // Actions34 // Actions
25- IconAdd = "" // nf-fa-plus (U+F067)35+ IconAdd = "\uf067" // nf-fa-plus
26- IconRemove = "" // nf-fa-minus (U+F068)36+ IconRemove = "\uf068" // nf-fa-minus
27- IconEdit = "" // nf-fa-pencil (U+F040)37+ IconEdit = "\uf040" // nf-fa-pencil
28- IconRefresh = "" // nf-fa-refresh (U+F021)38+ IconRefresh = "\uf021" // nf-fa-refresh
29- IconUpload = "" // nf-fa-cloud_upload (U+F0EE)39+ IconUpload = "\uf0ee" // nf-fa-cloud_upload
30- IconDelete = "" // nf-fa-trash (U+F1F8)40+ IconDelete = "\uf1f8" // nf-fa-trash
31 41
32 // Navigation42 // Navigation
33- IconArrowRight = "" // nf-fa-arrow_right (U+F061)43+ IconArrowRight = "\uf061" // nf-fa-arrow_right
34- IconArrowLeft = "" // nf-fa-arrow_left (U+F060)44+ IconArrowLeft = "\uf060" // nf-fa-arrow_left
35- IconArrowUp = "" // nf-fa-arrow_up (U+F062)45+ IconArrowUp = "\uf062" // nf-fa-arrow_up
36- IconArrowDown = "" // nf-fa-arrow_down (U+F063)46+ IconArrowDown = "\uf063" // nf-fa-arrow_down
37- IconChevron = "" // nf-fa-chevron_right (U+F054)47+ IconChevron = "\uf054" // nf-fa-chevron_right
38 48
39 // UI elements49 // UI elements
40- IconBullet = "▸" // Simple triangle bullet50+ IconBullet = "\u25b8" // ▸ Simple triangle bullet
41- IconDot = "●" // Filled circle51+ IconDot = "\u25cf" // ● Filled circle
42- IconDotEmpty = "○" // Empty circle52+ IconDotEmpty = "\u25cb" // ○ Empty circle
43- IconCheckbox = "" // nf-fa-square_o (U+F096)53+ IconCheckbox = "\uf096" // nf-fa-square_o
44- IconChecked = "" // nf-fa-check_square_o (U+F046)54+ IconChecked = "\uf046" // nf-fa-check_square_o
45- IconRadio = "" // nf-fa-circle_o (U+F10C)55+ IconRadio = "\uf10c" // nf-fa-circle_o
46- IconRadioCheck = "" // nf-fa-dot_circle_o (U+F192)56+ IconRadioCheck = "\uf192" // nf-fa-dot_circle_o
57+ IconPlay = "\uf04b" // nf-fa-play
58+ IconStop = "\uf04d" // nf-fa-stop
59+ IconPause = "\uf04c" // nf-fa-pause
60+
61+ // Tree structure
62+ IconTreeBranch = "\u251c" // ├
63+ IconTreeLast = "\u2514" // └
64+ IconTreeLine = "\u2500" // ─
65+ IconTreeVert = "\u2502" // │
47 66
48 // Spinners (individual frames)67 // Spinners (individual frames)
49- SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷"68+ SpinnerDot = "\u28fe\u28fd\u28fb\u28bf\u287f\u28df\u28ef\u28f7"
50 SpinnerLine = "|/-\\"69 SpinnerLine = "|/-\\"
51- SpinnerCircle = "◐◓◑◒"70+ SpinnerCircle = "\u25d0\u25d3\u25d1\u25d2"
52- SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"71+ SpinnerBrail = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f"
53 72
54 // Borders and decorations73 // Borders and decorations
55- BorderHorizontal = "─"74+ BorderHorizontal = "\u2500" // ─
56- BorderVertical = "│"75+ BorderVertical = "\u2502" // │
57- BorderCornerTL = "┌"76+ BorderCornerTL = "\u250c" // ┌
58- BorderCornerTR = "┐"77+ BorderCornerTR = "\u2510" // ┐
59- BorderCornerBL = "└"78+ BorderCornerBL = "\u2514" // └
60- BorderCornerBR = "┘"79+ BorderCornerBR = "\u2518" // ┘
61- BorderT = "┬"80+ BorderT = "\u252c" // ┬
62- BorderB = "┴"81+ BorderB = "\u2534" // ┴
63- BorderL = "├"82+ BorderL = "\u251c" // ├
64- BorderR = "┤"83+ BorderR = "\u2524" // ┤
65- BorderCross = "┼"84+ BorderCross = "\u253c" // ┼
66 85
67 // Gordon branding86 // Gordon branding
68- IconGordon = "" // nf-fa-cube (U+F1B2)87+ IconGordon = "\uf1b2" // nf-fa-cube
69 )88 )
70 89
71 // ASCII fallback alternatives for terminals without Nerd Fonts.90 // ASCII fallback alternatives for terminals without Nerd Fonts.
72 const (91 const (
73- AsciiSuccess = "[OK]"92+ AsciiSuccess = "[OK]"
74- AsciiError = "[X]"93+ AsciiError = "[X]"
75- AsciiWarning = "[!]"94+ AsciiWarning = "[!]"
76- AsciiInfo = "[i]"95+ AsciiInfo = "[i]"
77- AsciiBullet = ">"96+ AsciiBullet = ">"
78- AsciiDot = "*"97+ AsciiDot = "*"
98+ AsciiRunning = "[R]"
99+ AsciiStopped = "[S]"
100+ AsciiTreeBranch = "|-"
101+ AsciiTreeLast = "`-"
79 )102 )
M internal/adapters/in/http/admin/handler.go +1 -0
@@ -47,6 +47,7 @@ func toAttachmentResponse(a domain.Attachment) dto.Attachment {
4747 Image: a.Image,
4848 ContainerID: a.ContainerID,
4949 Status: a.Status,
50+ Network: a.Network,
5051 }
5152 }
5253
@@ -47,6 +47,7 @@ func toAttachmentResponse(a domain.Attachment) dto.Attachment {
47 Image: a.Image,47 Image: a.Image,
48 ContainerID: a.ContainerID,48 ContainerID: a.ContainerID,
49 Status: a.Status,49 Status: a.Status,
50+ Network: a.Network,
50 }51 }
51 }52 }
52 53
M internal/domain/container.go +1 -0
@@ -27,6 +27,7 @@ type Attachment struct {
2727 Image string
2828 ContainerID string
2929 Status string
30+ Network string
3031 }
3132
3233 // RouteInfo combines route configuration with runtime state.
@@ -27,6 +27,7 @@ type Attachment struct {
27 Image string27 Image string
28 ContainerID string28 ContainerID string
29 Status string29 Status string
30+ Network string
30 }31 }
31 32
32 // RouteInfo combines route configuration with runtime state.33 // RouteInfo combines route configuration with runtime state.
M internal/usecase/container/routes_test.go +6 -0
@@ -25,6 +25,9 @@ func TestService_ListRoutesWithDetails(t *testing.T) {
2525 }
2626
2727 runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("gordon-app-example-com", nil)
28+ // getAllAttachments calls GetContainerNetwork for each attachment
29+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-1").Return("gordon-app-example-com", nil)
30+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-2").Return("gordon-other-example-com", nil)
2831 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
2932 {
3033 ID: "attach-1",
@@ -71,6 +74,7 @@ func TestService_ListRoutesWithDetails(t *testing.T) {
7174 assert.Equal(t, "postgres", results[0].Attachments[0].Name)
7275 assert.Equal(t, "postgres:15", results[0].Attachments[0].Image)
7376 assert.Equal(t, "attach-1", results[0].Attachments[0].ContainerID)
77+ assert.Equal(t, "gordon-app-example-com", results[0].Attachments[0].Network)
7478 }
7579 }
7680
@@ -136,6 +140,7 @@ func TestService_ListAttachments(t *testing.T) {
136140 },
137141 },
138142 }, nil)
143+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-1").Return("gordon-app-example-com", nil)
139144
140145 attachments := svc.ListAttachments(ctx, "app.example.com")
141146
@@ -143,6 +148,7 @@ func TestService_ListAttachments(t *testing.T) {
143148 assert.Equal(t, "postgres", attachments[0].Name)
144149 assert.Equal(t, "postgres:15", attachments[0].Image)
145150 assert.Equal(t, "attach-1", attachments[0].ContainerID)
151+ assert.Equal(t, "gordon-app-example-com", attachments[0].Network)
146152 }
147153
148154 func TestService_ListNetworks(t *testing.T) {
@@ -25,6 +25,9 @@ func TestService_ListRoutesWithDetails(t *testing.T) {
25 }25 }
26 26
27 runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("gordon-app-example-com", nil)27 runtime.EXPECT().GetContainerNetwork(mock.Anything, "container-1").Return("gordon-app-example-com", nil)
28+ // getAllAttachments calls GetContainerNetwork for each attachment
29+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-1").Return("gordon-app-example-com", nil)
30+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-2").Return("gordon-other-example-com", nil)
28 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{31 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
29 {32 {
30 ID: "attach-1",33 ID: "attach-1",
@@ -71,6 +74,7 @@ func TestService_ListRoutesWithDetails(t *testing.T) {
71 assert.Equal(t, "postgres", results[0].Attachments[0].Name)74 assert.Equal(t, "postgres", results[0].Attachments[0].Name)
72 assert.Equal(t, "postgres:15", results[0].Attachments[0].Image)75 assert.Equal(t, "postgres:15", results[0].Attachments[0].Image)
73 assert.Equal(t, "attach-1", results[0].Attachments[0].ContainerID)76 assert.Equal(t, "attach-1", results[0].Attachments[0].ContainerID)
77+ assert.Equal(t, "gordon-app-example-com", results[0].Attachments[0].Network)
74 }78 }
75 }79 }
76 80
@@ -136,6 +140,7 @@ func TestService_ListAttachments(t *testing.T) {
136 },140 },
137 },141 },
138 }, nil)142 }, nil)
143+ runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-1").Return("gordon-app-example-com", nil)
139 144
140 attachments := svc.ListAttachments(ctx, "app.example.com")145 attachments := svc.ListAttachments(ctx, "app.example.com")
141 146
@@ -143,6 +148,7 @@ func TestService_ListAttachments(t *testing.T) {
143 assert.Equal(t, "postgres", attachments[0].Name)148 assert.Equal(t, "postgres", attachments[0].Name)
144 assert.Equal(t, "postgres:15", attachments[0].Image)149 assert.Equal(t, "postgres:15", attachments[0].Image)
145 assert.Equal(t, "attach-1", attachments[0].ContainerID)150 assert.Equal(t, "attach-1", attachments[0].ContainerID)
151+ assert.Equal(t, "gordon-app-example-com", attachments[0].Network)
146 }152 }
147 153
148 func TestService_ListNetworks(t *testing.T) {154 func TestService_ListNetworks(t *testing.T) {
M internal/usecase/container/service.go +31 -4
@@ -344,6 +344,8 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
344344 image = labelImage
345345 }
346346 }
347+ // Strip registry domain prefix from image for cleaner display
348+ image = s.stripRegistryPrefix(image)
347349 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
348350 network = networkName
349351 }
@@ -362,6 +364,19 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
362364 return results
363365 }
364366
367+// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
368+// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
369+func (s *Service) stripRegistryPrefix(image string) string {
370+ if s.config.RegistryDomain == "" {
371+ return image
372+ }
373+ prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"
374+ if strings.HasPrefix(image, prefix) {
375+ return strings.TrimPrefix(image, prefix)
376+ }
377+ return image
378+}
379+
365380 // ListAttachments returns attachments for a domain.
366381 func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
367382 return s.getAttachmentsForDomain(ctx, domainName)
@@ -943,7 +958,7 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string
943958 return nil
944959 }
945960
946- return s.filterAttachments(containers, domainName)
961+ return s.filterAttachments(ctx, containers, domainName)
947962 }
948963
949964 // getAllAttachments fetches all containers once and returns attachments grouped by domain.
@@ -972,11 +987,17 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
972987 image = labelImage
973988 serviceName = extractServiceName(labelImage)
974989 }
990+ // Get the container's network for display in the routes table
991+ network := ""
992+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
993+ network = networkName
994+ }
975995 attachment := domain.Attachment{
976996 Name: serviceName,
977- Image: image,
997+ Image: s.stripRegistryPrefix(image),
978998 ContainerID: container.ID,
979999 Status: container.Status,
1000+ Network: network,
9801001 }
9811002 result[ownerDomain] = append(result[ownerDomain], attachment)
9821003 }
@@ -985,7 +1006,7 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
9851006 }
9861007
9871008 // filterAttachments extracts attachments for a specific domain from a container list.
988-func (s *Service) filterAttachments(containers []*domain.Container, domainName string) []domain.Attachment {
1009+func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
9891010 attachments := make([]domain.Attachment, 0)
9901011 for _, container := range containers {
9911012 if container.Labels == nil {
@@ -1003,11 +1024,17 @@ func (s *Service) filterAttachments(containers []*domain.Container, domainName s
10031024 image = labelImage
10041025 serviceName = extractServiceName(labelImage)
10051026 }
1027+ // Get the container's network for display in the routes table
1028+ network := ""
1029+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1030+ network = networkName
1031+ }
10061032 attachment := domain.Attachment{
10071033 Name: serviceName,
1008- Image: image,
1034+ Image: s.stripRegistryPrefix(image),
10091035 ContainerID: container.ID,
10101036 Status: container.Status,
1037+ Network: network,
10111038 }
10121039 attachments = append(attachments, attachment)
10131040 }
@@ -344,6 +344,8 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
344 image = labelImage344 image = labelImage
345 }345 }
346 }346 }
347+ // Strip registry domain prefix from image for cleaner display
348+ image = s.stripRegistryPrefix(image)
347 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {349 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
348 network = networkName350 network = networkName
349 }351 }
@@ -362,6 +364,19 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
362 return results364 return results
363 }365 }
364 366
367+// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
368+// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
369+func (s *Service) stripRegistryPrefix(image string) string {
370+ if s.config.RegistryDomain == "" {
371+ return image
372+ }
373+ prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"
374+ if strings.HasPrefix(image, prefix) {
375+ return strings.TrimPrefix(image, prefix)
376+ }
377+ return image
378+}
379+
365 // ListAttachments returns attachments for a domain.380 // ListAttachments returns attachments for a domain.
366 func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {381 func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
367 return s.getAttachmentsForDomain(ctx, domainName)382 return s.getAttachmentsForDomain(ctx, domainName)
@@ -943,7 +958,7 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string
943 return nil958 return nil
944 }959 }
945 960
946- return s.filterAttachments(containers, domainName)961+ return s.filterAttachments(ctx, containers, domainName)
947 }962 }
948 963
949 // getAllAttachments fetches all containers once and returns attachments grouped by domain.964 // getAllAttachments fetches all containers once and returns attachments grouped by domain.
@@ -972,11 +987,17 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
972 image = labelImage987 image = labelImage
973 serviceName = extractServiceName(labelImage)988 serviceName = extractServiceName(labelImage)
974 }989 }
990+ // Get the container's network for display in the routes table
991+ network := ""
992+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
993+ network = networkName
994+ }
975 attachment := domain.Attachment{995 attachment := domain.Attachment{
976 Name: serviceName,996 Name: serviceName,
977- Image: image,997+ Image: s.stripRegistryPrefix(image),
978 ContainerID: container.ID,998 ContainerID: container.ID,
979 Status: container.Status,999 Status: container.Status,
1000+ Network: network,
980 }1001 }
981 result[ownerDomain] = append(result[ownerDomain], attachment)1002 result[ownerDomain] = append(result[ownerDomain], attachment)
982 }1003 }
@@ -985,7 +1006,7 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
985 }1006 }
986 1007
987 // filterAttachments extracts attachments for a specific domain from a container list.1008 // filterAttachments extracts attachments for a specific domain from a container list.
988-func (s *Service) filterAttachments(containers []*domain.Container, domainName string) []domain.Attachment {1009+func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
989 attachments := make([]domain.Attachment, 0)1010 attachments := make([]domain.Attachment, 0)
990 for _, container := range containers {1011 for _, container := range containers {
991 if container.Labels == nil {1012 if container.Labels == nil {
@@ -1003,11 +1024,17 @@ func (s *Service) filterAttachments(containers []*domain.Container, domainName s
1003 image = labelImage1024 image = labelImage
1004 serviceName = extractServiceName(labelImage)1025 serviceName = extractServiceName(labelImage)
1005 }1026 }
1027+ // Get the container's network for display in the routes table
1028+ network := ""
1029+ if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1030+ network = networkName
1031+ }
1006 attachment := domain.Attachment{1032 attachment := domain.Attachment{
1007 Name: serviceName,1033 Name: serviceName,
1008- Image: image,1034+ Image: s.stripRegistryPrefix(image),
1009 ContainerID: container.ID,1035 ContainerID: container.ID,
1010 Status: container.Status,1036 Status: container.Status,
1037+ Network: network,
1011 }1038 }
1012 attachments = append(attachments, attachment)1039 attachments = append(attachments, attachment)
1013 }1040 }
M internal/usecase/container/service_test.go +62 -0
@@ -1056,3 +1056,65 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
10561056 assert.True(t, exists)
10571057 assert.Equal(t, "new-container", tracked.ID)
10581058 }
1059+
1060+func TestService_StripRegistryPrefix(t *testing.T) {
1061+ tests := []struct {
1062+ name string
1063+ registryDomain string
1064+ image string
1065+ expected string
1066+ }{
1067+ {
1068+ name: "strips registry prefix",
1069+ registryDomain: "reg.example.com",
1070+ image: "reg.example.com/myapp:latest",
1071+ expected: "myapp:latest",
1072+ },
1073+ {
1074+ name: "strips registry prefix with trailing slash in domain",
1075+ registryDomain: "reg.example.com/",
1076+ image: "reg.example.com/myapp:v1.0",
1077+ expected: "myapp:v1.0",
1078+ },
1079+ {
1080+ name: "preserves image without registry prefix",
1081+ registryDomain: "reg.example.com",
1082+ image: "nginx:latest",
1083+ expected: "nginx:latest",
1084+ },
1085+ {
1086+ name: "preserves image with different registry",
1087+ registryDomain: "reg.example.com",
1088+ image: "gcr.io/project/image:latest",
1089+ expected: "gcr.io/project/image:latest",
1090+ },
1091+ {
1092+ name: "handles empty registry domain",
1093+ registryDomain: "",
1094+ image: "reg.example.com/myapp:latest",
1095+ expected: "reg.example.com/myapp:latest",
1096+ },
1097+ {
1098+ name: "handles nested paths",
1099+ registryDomain: "reg.example.com",
1100+ image: "reg.example.com/org/repo/app:latest",
1101+ expected: "org/repo/app:latest",
1102+ },
1103+ }
1104+
1105+ for _, tt := range tests {
1106+ t.Run(tt.name, func(t *testing.T) {
1107+ runtime := mocks.NewMockContainerRuntime(t)
1108+ envLoader := mocks.NewMockEnvLoader(t)
1109+ eventBus := mocks.NewMockEventPublisher(t)
1110+
1111+ config := Config{
1112+ RegistryDomain: tt.registryDomain,
1113+ }
1114+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1115+
1116+ result := svc.stripRegistryPrefix(tt.image)
1117+ assert.Equal(t, tt.expected, result)
1118+ })
1119+ }
1120+}
@@ -1056,3 +1056,65 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1056 assert.True(t, exists)1056 assert.True(t, exists)
1057 assert.Equal(t, "new-container", tracked.ID)1057 assert.Equal(t, "new-container", tracked.ID)
1058 }1058 }
1059+
1060+func TestService_StripRegistryPrefix(t *testing.T) {
1061+ tests := []struct {
1062+ name string
1063+ registryDomain string
1064+ image string
1065+ expected string
1066+ }{
1067+ {
1068+ name: "strips registry prefix",
1069+ registryDomain: "reg.example.com",
1070+ image: "reg.example.com/myapp:latest",
1071+ expected: "myapp:latest",
1072+ },
1073+ {
1074+ name: "strips registry prefix with trailing slash in domain",
1075+ registryDomain: "reg.example.com/",
1076+ image: "reg.example.com/myapp:v1.0",
1077+ expected: "myapp:v1.0",
1078+ },
1079+ {
1080+ name: "preserves image without registry prefix",
1081+ registryDomain: "reg.example.com",
1082+ image: "nginx:latest",
1083+ expected: "nginx:latest",
1084+ },
1085+ {
1086+ name: "preserves image with different registry",
1087+ registryDomain: "reg.example.com",
1088+ image: "gcr.io/project/image:latest",
1089+ expected: "gcr.io/project/image:latest",
1090+ },
1091+ {
1092+ name: "handles empty registry domain",
1093+ registryDomain: "",
1094+ image: "reg.example.com/myapp:latest",
1095+ expected: "reg.example.com/myapp:latest",
1096+ },
1097+ {
1098+ name: "handles nested paths",
1099+ registryDomain: "reg.example.com",
1100+ image: "reg.example.com/org/repo/app:latest",
1101+ expected: "org/repo/app:latest",
1102+ },
1103+ }
1104+
1105+ for _, tt := range tests {
1106+ t.Run(tt.name, func(t *testing.T) {
1107+ runtime := mocks.NewMockContainerRuntime(t)
1108+ envLoader := mocks.NewMockEnvLoader(t)
1109+ eventBus := mocks.NewMockEventPublisher(t)
1110+
1111+ config := Config{
1112+ RegistryDomain: tt.registryDomain,
1113+ }
1114+ svc := NewService(runtime, envLoader, eventBus, nil, config)
1115+
1116+ result := svc.stripRegistryPrefix(tt.image)
1117+ assert.Equal(t, tt.expected, result)
1118+ })
1119+ }
1120+}

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