feat: catch-all page for stopped apps, deploy controls, dashboard open button

Stopped/undeployed app subdomains now show a branded page instead of
Traefik 404. Deploy settings page gains start/stop/restart controls.
Dashboard shows "Megnyitás" button for running apps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 13:38:53 +01:00
parent aaf479356a
commit df165f7ef0
10 changed files with 205 additions and 5 deletions
+22
View File
@@ -171,6 +171,21 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
data["CrossDriveFailed"] = crossDriveFailed
}
// Build subdomain map for "Megnyitás" buttons
subdomains := make(map[string]string)
for _, stack := range deployedStacks {
if stack.Deployed {
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
subdomains[stack.Name] = sd
continue
}
}
}
subdomains[stack.Name] = stack.Meta.Subdomain
}
data["Subdomains"] = subdomains
if s.alertManager != nil {
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
}
@@ -303,6 +318,13 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri
}
data["StoragePaths"] = deployPaths
// Effective subdomain for "Megnyitás" button
if alreadyDeployed && appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
data["EffectiveSubdomain"] = sd
}
}
// Storage info for already-deployed apps with HDD data
if alreadyDeployed {
storageInfo := s.storageInfoForStack(name)
+82
View File
@@ -285,6 +285,88 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
}
// CatchAllMiddleware intercepts requests to non-controller hosts and serves
// a branded error page (for stopped/undeployed app subdomains). Requests to
// the controller host (felhom.DOMAIN) pass through normally.
func (s *Server) CatchAllMiddleware(next http.Handler) http.Handler {
controllerHost := "felhom." + s.cfg.Customer.Domain
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host := r.Host
if idx := strings.LastIndex(host, ":"); idx != -1 {
host = host[:idx]
}
if strings.EqualFold(host, controllerHost) || host == "" {
next.ServeHTTP(w, r)
return
}
s.serveCatchAll(w, r, host)
})
}
// serveCatchAll renders a branded page for requests reaching a stopped/undeployed
// app subdomain. Served without auth since the user has no session on this host.
func (s *Server) serveCatchAll(w http.ResponseWriter, r *http.Request, host string) {
domain := s.cfg.Customer.Domain
subdomain := ""
suffix := "." + domain
if strings.HasSuffix(host, suffix) {
subdomain = strings.TrimSuffix(host, suffix)
}
data := map[string]interface{}{
"Domain": domain,
"ControllerURL": "https://felhom." + domain,
"Host": host,
}
if subdomain != "" {
if stack, ok := s.findStackBySubdomain(subdomain); ok {
data["AppName"] = stack.Meta.DisplayName
data["AppSlug"] = stack.Meta.Slug
data["AppLogoURL"] = s.cfg.AppLogoURL(stack.Meta.Slug)
if stack.Deployed {
data["Status"] = "stopped"
data["StatusText"] = "Az alkalmazás jelenleg le van állítva"
} else {
data["Status"] = "not_deployed"
data["StatusText"] = "Az alkalmazás nincs telepítve"
}
} else {
data["Status"] = "unknown"
data["StatusText"] = "Ez az oldal nem található"
}
} else {
data["Status"] = "unknown"
data["StatusText"] = "Ez az oldal nem található"
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
if err := s.tmpl.ExecuteTemplate(w, "catchall", data); err != nil {
s.logger.Printf("[ERROR] Catch-all template error: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
// findStackBySubdomain looks up the stack that owns the given subdomain.
func (s *Server) findStackBySubdomain(subdomain string) (*stacks.Stack, bool) {
for _, stack := range s.stackMgr.GetStacks() {
// Check deployed app.yaml SUBDOMAIN env first
if stack.Deployed {
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd == subdomain {
return &stack, true
}
}
}
// Fallback to metadata subdomain
if stack.Meta.Subdomain == subdomain {
return &stack, true
}
}
return nil, false
}
// ServeStorageAPI handles /api/storage/* routes (JSON API for disk operations).
func (s *Server) ServeStorageAPI(w http.ResponseWriter, r *http.Request) {
s.storageAPIHandler(w, r)
@@ -0,0 +1,47 @@
{{define "catchall"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .AppName}}{{.AppName}} — {{end}}felhom.eu</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;color:#e6edf3;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;min-height:100vh;display:flex;align-items:center;justify-content:center}
.container{text-align:center;max-width:480px;padding:2rem}
.logo{width:80px;height:auto;margin-bottom:1.5rem;opacity:.85}
.app-name{font-size:1.5rem;font-weight:600;margin-bottom:.5rem}
.status-text{color:#8b949e;font-size:1.1rem;margin-bottom:2rem;line-height:1.5}
.status-icon{font-size:2.5rem;margin-bottom:1rem}
.status-stopped .status-icon{color:#da3633}
.status-not_deployed .status-icon{color:#8b949e}
.status-unknown .status-icon{color:#8b949e}
.btn{display:inline-block;padding:.6rem 1.2rem;border-radius:6px;text-decoration:none;font-size:.9rem;font-weight:500;transition:all .2s}
.btn-primary{background:#0088cc;color:#fff}
.btn-primary:hover{background:#00aaff}
.btn-outline{border:1px solid #30363d;color:#8b949e;margin-left:.5rem}
.btn-outline:hover{border-color:#8b949e;color:#e6edf3}
.host-label{color:#6e7681;font-size:.85rem;margin-top:2rem}
</style>
</head>
<body>
<div class="container status-{{.Status}}">
<img class="logo" src="{{.ControllerURL}}/static/felhom-logo.svg" alt="felhom.eu">
<div class="status-icon">
{{if eq .Status "stopped"}}⏸{{else if eq .Status "not_deployed"}}📦{{else}}🔍{{end}}
</div>
{{if .AppName}}<div class="app-name">{{.AppName}}</div>{{end}}
<div class="status-text">{{.StatusText}}</div>
<div>
{{if .AppSlug}}
<a class="btn btn-primary" href="{{.ControllerURL}}/apps/{{.AppSlug}}">Alkalmazás kezelése</a>
{{else}}
<a class="btn btn-primary" href="{{.ControllerURL}}/stacks">Alkalmazások</a>
{{end}}
<a class="btn btn-outline" href="{{.ControllerURL}}">Vezérlőpult</a>
</div>
<div class="host-label">{{.Host}}</div>
</div>
</body>
</html>
{{end}}
@@ -174,6 +174,8 @@
<a href="/stacks/{{.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">Telepítés</a>
{{else}}
{{if isOperational .State}}
{{$subdomain := index $.Subdomains .Name}}
{{if $subdomain}}<a href="https://{{$subdomain}}.{{$.Domain}}" target="_blank" class="btn btn-sm btn-outline" onclick="event.stopPropagation()">Megnyitás ↗</a>{{end}}
<button class="btn btn-sm btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')"></button>
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Name}}', 'stop')"></button>
{{else}}
+14 -1
View File
@@ -6,7 +6,20 @@
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
<h2>{{.Meta.DisplayName}} — {{if .AlreadyDeployed}}Beállítások{{else}}Telepítés{{end}}</h2>
</div>
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline">️ Részletek</a>
<div style="display:flex;align-items:center;gap:.5rem">
{{if .AlreadyDeployed}}
{{if isOperational .Stack.State}}
<button class="btn btn-sm btn-warning" onclick="stackAction(event, '{{.Stack.Name}}', 'restart')">Újraindítás</button>
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Stack.Name}}', 'stop')">Leállítás</button>
{{else}}
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Stack.Name}}', 'start')">Indítás</button>
{{end}}
{{if and (isOperational .Stack.State) .EffectiveSubdomain}}
<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
{{end}}
{{end}}
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline">️ Részletek</a>
</div>
</div>
<div class="deploy-container">
@@ -25,7 +25,7 @@
<div>
<h3>{{.Meta.DisplayName}}</h3>
{{$subdomain := index $.Subdomains .Name}}
{{if $subdomain}}
{{if and $subdomain .Deployed}}
<a class="subdomain-link" href="https://{{$subdomain}}.{{$.Domain}}" target="_blank">
{{$subdomain}}.{{$.Domain}} ↗
</a>