detached 4106bb1

Merge pull request #199 from bnema/fix/local-routes-status-sync

Fix route status for configured local and remote routes
commit
4106bb161e4900357016cb068735a79f3e7e0995
author
bnema · · 2026-05-17 05:48 UTC
committer
GitHub · · 2026-05-17 05:48 UTC
merge of
bd898f0 + dede852 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +174 -39
M internal/adapters/in/cli/controlplane_local.go +28 -1
@@ -57,7 +57,14 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
5757
5858 func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
5959 if l.containerSvc != nil {
60- return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil
60+ if err := l.containerSvc.SyncContainers(ctx); err != nil {
61+ return nil, err
62+ }
63+ detailed := l.containerSvc.ListRoutesWithDetails(ctx)
64+ if l.configSvc == nil {
65+ return toRemoteRouteInfos(detailed), nil
66+ }
67+ return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil
6168 }
6269
6370 if l.configSvc == nil {
@@ -606,6 +613,26 @@ func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePrun
606613 }, nil
607614 }
608615
616+func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo {
617+ detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
618+ for _, info := range detailed {
619+ detailedByDomain[info.Domain] = info
620+ }
621+
622+ merged := make([]domain.RouteInfo, 0, len(configured))
623+ for _, route := range configured {
624+ info, ok := detailedByDomain[route.Domain]
625+ if !ok {
626+ info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
627+ } else if info.Image == "" {
628+ info.Image = route.Image
629+ }
630+ merged = append(merged, info)
631+ }
632+
633+ return toRemoteRouteInfos(merged)
634+}
635+
609636 func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
610637 out := make([]remote.RouteInfo, 0, len(routes))
611638 for _, route := range routes {
@@ -57,7 +57,14 @@ func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
57 57
58 func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {58 func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
59 if l.containerSvc != nil {59 if l.containerSvc != nil {
60- return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil60+ if err := l.containerSvc.SyncContainers(ctx); err != nil {
61+ return nil, err
62+ }
63+ detailed := l.containerSvc.ListRoutesWithDetails(ctx)
64+ if l.configSvc == nil {
65+ return toRemoteRouteInfos(detailed), nil
66+ }
67+ return mergeConfiguredRemoteRouteInfos(l.configSvc.GetRoutes(ctx), detailed), nil
61 }68 }
62 69
63 if l.configSvc == nil {70 if l.configSvc == nil {
@@ -606,6 +613,26 @@ func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePrun
606 }, nil613 }, nil
607 }614 }
608 615
616+func mergeConfiguredRemoteRouteInfos(configured []domain.Route, detailed []domain.RouteInfo) []remote.RouteInfo {
617+ detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
618+ for _, info := range detailed {
619+ detailedByDomain[info.Domain] = info
620+ }
621+
622+ merged := make([]domain.RouteInfo, 0, len(configured))
623+ for _, route := range configured {
624+ info, ok := detailedByDomain[route.Domain]
625+ if !ok {
626+ info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
627+ } else if info.Image == "" {
628+ info.Image = route.Image
629+ }
630+ merged = append(merged, info)
631+ }
632+
633+ return toRemoteRouteInfos(merged)
634+}
635+
609 func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {636 func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
610 out := make([]remote.RouteInfo, 0, len(routes))637 out := make([]remote.RouteInfo, 0, len(routes))
611 for _, route := range routes {638 for _, route := range routes {
M internal/adapters/in/cli/controlplane_local_test.go +46 -0
@@ -123,6 +123,52 @@ func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
123123 require.Equal(t, "app.local", result.Domain)
124124 }
125125
126+func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) {
127+ t.Parallel()
128+
129+ containerSvc := inmocks.NewMockContainerService(t)
130+ syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
131+ listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{
132+ Domain: "app.local",
133+ Image: "repo/app:latest",
134+ ContainerID: "container-1",
135+ ContainerStatus: "running",
136+ }}).Once()
137+ mock.InOrder(syncCall, listCall)
138+
139+ cp := &localControlPlane{containerSvc: containerSvc}
140+ routes, err := cp.ListRoutesWithDetails(context.Background())
141+ require.NoError(t, err)
142+ require.Len(t, routes, 1)
143+ require.Equal(t, "app.local", routes[0].Domain)
144+ require.Equal(t, "repo/app:latest", routes[0].Image)
145+ require.Equal(t, "container-1", routes[0].ContainerID)
146+ require.Equal(t, "running", routes[0].ContainerStatus)
147+}
148+
149+func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) {
150+ t.Parallel()
151+
152+ configSvc := inmocks.NewMockConfigService(t)
153+ containerSvc := inmocks.NewMockContainerService(t)
154+
155+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
156+ Domain: "app.local",
157+ Image: "repo/app:latest",
158+ }}).Once()
159+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
160+ containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
161+
162+ cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
163+ routes, err := cp.ListRoutesWithDetails(context.Background())
164+ require.NoError(t, err)
165+ require.Len(t, routes, 1)
166+ require.Equal(t, "app.local", routes[0].Domain)
167+ require.Equal(t, "repo/app:latest", routes[0].Image)
168+ require.Empty(t, routes[0].ContainerID)
169+ require.Empty(t, routes[0].ContainerStatus)
170+}
171+
126172 func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {
127173 t.Parallel()
128174
@@ -123,6 +123,52 @@ func TestLocalControlPlane_RestartWithAttachments(t *testing.T) {
123 require.Equal(t, "app.local", result.Domain)123 require.Equal(t, "app.local", result.Domain)
124 }124 }
125 125
126+func TestLocalControlPlane_ListRoutesWithDetailsSyncsBeforeListing(t *testing.T) {
127+ t.Parallel()
128+
129+ containerSvc := inmocks.NewMockContainerService(t)
130+ syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
131+ listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return([]domain.RouteInfo{{
132+ Domain: "app.local",
133+ Image: "repo/app:latest",
134+ ContainerID: "container-1",
135+ ContainerStatus: "running",
136+ }}).Once()
137+ mock.InOrder(syncCall, listCall)
138+
139+ cp := &localControlPlane{containerSvc: containerSvc}
140+ routes, err := cp.ListRoutesWithDetails(context.Background())
141+ require.NoError(t, err)
142+ require.Len(t, routes, 1)
143+ require.Equal(t, "app.local", routes[0].Domain)
144+ require.Equal(t, "repo/app:latest", routes[0].Image)
145+ require.Equal(t, "container-1", routes[0].ContainerID)
146+ require.Equal(t, "running", routes[0].ContainerStatus)
147+}
148+
149+func TestLocalControlPlane_ListRoutesWithDetails_IncludesConfiguredRouteWithoutRuntimeDetail(t *testing.T) {
150+ t.Parallel()
151+
152+ configSvc := inmocks.NewMockConfigService(t)
153+ containerSvc := inmocks.NewMockContainerService(t)
154+
155+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
156+ Domain: "app.local",
157+ Image: "repo/app:latest",
158+ }}).Once()
159+ containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
160+ containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
161+
162+ cp := &localControlPlane{configSvc: configSvc, containerSvc: containerSvc}
163+ routes, err := cp.ListRoutesWithDetails(context.Background())
164+ require.NoError(t, err)
165+ require.Len(t, routes, 1)
166+ require.Equal(t, "app.local", routes[0].Domain)
167+ require.Equal(t, "repo/app:latest", routes[0].Image)
168+ require.Empty(t, routes[0].ContainerID)
169+ require.Empty(t, routes[0].ContainerStatus)
170+}
171+
126 func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {172 func TestLocalControlPlane_GetTLSStatusWithoutService(t *testing.T) {
127 t.Parallel()173 t.Parallel()
128 174
M internal/adapters/in/cli/routes.go +35 -37
@@ -835,47 +835,45 @@ func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remot
835835 func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
836836 items := make([]routeStatusItem, 0, len(routes))
837837 for _, route := range routes {
838- item := routeStatusItem{
839- Domain: route.Domain,
840- Image: route.Image,
841- ContainerID: route.ContainerID,
842- ContainerStatus: route.ContainerStatus,
843- Network: route.Network,
844- }
845- if item.ContainerStatus == "" {
846- if routeHealth := health[route.Domain]; routeHealth != nil {
847- item.ContainerStatus = routeHealth.ContainerStatus
848- item.HTTPStatus = routeHealth.HTTPStatus
849- item.HealthError = routeHealth.Error
850- }
851- }
852- if item.ContainerStatus == "" {
853- item.ContainerStatus = "unknown"
854- }
855- if item.HTTPStatus == 0 {
856- if routeHealth := health[route.Domain]; routeHealth != nil {
857- item.HTTPStatus = routeHealth.HTTPStatus
858- item.HealthError = routeHealth.Error
859- }
860- }
861- if item.HealthError == "" {
862- if routeHealth := health[route.Domain]; routeHealth != nil {
863- item.HealthError = routeHealth.Error
864- }
865- }
866- item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867- for _, attachment := range route.Attachments {
868- status := attachment.Status
869- if status == "" {
870- status = "unknown"
871- }
872- item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873- }
874- items = append(items, item)
838+ items = append(items, routeStatusItemFromInfo(route, health[route.Domain]))
875839 }
876840 return items
877841 }
878842
843+func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem {
844+ item := routeStatusItem{
845+ Domain: route.Domain,
846+ Image: route.Image,
847+ ContainerID: route.ContainerID,
848+ ContainerStatus: route.ContainerStatus,
849+ Network: route.Network,
850+ }
851+ if item.ContainerStatus == "" && routeHealth != nil {
852+ item.ContainerStatus = routeHealth.ContainerStatus
853+ item.HTTPStatus = routeHealth.HTTPStatus
854+ item.HealthError = routeHealth.Error
855+ }
856+ if item.ContainerStatus == "" {
857+ item.ContainerStatus = "unknown"
858+ }
859+ if item.HTTPStatus == 0 && routeHealth != nil {
860+ item.HTTPStatus = routeHealth.HTTPStatus
861+ item.HealthError = routeHealth.Error
862+ }
863+ if item.HealthError == "" && routeHealth != nil {
864+ item.HealthError = routeHealth.Error
865+ }
866+ item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867+ for _, attachment := range route.Attachments {
868+ status := attachment.Status
869+ if status == "" {
870+ status = "unknown"
871+ }
872+ item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873+ }
874+ return item
875+}
876+
879877 func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
880878 items := make([]routeListItem, 0, len(routes))
881879 for _, route := range routes {
@@ -835,47 +835,45 @@ func loadRoutesStatusRemoteSection(ctx context.Context, name string, entry remot
835 func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {835 func routeStatusItemsFromInfos(routes []remote.RouteInfo, health map[string]*remote.RouteHealth) []routeStatusItem {
836 items := make([]routeStatusItem, 0, len(routes))836 items := make([]routeStatusItem, 0, len(routes))
837 for _, route := range routes {837 for _, route := range routes {
838- item := routeStatusItem{838+ items = append(items, routeStatusItemFromInfo(route, health[route.Domain]))
839- Domain: route.Domain,
840- Image: route.Image,
841- ContainerID: route.ContainerID,
842- ContainerStatus: route.ContainerStatus,
843- Network: route.Network,
844- }
845- if item.ContainerStatus == "" {
846- if routeHealth := health[route.Domain]; routeHealth != nil {
847- item.ContainerStatus = routeHealth.ContainerStatus
848- item.HTTPStatus = routeHealth.HTTPStatus
849- item.HealthError = routeHealth.Error
850- }
851- }
852- if item.ContainerStatus == "" {
853- item.ContainerStatus = "unknown"
854- }
855- if item.HTTPStatus == 0 {
856- if routeHealth := health[route.Domain]; routeHealth != nil {
857- item.HTTPStatus = routeHealth.HTTPStatus
858- item.HealthError = routeHealth.Error
859- }
860- }
861- if item.HealthError == "" {
862- if routeHealth := health[route.Domain]; routeHealth != nil {
863- item.HealthError = routeHealth.Error
864- }
865- }
866- item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867- for _, attachment := range route.Attachments {
868- status := attachment.Status
869- if status == "" {
870- status = "unknown"
871- }
872- item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873- }
874- items = append(items, item)
875 }839 }
876 return items840 return items
877 }841 }
878 842
843+func routeStatusItemFromInfo(route remote.RouteInfo, routeHealth *remote.RouteHealth) routeStatusItem {
844+ item := routeStatusItem{
845+ Domain: route.Domain,
846+ Image: route.Image,
847+ ContainerID: route.ContainerID,
848+ ContainerStatus: route.ContainerStatus,
849+ Network: route.Network,
850+ }
851+ if item.ContainerStatus == "" && routeHealth != nil {
852+ item.ContainerStatus = routeHealth.ContainerStatus
853+ item.HTTPStatus = routeHealth.HTTPStatus
854+ item.HealthError = routeHealth.Error
855+ }
856+ if item.ContainerStatus == "" {
857+ item.ContainerStatus = "unknown"
858+ }
859+ if item.HTTPStatus == 0 && routeHealth != nil {
860+ item.HTTPStatus = routeHealth.HTTPStatus
861+ item.HealthError = routeHealth.Error
862+ }
863+ if item.HealthError == "" && routeHealth != nil {
864+ item.HealthError = routeHealth.Error
865+ }
866+ item.Attachments = make([]routeStatusAttachment, 0, len(route.Attachments))
867+ for _, attachment := range route.Attachments {
868+ status := attachment.Status
869+ if status == "" {
870+ status = "unknown"
871+ }
872+ item.Attachments = append(item.Attachments, routeStatusAttachment{Name: attachment.Name, Image: attachment.Image, Status: status})
873+ }
874+ return item
875+}
876+
879 func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {877 func routeListItemsFromInfos(routes []remote.RouteInfo) []routeListItem {
880 items := make([]routeListItem, 0, len(routes))878 items := make([]routeListItem, 0, len(routes))
881 for _, route := range routes {879 for _, route := range routes {
M internal/adapters/in/http/admin/handler.go +26 -1
@@ -97,6 +97,25 @@ func toRouteResponse(r domain.Route) dto.Route {
9797 }
9898 }
9999
100+func mergeConfiguredRouteDetails(configured []domain.Route, detailed []domain.RouteInfo) []domain.RouteInfo {
101+ detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
102+ for _, info := range detailed {
103+ detailedByDomain[info.Domain] = info
104+ }
105+
106+ merged := make([]domain.RouteInfo, 0, len(configured))
107+ for _, route := range configured {
108+ info, ok := detailedByDomain[route.Domain]
109+ if !ok {
110+ info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
111+ } else if info.Image == "" {
112+ info.Image = route.Image
113+ }
114+ merged = append(merged, info)
115+ }
116+ return merged
117+}
118+
100119 func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
101120 var startedAt *time.Time
102121 if !job.StartedAt.IsZero() {
@@ -527,7 +546,13 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
527546
528547 if routeDomain == "" {
529548 if r.URL.Query().Get("detailed") == "true" {
530- routes := h.containerSvc.ListRoutesWithDetails(ctx)
549+ if err := h.containerSvc.SyncContainers(ctx); err != nil {
550+ h.log.Error().Err(err).Msg("failed to sync containers before detailed route listing")
551+ h.sendError(w, http.StatusInternalServerError, "failed to list routes")
552+ return
553+ }
554+ configured := h.configSvc.GetRoutes(ctx)
555+ routes := mergeConfiguredRouteDetails(configured, h.containerSvc.ListRoutesWithDetails(ctx))
531556 response := make([]routeInfoResponse, 0, len(routes))
532557 for _, route := range routes {
533558 response = append(response, toRouteInfoResponse(route))
@@ -97,6 +97,25 @@ func toRouteResponse(r domain.Route) dto.Route {
97 }97 }
98 }98 }
99 99
100+func mergeConfiguredRouteDetails(configured []domain.Route, detailed []domain.RouteInfo) []domain.RouteInfo {
101+ detailedByDomain := make(map[string]domain.RouteInfo, len(detailed))
102+ for _, info := range detailed {
103+ detailedByDomain[info.Domain] = info
104+ }
105+
106+ merged := make([]domain.RouteInfo, 0, len(configured))
107+ for _, route := range configured {
108+ info, ok := detailedByDomain[route.Domain]
109+ if !ok {
110+ info = domain.RouteInfo{Domain: route.Domain, Image: route.Image}
111+ } else if info.Image == "" {
112+ info.Image = route.Image
113+ }
114+ merged = append(merged, info)
115+ }
116+ return merged
117+}
118+
100 func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {119 func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
101 var startedAt *time.Time120 var startedAt *time.Time
102 if !job.StartedAt.IsZero() {121 if !job.StartedAt.IsZero() {
@@ -527,7 +546,13 @@ func (h *Handler) handleRoutesGet(w http.ResponseWriter, r *http.Request, routeD
527 546
528 if routeDomain == "" {547 if routeDomain == "" {
529 if r.URL.Query().Get("detailed") == "true" {548 if r.URL.Query().Get("detailed") == "true" {
530- routes := h.containerSvc.ListRoutesWithDetails(ctx)549+ if err := h.containerSvc.SyncContainers(ctx); err != nil {
550+ h.log.Error().Err(err).Msg("failed to sync containers before detailed route listing")
551+ h.sendError(w, http.StatusInternalServerError, "failed to list routes")
552+ return
553+ }
554+ configured := h.configSvc.GetRoutes(ctx)
555+ routes := mergeConfiguredRouteDetails(configured, h.containerSvc.ListRoutesWithDetails(ctx))
531 response := make([]routeInfoResponse, 0, len(routes))556 response := make([]routeInfoResponse, 0, len(routes))
532 for _, route := range routes {557 for _, route := range routes {
533 response = append(response, toRouteInfoResponse(route))558 response = append(response, toRouteInfoResponse(route))
M internal/adapters/in/http/admin/handler_test.go +39 -0
@@ -1357,6 +1357,45 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
13571357 assert.Len(t, response.Routes, 2)
13581358 }
13591359
1360+func TestHandler_RoutesGet_DetailedSyncsAndIncludesConfiguredRouteWithoutContainer(t *testing.T) {
1361+ configSvc := inmocks.NewMockConfigService(t)
1362+ authSvc := inmocks.NewMockAuthService(t)
1363+ containerSvc := inmocks.NewMockContainerService(t)
1364+ secretSvc := inmocks.NewMockSecretService(t)
1365+
1366+ handler := newTestHandler(t, func(d *HandlerDeps) {
1367+ d.ConfigSvc = configSvc
1368+ d.AuthSvc = authSvc
1369+ d.ContainerSvc = containerSvc
1370+ d.SecretSvc = secretSvc
1371+ })
1372+
1373+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
1374+ Domain: "app.example.com",
1375+ Image: "app:latest",
1376+ }}).Once()
1377+ syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
1378+ listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
1379+ mock.InOrder(syncCall, listCall)
1380+
1381+ req := httptest.NewRequest("GET", "/admin/routes?detailed=true", nil)
1382+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
1383+ rec := httptest.NewRecorder()
1384+
1385+ handler.ServeHTTP(rec, req)
1386+
1387+ assert.Equal(t, http.StatusOK, rec.Code)
1388+
1389+ var response dto.RoutesDetailResponse
1390+ err := json.NewDecoder(rec.Body).Decode(&response)
1391+ require.NoError(t, err)
1392+ require.Len(t, response.Routes, 1)
1393+ assert.Equal(t, "app.example.com", response.Routes[0].Domain)
1394+ assert.Equal(t, "app:latest", response.Routes[0].Image)
1395+ assert.Empty(t, response.Routes[0].ContainerID)
1396+ assert.Empty(t, response.Routes[0].ContainerStatus)
1397+}
1398+
13601399 func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
13611400 configSvc := inmocks.NewMockConfigService(t)
13621401 authSvc := inmocks.NewMockAuthService(t)
@@ -1357,6 +1357,45 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
1357 assert.Len(t, response.Routes, 2)1357 assert.Len(t, response.Routes, 2)
1358 }1358 }
1359 1359
1360+func TestHandler_RoutesGet_DetailedSyncsAndIncludesConfiguredRouteWithoutContainer(t *testing.T) {
1361+ configSvc := inmocks.NewMockConfigService(t)
1362+ authSvc := inmocks.NewMockAuthService(t)
1363+ containerSvc := inmocks.NewMockContainerService(t)
1364+ secretSvc := inmocks.NewMockSecretService(t)
1365+
1366+ handler := newTestHandler(t, func(d *HandlerDeps) {
1367+ d.ConfigSvc = configSvc
1368+ d.AuthSvc = authSvc
1369+ d.ContainerSvc = containerSvc
1370+ d.SecretSvc = secretSvc
1371+ })
1372+
1373+ configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{{
1374+ Domain: "app.example.com",
1375+ Image: "app:latest",
1376+ }}).Once()
1377+ syncCall := containerSvc.EXPECT().SyncContainers(mock.Anything).Return(nil).Once()
1378+ listCall := containerSvc.EXPECT().ListRoutesWithDetails(mock.Anything).Return(nil).Once()
1379+ mock.InOrder(syncCall, listCall)
1380+
1381+ req := httptest.NewRequest("GET", "/admin/routes?detailed=true", nil)
1382+ req = req.WithContext(ctxWithScopes("admin:routes:read"))
1383+ rec := httptest.NewRecorder()
1384+
1385+ handler.ServeHTTP(rec, req)
1386+
1387+ assert.Equal(t, http.StatusOK, rec.Code)
1388+
1389+ var response dto.RoutesDetailResponse
1390+ err := json.NewDecoder(rec.Body).Decode(&response)
1391+ require.NoError(t, err)
1392+ require.Len(t, response.Routes, 1)
1393+ assert.Equal(t, "app.example.com", response.Routes[0].Domain)
1394+ assert.Equal(t, "app:latest", response.Routes[0].Image)
1395+ assert.Empty(t, response.Routes[0].ContainerID)
1396+ assert.Empty(t, response.Routes[0].ContainerStatus)
1397+}
1398+
1360 func TestHandler_RoutesGet_SingleRoute(t *testing.T) {1399 func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
1361 configSvc := inmocks.NewMockConfigService(t)1400 configSvc := inmocks.NewMockConfigService(t)
1362 authSvc := inmocks.NewMockAuthService(t)1401 authSvc := inmocks.NewMockAuthService(t)

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