Merge pull request #31 from bnema/fix/cli-routes-display
fix: improve CLI routes list display
10 files · +231 -74
M internal/adapters/dto/admin_route.go +1 -0
| @@ -24,6 +24,7 @@ type Attachment struct { | |||
| 24 | 24 | Image string `json:"image"` | |
| 25 | 25 | ContainerID string `json:"container_id"` | |
| 26 | 26 | Status string `json:"status"` | |
| 27 | + | Network string `json:"network"` | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | // 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 { | |||
| 170 | 170 | rows = append(rows, []string{route.Domain, displayImage, displayNetwork, containerBadge, httpStatus}) | |
| 171 | 171 | ||
| 172 | 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 | 175 | if i == len(route.Attachments)-1 { | |
| 175 | - | prefix = "`-" | |
| 176 | + | prefix = styles.IconTreeLast + styles.IconTreeLine | |
| 176 | 177 | } | |
| 177 | 178 | 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]) + "..." | |
| 180 | 182 | } | |
| 181 | 183 | attachmentStatus := components.ContainerStatusBadge(attachment.Status) | |
| 182 | 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 | ||
| @@ -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.Name | 178 | 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 ( | |||
| 19 | 19 | StatusPending | |
| 20 | 20 | StatusRunning | |
| 21 | 21 | StatusStopped | |
| 22 | + | StatusPaused | |
| 23 | + | StatusRestarting | |
| 24 | + | StatusExited | |
| 25 | + | StatusUnknown | |
| 22 | 26 | ) | |
| 23 | 27 | ||
| 24 | 28 | // StatusConfig holds configuration for status rendering. | |
| @@ -54,13 +58,29 @@ var DefaultStatusConfigs = map[Status]StatusConfig{ | |||
| 54 | 58 | Style: styles.Theme.Muted, | |
| 55 | 59 | }, | |
| 56 | 60 | StatusRunning: { | |
| 57 | - | Icon: styles.IconSuccess, | |
| 61 | + | Icon: styles.IconRunning, | |
| 58 | 62 | Style: styles.Theme.Success, | |
| 59 | 63 | }, | |
| 60 | 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 | 78 | Style: styles.Theme.Error, | |
| 63 | 79 | }, | |
| 80 | + | StatusUnknown: { | |
| 81 | + | Icon: styles.IconUnknown, | |
| 82 | + | Style: styles.Theme.Muted, | |
| 83 | + | }, | |
| 64 | 84 | } | |
| 65 | 85 | ||
| 66 | 86 | // RenderStatus renders a status with icon and optional label. | |
| @@ -72,22 +92,26 @@ func RenderStatus(status Status, label string) string { | |||
| 72 | 92 | return config.Style.Render(config.Icon + " " + label) | |
| 73 | 93 | } | |
| 74 | 94 | ||
| 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] | |
| 77 | 99 | var badgeStyle lipgloss.Style | |
| 78 | 100 | switch status { | |
| 79 | 101 | case StatusSuccess, StatusRunning: | |
| 80 | 102 | badgeStyle = styles.Theme.BadgeSuccess | |
| 81 | - | case StatusError, StatusStopped: | |
| 103 | + | case StatusError, StatusStopped, StatusExited: | |
| 82 | 104 | badgeStyle = styles.Theme.BadgeError | |
| 83 | - | case StatusWarning: | |
| 105 | + | case StatusWarning, StatusPaused, StatusRestarting: | |
| 84 | 106 | badgeStyle = styles.Theme.BadgeWarning | |
| 85 | 107 | case StatusPending: | |
| 86 | 108 | badgeStyle = styles.Theme.BadgePending | |
| 109 | + | case StatusUnknown: | |
| 110 | + | badgeStyle = styles.Theme.Muted | |
| 87 | 111 | default: | |
| 88 | 112 | badgeStyle = styles.Theme.BadgeInfo | |
| 89 | 113 | } | |
| 90 | - | return badgeStyle.Render(label) | |
| 114 | + | return badgeStyle.Render(config.Icon) | |
| 91 | 115 | } | |
| 92 | 116 | ||
| 93 | 117 | // ParseStatus converts a string status to Status type. | |
| @@ -101,14 +125,22 @@ func ParseStatus(s string) Status { | |||
| 101 | 125 | return StatusWarning | |
| 102 | 126 | case "info": | |
| 103 | 127 | return StatusInfo | |
| 104 | - | case "pending", "starting", "waiting": | |
| 128 | + | case "pending", "starting", "waiting", "created": | |
| 105 | 129 | return StatusPending | |
| 106 | 130 | case "running", "up": | |
| 107 | 131 | return StatusRunning | |
| 108 | - | case "stopped", "down", "exited": | |
| 132 | + | case "stopped", "down": | |
| 109 | 133 | 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 | 142 | default: | |
| 111 | - | return StatusInfo | |
| 143 | + | return StatusUnknown | |
| 112 | 144 | } | |
| 113 | 145 | } | |
| 114 | 146 | ||
| @@ -140,7 +172,7 @@ func NewStatusBadge(status Status, label string) StatusIndicator { | |||
| 140 | 172 | // View renders the status indicator. | |
| 141 | 173 | func (s StatusIndicator) View() string { | |
| 142 | 174 | if s.Badge { | |
| 143 | - | return RenderStatusBadge(s.Status, s.Label) | |
| 175 | + | return RenderStatusBadge(s.Status) | |
| 144 | 176 | } | |
| 145 | 177 | return RenderStatus(s.Status, s.Label) | |
| 146 | 178 | } | |
| @@ -154,5 +186,5 @@ func ContainerStatusIndicator(status string) string { | |||
| 154 | 186 | // ContainerStatusBadge renders container status as a badge. | |
| 155 | 187 | func ContainerStatusBadge(status string) string { | |
| 156 | 188 | parsed := ParseStatus(status) | |
| 157 | - | return RenderStatusBadge(parsed, status) | |
| 189 | + | return RenderStatusBadge(parsed) | |
| 158 | 190 | } | |
| @@ -19,6 +19,10 @@ const ( | |||||
| 19 | StatusPending | 19 | StatusPending | ||
| 20 | StatusRunning | 20 | StatusRunning | ||
| 21 | StatusStopped | 21 | 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.Style | 99 | var badgeStyle lipgloss.Style | ||
| 78 | switch status { | 100 | switch status { | ||
| 79 | case StatusSuccess, StatusRunning: | 101 | case StatusSuccess, StatusRunning: | ||
| 80 | badgeStyle = styles.Theme.BadgeSuccess | 102 | badgeStyle = styles.Theme.BadgeSuccess | ||
| 81 | - | case StatusError, StatusStopped: | 103 | + | case StatusError, StatusStopped, StatusExited: |
| 82 | badgeStyle = styles.Theme.BadgeError | 104 | badgeStyle = styles.Theme.BadgeError | ||
| 83 | - | case StatusWarning: | 105 | + | case StatusWarning, StatusPaused, StatusRestarting: |
| 84 | badgeStyle = styles.Theme.BadgeWarning | 106 | badgeStyle = styles.Theme.BadgeWarning | ||
| 85 | case StatusPending: | 107 | case StatusPending: | ||
| 86 | badgeStyle = styles.Theme.BadgePending | 108 | badgeStyle = styles.Theme.BadgePending | ||
| 109 | + | case StatusUnknown: | |||
| 110 | + | badgeStyle = styles.Theme.Muted | |||
| 87 | default: | 111 | default: | ||
| 88 | badgeStyle = styles.Theme.BadgeInfo | 112 | 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 StatusWarning | 125 | return StatusWarning | ||
| 102 | case "info": | 126 | case "info": | ||
| 103 | return StatusInfo | 127 | return StatusInfo | ||
| 104 | - | case "pending", "starting", "waiting": | 128 | + | case "pending", "starting", "waiting", "created": |
| 105 | return StatusPending | 129 | return StatusPending | ||
| 106 | case "running", "up": | 130 | case "running", "up": | ||
| 107 | return StatusRunning | 131 | return StatusRunning | ||
| 108 | - | case "stopped", "down", "exited": | 132 | + | case "stopped", "down": |
| 109 | return StatusStopped | 133 | 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 StatusInfo | 143 | + | 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 { | |||
| 45 | 45 | Foreground(styles.ColorText). | |
| 46 | 46 | Padding(0, 1), | |
| 47 | 47 | evenStyle: lipgloss.NewStyle(). | |
| 48 | - | Foreground(styles.ColorTextMuted). | |
| 48 | + | Foreground(styles.ColorText). | |
| 49 | 49 | Padding(0, 1), | |
| 50 | 50 | } | |
| 51 | 51 | ||
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | // Nerd Font icons for terminal UI. | |
| 4 | 4 | // These icons require a Nerd Font compatible terminal font. | |
| 5 | + | // All icons are defined using Unicode escape sequences for portability. | |
| 5 | 6 | // Fallback ASCII alternatives are provided where practical. | |
| 6 | 7 | const ( | |
| 7 | 8 | // 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 | |
| 13 | 22 | ||
| 14 | 23 | // 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 | |
| 23 | 33 | ||
| 24 | 34 | // 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 | |
| 31 | 41 | ||
| 32 | 42 | // 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 | |
| 38 | 48 | ||
| 39 | 49 | // 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" // │ | |
| 47 | 66 | ||
| 48 | 67 | // Spinners (individual frames) | |
| 49 | - | SpinnerDot = "⣾⣽⣻⢿⡿⣟⣯⣷" | |
| 68 | + | SpinnerDot = "\u28fe\u28fd\u28fb\u28bf\u287f\u28df\u28ef\u28f7" | |
| 50 | 69 | SpinnerLine = "|/-\\" | |
| 51 | - | SpinnerCircle = "◐◓◑◒" | |
| 52 | - | SpinnerBrail = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" | |
| 70 | + | SpinnerCircle = "\u25d0\u25d3\u25d1\u25d2" | |
| 71 | + | SpinnerBrail = "\u280b\u2819\u2839\u2838\u283c\u2834\u2826\u2827\u2807\u280f" | |
| 53 | 72 | ||
| 54 | 73 | // 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" // ┼ | |
| 66 | 85 | ||
| 67 | 86 | // Gordon branding | |
| 68 | - | IconGordon = "" // nf-fa-cube (U+F1B2) | |
| 87 | + | IconGordon = "\uf1b2" // nf-fa-cube | |
| 69 | 88 | ) | |
| 70 | 89 | ||
| 71 | 90 | // ASCII fallback alternatives for terminals without Nerd Fonts. | |
| 72 | 91 | 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 = "`-" | |
| 79 | 102 | ) | |
| @@ -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 indicators | 8 | // 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 | // Objects | 23 | // 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 | // Actions | 34 | // 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 | // Navigation | 42 | // 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 elements | 49 | // UI elements | ||
| 40 | - | IconBullet = "▸" // Simple triangle bullet | 50 | + | IconBullet = "\u25b8" // ▸ Simple triangle bullet |
| 41 | - | IconDot = "●" // Filled circle | 51 | + | IconDot = "\u25cf" // ● Filled circle |
| 42 | - | IconDotEmpty = "○" // Empty circle | 52 | + | 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 decorations | 73 | // 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 branding | 86 | // 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 { | |||
| 47 | 47 | Image: a.Image, | |
| 48 | 48 | ContainerID: a.ContainerID, | |
| 49 | 49 | Status: a.Status, | |
| 50 | + | Network: a.Network, | |
| 50 | 51 | } | |
| 51 | 52 | } | |
| 52 | 53 | ||
| @@ -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 { | |||
| 27 | 27 | Image string | |
| 28 | 28 | ContainerID string | |
| 29 | 29 | Status string | |
| 30 | + | Network string | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | // RouteInfo combines route configuration with runtime state. | |
| @@ -27,6 +27,7 @@ type Attachment struct { | |||||
| 27 | Image string | 27 | Image string | ||
| 28 | ContainerID string | 28 | ContainerID string | ||
| 29 | Status string | 29 | 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) { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 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 | 31 | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{ | |
| 29 | 32 | { | |
| 30 | 33 | ID: "attach-1", | |
| @@ -71,6 +74,7 @@ func TestService_ListRoutesWithDetails(t *testing.T) { | |||
| 71 | 74 | assert.Equal(t, "postgres", results[0].Attachments[0].Name) | |
| 72 | 75 | assert.Equal(t, "postgres:15", results[0].Attachments[0].Image) | |
| 73 | 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 | 142 | }, nil) | |
| 143 | + | runtime.EXPECT().GetContainerNetwork(mock.Anything, "attach-1").Return("gordon-app-example-com", nil) | |
| 139 | 144 | ||
| 140 | 145 | attachments := svc.ListAttachments(ctx, "app.example.com") | |
| 141 | 146 | ||
| @@ -143,6 +148,7 @@ func TestService_ListAttachments(t *testing.T) { | |||
| 143 | 148 | assert.Equal(t, "postgres", attachments[0].Name) | |
| 144 | 149 | assert.Equal(t, "postgres:15", attachments[0].Image) | |
| 145 | 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 | 154 | 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 | |||
| 344 | 344 | image = labelImage | |
| 345 | 345 | } | |
| 346 | 346 | } | |
| 347 | + | // Strip registry domain prefix from image for cleaner display | |
| 348 | + | image = s.stripRegistryPrefix(image) | |
| 347 | 349 | if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil { | |
| 348 | 350 | network = networkName | |
| 349 | 351 | } | |
| @@ -362,6 +364,19 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||
| 362 | 364 | 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 | 380 | // ListAttachments returns attachments for a domain. | |
| 366 | 381 | func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment { | |
| 367 | 382 | return s.getAttachmentsForDomain(ctx, domainName) | |
| @@ -943,7 +958,7 @@ func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string | |||
| 943 | 958 | return nil | |
| 944 | 959 | } | |
| 945 | 960 | ||
| 946 | - | return s.filterAttachments(containers, domainName) | |
| 961 | + | return s.filterAttachments(ctx, containers, domainName) | |
| 947 | 962 | } | |
| 948 | 963 | ||
| 949 | 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 | 987 | image = labelImage | |
| 973 | 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 | 995 | attachment := domain.Attachment{ | |
| 976 | 996 | Name: serviceName, | |
| 977 | - | Image: image, | |
| 997 | + | Image: s.stripRegistryPrefix(image), | |
| 978 | 998 | ContainerID: container.ID, | |
| 979 | 999 | Status: container.Status, | |
| 1000 | + | Network: network, | |
| 980 | 1001 | } | |
| 981 | 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 | 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 | 1010 | attachments := make([]domain.Attachment, 0) | |
| 990 | 1011 | for _, container := range containers { | |
| 991 | 1012 | if container.Labels == nil { | |
| @@ -1003,11 +1024,17 @@ func (s *Service) filterAttachments(containers []*domain.Container, domainName s | |||
| 1003 | 1024 | image = labelImage | |
| 1004 | 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 | 1032 | attachment := domain.Attachment{ | |
| 1007 | 1033 | Name: serviceName, | |
| 1008 | - | Image: image, | |
| 1034 | + | Image: s.stripRegistryPrefix(image), | |
| 1009 | 1035 | ContainerID: container.ID, | |
| 1010 | 1036 | Status: container.Status, | |
| 1037 | + | Network: network, | |
| 1011 | 1038 | } | |
| 1012 | 1039 | attachments = append(attachments, attachment) | |
| 1013 | 1040 | } | |
| @@ -344,6 +344,8 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||||
| 344 | image = labelImage | 344 | 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 = networkName | 350 | network = networkName | ||
| 349 | } | 351 | } | ||
| @@ -362,6 +364,19 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||||
| 362 | return results | 364 | 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 nil | 958 | 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 = labelImage | 987 | 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 = labelImage | 1024 | 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) { | |||
| 1056 | 1056 | assert.True(t, exists) | |
| 1057 | 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 | + | } | |
| @@ -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 | + | } | |||