From 803ce50578d8de7f5382070aa040bfa6bbe3aab7 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sun, 14 Jun 2026 09:56:30 +0200 Subject: [PATCH] F5 (dashboard): surface 'route unpublished' for unhealthy/restarting deployed apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Traefik only publishes a route to a healthy container, so an unhealthy deployed app returns 404 at its URL though the container runs — previously shown only as 'Nem egészséges' with no hint the URL is dead. New routeUnpublished() funcmap helper + a distinct indicator on the dashboard and stacks cards (gated on .Deployed). Tests: routeUnpublished across all states, real templateFS parses with the funcmap, and the card guard renders the indicator only for deployed+unhealthy. --- controller/internal/web/funcmap.go | 15 ++++ .../internal/web/route_unpublished_test.go | 78 +++++++++++++++++++ .../internal/web/templates/dashboard.html | 1 + controller/internal/web/templates/stacks.html | 3 + controller/internal/web/templates/style.css | 14 ++++ 5 files changed, 111 insertions(+) create mode 100644 controller/internal/web/route_unpublished_test.go diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index b9c1c52..a8e2807 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -30,6 +30,20 @@ func getTimezone() *time.Location { return webTimezone } +// routeUnpublished reports whether the reverse proxy (Traefik) is withholding a deployed stack's public +// route because the container is not healthy. Traefik's docker provider only publishes a route to a +// container that is healthy (or has no healthcheck); an unhealthy or restarting container yields a 404 +// at its URL even though the card "looks deployed". Templates use this to surface that distinctly (F5), +// so an unhealthy app with a dead URL isn't mistaken for a merely-degraded-but-reachable one. +func routeUnpublished(state stacks.ContainerState) bool { + switch state { + case stacks.StateUnhealthy, stacks.StateRestarting: + return true + default: + return false + } +} + // templateFuncMap returns the FuncMap used by all HTML templates. func (s *Server) templateFuncMap() template.FuncMap { loc := getTimezone() @@ -102,6 +116,7 @@ func (s *Server) templateFuncMap() template.FuncMap { return false } }, + "routeUnpublished": routeUnpublished, "logoURL": func(slug string) string { return s.cfg.AppLogoURL(slug) }, diff --git a/controller/internal/web/route_unpublished_test.go b/controller/internal/web/route_unpublished_test.go new file mode 100644 index 0000000..a160463 --- /dev/null +++ b/controller/internal/web/route_unpublished_test.go @@ -0,0 +1,78 @@ +package web + +import ( + "bytes" + "html/template" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// TestRouteUnpublished asserts F5: routeUnpublished is true exactly for the states where Traefik +// withholds the public route (unhealthy / restarting) and false otherwise. +func TestRouteUnpublished(t *testing.T) { + cases := []struct { + state stacks.ContainerState + want bool + }{ + {stacks.StateUnhealthy, true}, + {stacks.StateRestarting, true}, + {stacks.StateRunning, false}, + {stacks.StateStarting, false}, + {stacks.StateDeploying, false}, + {stacks.StateStopped, false}, + {stacks.StateExited, false}, + {stacks.StateNotDeployed, false}, + {stacks.StatePaused, false}, + } + for _, c := range cases { + if got := routeUnpublished(c.state); got != c.want { + t.Errorf("routeUnpublished(%q) = %v, want %v", c.state, got, c.want) + } + } +} + +// TestTemplatesParseWithFuncmap asserts the real embedded templates (including the stacks.html / +// dashboard.html edits that reference routeUnpublished) parse with the production funcmap. Catches an +// unregistered func or a template syntax error introduced by the F5 edits. +func TestTemplatesParseWithFuncmap(t *testing.T) { + s := &Server{cfg: &config.Config{}} + if _, err := template.New("").Funcs(s.templateFuncMap()).ParseFS(templateFS, "templates/*.html"); err != nil { + t.Fatalf("templates failed to parse with funcmap: %v", err) + } +} + +// TestRouteUnpublishedIndicatorRenders asserts the dashboard/stacks card guard renders the distinct +// indicator for a DEPLOYED + unhealthy stack, and NOT for a healthy one — the exact condition both +// edited templates use ({{if and .Deployed (routeUnpublished .State)}}). +func TestRouteUnpublishedIndicatorRenders(t *testing.T) { + s := &Server{cfg: &config.Config{}} + const frag = `{{if and .Deployed (routeUnpublished .State)}}URL-NOT-PUBLISHED{{end}}` + tmpl, err := template.New("frag").Funcs(s.templateFuncMap()).Parse(frag) + if err != nil { + t.Fatal(err) + } + type row struct { + Deployed bool + State stacks.ContainerState + } + render := func(r row) string { + var b bytes.Buffer + if err := tmpl.Execute(&b, r); err != nil { + t.Fatal(err) + } + return b.String() + } + + if got := render(row{Deployed: true, State: stacks.StateUnhealthy}); !strings.Contains(got, "URL-NOT-PUBLISHED") { + t.Errorf("deployed+unhealthy should show the indicator, got %q", got) + } + if got := render(row{Deployed: true, State: stacks.StateRunning}); strings.Contains(got, "URL-NOT-PUBLISHED") { + t.Errorf("deployed+running must NOT show the indicator, got %q", got) + } + if got := render(row{Deployed: false, State: stacks.StateUnhealthy}); strings.Contains(got, "URL-NOT-PUBLISHED") { + t.Errorf("not-deployed must NOT show the indicator, got %q", got) + } +} diff --git a/controller/internal/web/templates/dashboard.html b/controller/internal/web/templates/dashboard.html index 5b40e88..046d736 100644 --- a/controller/internal/web/templates/dashboard.html +++ b/controller/internal/web/templates/dashboard.html @@ -149,6 +149,7 @@
{{stateLabel .State}} {{if .Orphaned}}Elavult{{end}} + {{if and .Deployed (routeUnpublished .State)}}⚠ URL nem elérhető{{end}} {{if .Protected}} Védett diff --git a/controller/internal/web/templates/stacks.html b/controller/internal/web/templates/stacks.html index 840495b..9c468f4 100644 --- a/controller/internal/web/templates/stacks.html +++ b/controller/internal/web/templates/stacks.html @@ -30,6 +30,9 @@ {{$subdomain}}.{{$.Domain}} ↗ + {{if and .Deployed (routeUnpublished .State)}} + ⚠ URL nem elérhető – útvonal nincs publikálva + {{end}} {{end}}
diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 422858d..767e1ed 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1249,6 +1249,20 @@ a.stat-card:hover { color: var(--orange); } +/* F5: an unhealthy/restarting deployed app has its public route withheld by Traefik (404 at the URL). */ +.badge-route-unpublished { + background: var(--orange-bg); + color: var(--orange); + white-space: nowrap; +} +.route-unpublished { + display: inline-block; + margin-top: 2px; + font-size: 0.8em; + color: var(--orange); + white-space: nowrap; +} + /* Delete modal */ .modal-overlay { position: fixed;