F5 (dashboard): surface 'route unpublished' for unhealthy/restarting deployed apps
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.
This commit is contained in:
@@ -30,6 +30,20 @@ func getTimezone() *time.Location {
|
|||||||
return webTimezone
|
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.
|
// templateFuncMap returns the FuncMap used by all HTML templates.
|
||||||
func (s *Server) templateFuncMap() template.FuncMap {
|
func (s *Server) templateFuncMap() template.FuncMap {
|
||||||
loc := getTimezone()
|
loc := getTimezone()
|
||||||
@@ -102,6 +116,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"routeUnpublished": routeUnpublished,
|
||||||
"logoURL": func(slug string) string {
|
"logoURL": func(slug string) string {
|
||||||
return s.cfg.AppLogoURL(slug)
|
return s.cfg.AppLogoURL(slug)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,6 +149,7 @@
|
|||||||
<div class="stack-actions">
|
<div class="stack-actions">
|
||||||
<span class="stack-state-label">{{stateLabel .State}}</span>
|
<span class="stack-state-label">{{stateLabel .State}}</span>
|
||||||
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
{{if .Orphaned}}<span class="badge badge-orphaned">Elavult</span>{{end}}
|
||||||
|
{{if and .Deployed (routeUnpublished .State)}}<span class="badge badge-route-unpublished" title="A proxy (Traefik) csak egészséges konténerhez publikál nyilvános útvonalat. Amíg az alkalmazás nem egészséges, az URL 404-et ad, pedig a konténer fut.">⚠ URL nem elérhető</span>{{end}}
|
||||||
|
|
||||||
{{if .Protected}}
|
{{if .Protected}}
|
||||||
<span class="badge badge-protected">Védett</span>
|
<span class="badge badge-protected">Védett</span>
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
|
||||||
{{$subdomain}}.{{$.Domain}} ↗
|
{{$subdomain}}.{{$.Domain}} ↗
|
||||||
</a>
|
</a>
|
||||||
|
{{if and .Deployed (routeUnpublished .State)}}
|
||||||
|
<span class="route-unpublished" title="A proxy (Traefik) csak egészséges konténerhez publikál nyilvános útvonalat. Amíg az alkalmazás nem egészséges, az URL nem érhető el (404), pedig a konténer fut.">⚠ URL nem elérhető – útvonal nincs publikálva</span>
|
||||||
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1249,6 +1249,20 @@ a.stat-card:hover {
|
|||||||
color: var(--orange);
|
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 */
|
/* Delete modal */
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
Reference in New Issue
Block a user