diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go
index 793adcf..f828315 100644
--- a/controller/internal/stacks/deploy.go
+++ b/controller/internal/stacks/deploy.go
@@ -35,6 +35,7 @@ type DeployRequest struct {
// 4. Validate all user-provided values (password, path, required fields)
// 5. Save app.yaml
// 6. Run docker compose up -d with env vars
+// 7. Update in-memory stack state
func (m *Manager) DeployStack(req DeployRequest) error {
stack, ok := m.GetStack(req.StackName)
if !ok {
@@ -50,7 +51,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
- // Debug: log received values (redact passwords)
+ // Debug: log received values (redact passwords/secrets)
m.logger.Printf("[DEBUG] Deploy %s: received %d user values", req.StackName, len(req.Values))
for k, v := range req.Values {
if strings.Contains(strings.ToLower(k), "password") || strings.Contains(strings.ToLower(k), "secret") {
@@ -103,7 +104,7 @@ func (m *Manager) DeployStack(req DeployRequest) error {
return fmt.Errorf("a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
}
- // Validate path fields exist
+ // Validate path fields exist on disk (inside the container's filesystem)
if field.Type == "path" && value != "" {
if _, err := os.Stat(value); os.IsNotExist(err) {
return fmt.Errorf("path %q does not exist for field %q", value, field.Label)
@@ -147,6 +148,15 @@ func (m *Manager) DeployStack(req DeployRequest) error {
return fmt.Errorf("docker compose up failed: %w", err)
}
+ // Update in-memory stack state immediately so the UI reflects the deployment
+ // without waiting for the next ScanStacks() cycle.
+ m.mu.Lock()
+ if s, ok := m.stacks[req.StackName]; ok {
+ s.Deployed = true
+ s.AppConfig = appCfg
+ }
+ m.mu.Unlock()
+
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
return m.RefreshStatus()
}
@@ -164,7 +174,6 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
return fmt.Errorf("stack %q is not deployed yet", name)
}
- // Apply changes, respecting locked fields
lockedSet := make(map[string]bool)
for _, f := range appCfg.LockedFields {
lockedSet[f] = true
@@ -181,7 +190,6 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
return fmt.Errorf("saving updated config: %w", err)
}
- // Restart with new env
_, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d")
if err != nil {
return fmt.Errorf("restarting with new config: %w", err)
@@ -193,14 +201,11 @@ func (m *Manager) UpdateStackConfig(name string, values map[string]string) error
// composeExecWithEnv runs a compose command with custom env vars injected.
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
- // Build env slice: start with os env, then add our vars
cmdEnv := os.Environ()
for k, v := range env {
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
}
- // Always inject DOMAIN from controller config
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
-
return m.composeExecCustomEnv(dir, cmdEnv, args...)
}
@@ -220,15 +225,12 @@ func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
// --- App config persistence ---
-// LoadAppConfig reads app.yaml from a stack directory.
-// Returns nil if the file doesn't exist.
func LoadAppConfig(stackDir string) *AppConfig {
path := filepath.Join(stackDir, "app.yaml")
data, err := os.ReadFile(path)
if err != nil {
return nil
}
-
cfg := &AppConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil
@@ -236,18 +238,14 @@ func LoadAppConfig(stackDir string) *AppConfig {
return cfg
}
-// SaveAppConfig writes app.yaml to a stack directory.
func SaveAppConfig(stackDir string, cfg *AppConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
-
path := filepath.Join(stackDir, "app.yaml")
-
header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
content := header + string(data)
-
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
@@ -258,45 +256,36 @@ func SaveAppConfig(stackDir string, cfg *AppConfig) error {
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
-// generateValue creates a random value based on the generator spec.
-// Formats: "password:N", "hex:N", "static:VALUE"
func generateValue(spec string) (string, error) {
if spec == "" {
return "", fmt.Errorf("empty generator spec")
}
-
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec)
}
- genType := parts[0]
- param := parts[1]
-
- switch genType {
+ switch parts[0] {
case "password":
length := 0
- if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 {
- return "", fmt.Errorf("invalid password length: %q", param)
+ if _, err := fmt.Sscanf(parts[1], "%d", &length); err != nil || length <= 0 {
+ return "", fmt.Errorf("invalid password length: %q", parts[1])
}
return randomAlphanumeric(length)
-
case "hex":
byteLen := 0
- if _, err := fmt.Sscanf(param, "%d", &byteLen); err != nil || byteLen <= 0 {
- return "", fmt.Errorf("invalid hex length: %q", param)
+ if _, err := fmt.Sscanf(parts[1], "%d", &byteLen); err != nil || byteLen <= 0 {
+ return "", fmt.Errorf("invalid hex length: %q", parts[1])
}
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("reading random bytes: %w", err)
}
return hex.EncodeToString(b), nil
-
case "static":
- return param, nil
-
+ return parts[1], nil
default:
- return "", fmt.Errorf("unknown generator type: %q", genType)
+ return "", fmt.Errorf("unknown generator type: %q", parts[0])
}
}
diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go
index eab3979..55fb6cb 100644
--- a/controller/internal/stacks/manager.go
+++ b/controller/internal/stacks/manager.go
@@ -18,12 +18,14 @@ import (
type ContainerState string
const (
- StateRunning ContainerState = "running"
- StateStopped ContainerState = "stopped"
- StateRestarting ContainerState = "restarting"
- StateExited ContainerState = "exited"
- StatePaused ContainerState = "paused"
- StateUnknown ContainerState = "unknown"
+ StateRunning ContainerState = "running"
+ StateStarting ContainerState = "starting" // running but health: starting
+ StateUnhealthy ContainerState = "unhealthy" // running but health: unhealthy
+ StateStopped ContainerState = "stopped"
+ StateRestarting ContainerState = "restarting"
+ StateExited ContainerState = "exited"
+ StatePaused ContainerState = "paused"
+ StateUnknown ContainerState = "unknown"
StateNotDeployed ContainerState = "not_deployed"
)
@@ -32,7 +34,7 @@ type ContainerInfo struct {
Name string `json:"name"`
Image string `json:"image"`
State ContainerState `json:"state"`
- Status string `json:"status"` // e.g. "Up 3 hours"
+ Status string `json:"status"` // e.g. "Up 3 hours (healthy)"
}
// Stack represents a docker compose stack on disk.
@@ -193,9 +195,9 @@ func (m *Manager) refreshStatusLocked() error {
}
ci := ContainerInfo{
- Name: parts[0],
- Image: parts[1],
- State: parseContainerState(parts[2]),
+ Name: parts[0],
+ Image: parts[1],
+ State: resolveContainerState(parts[2], parts[3]),
Status: parts[3],
}
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
@@ -220,10 +222,27 @@ func (m *Manager) refreshStatusLocked() error {
return nil
}
-func parseContainerState(s string) ContainerState {
- switch strings.ToLower(strings.TrimSpace(s)) {
+// resolveContainerState determines the effective state by combining Docker's
+// State field (running/exited/etc.) with the Status field that contains health info.
+//
+// Docker State: "running", "exited", "restarting", "paused", "created", "dead", "removing"
+// Docker Status: "Up 3 hours (healthy)", "Up 9 seconds (health: starting)", "Up 2 min (unhealthy)"
+func resolveContainerState(dockerState, dockerStatus string) ContainerState {
+ state := strings.ToLower(strings.TrimSpace(dockerState))
+ status := strings.ToLower(dockerStatus)
+
+ switch state {
case "running":
+ // Check health sub-status for containers with healthchecks
+ if strings.Contains(status, "(health: starting)") {
+ return StateStarting
+ }
+ if strings.Contains(status, "(unhealthy)") {
+ return StateUnhealthy
+ }
+ // "(healthy)" or no healthcheck = running
return StateRunning
+
case "exited":
return StateExited
case "restarting":
@@ -237,20 +256,61 @@ func parseContainerState(s string) ContainerState {
}
}
+// aggregateState determines the overall stack state from its containers.
+// Priority: unhealthy/starting > restarting > all-running > stopped
func aggregateState(containers []ContainerInfo) ContainerState {
if len(containers) == 0 {
return StateNotDeployed
}
+
+ running := 0
+ starting := 0
+ unhealthy := 0
+ restarting := 0
+ stopped := 0
+
for _, c := range containers {
- if c.State == StateRunning {
- return StateRunning
+ switch c.State {
+ case StateRunning:
+ running++
+ case StateStarting:
+ starting++
+ case StateUnhealthy:
+ unhealthy++
+ case StateRestarting:
+ restarting++
+ case StateStopped, StateExited:
+ stopped++
}
}
- for _, c := range containers {
- if c.State == StateRestarting {
- return StateRestarting
- }
+
+ total := len(containers)
+
+ // Any unhealthy → whole stack is unhealthy
+ if unhealthy > 0 {
+ return StateUnhealthy
}
+ // Any still starting → stack is starting
+ if starting > 0 {
+ return StateStarting
+ }
+ // Any restarting → stack is restarting
+ if restarting > 0 {
+ return StateRestarting
+ }
+ // All running (and healthy) → stack is running
+ if running == total {
+ return StateRunning
+ }
+ // All stopped → stack is stopped
+ if stopped == total {
+ return StateStopped
+ }
+ // Mix (some running, some stopped) — report as running (partial)
+ if running > 0 {
+ return StateRunning
+ }
+
return StateStopped
}
@@ -449,4 +509,4 @@ func (m *Manager) execCommand(name string, args ...string) (string, error) {
}
return stdout.String(), nil
-}
+}
\ No newline at end of file
diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go
index affdd74..0beebf6 100644
--- a/controller/internal/web/server.go
+++ b/controller/internal/web/server.go
@@ -59,6 +59,10 @@ func (s *Server) loadTemplates() {
switch state {
case stacks.StateRunning:
return "green"
+ case stacks.StateStarting:
+ return "orange"
+ case stacks.StateUnhealthy:
+ return "yellow"
case stacks.StateStopped, stacks.StateExited:
return "red"
case stacks.StateRestarting:
@@ -71,6 +75,10 @@ func (s *Server) loadTemplates() {
switch state {
case stacks.StateRunning:
return "Fut"
+ case stacks.StateStarting:
+ return "Indulás..."
+ case stacks.StateUnhealthy:
+ return "Nem egészséges"
case stacks.StateStopped, stacks.StateExited:
return "Leállítva"
case stacks.StateRestarting:
@@ -87,6 +95,10 @@ func (s *Server) loadTemplates() {
switch state {
case stacks.StateRunning:
return "●"
+ case stacks.StateStarting:
+ return "◐"
+ case stacks.StateUnhealthy:
+ return "◑"
case stacks.StateStopped, stacks.StateExited:
return "○"
case stacks.StateRestarting:
@@ -98,6 +110,16 @@ func (s *Server) loadTemplates() {
"stateStr": func(state stacks.ContainerState) string {
return string(state)
},
+ // isOperational returns true for any state where the stack has containers
+ // and is not stopped/exited — used by templates for showing action buttons
+ "isOperational": func(state stacks.ContainerState) bool {
+ switch state {
+ case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
+ return true
+ default:
+ return false
+ }
+ },
"logoURL": func(slug string) string {
return s.cfg.AppLogoURL(slug)
},
@@ -134,7 +156,6 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "public, max-age=3600")
fmt.Fprint(w, cssContent)
case strings.HasPrefix(path, "/static/assets/"):
- // Serve baked-in app assets (logos, screenshots)
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
@@ -287,6 +308,10 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
running++
case stacks.StateStopped, stacks.StateExited:
stopped++
+ case stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting:
+ // Count starting/unhealthy/restarting as "running" for the dashboard stat
+ // (they have containers, they're just not fully healthy yet)
+ running++
}
}
@@ -347,11 +372,9 @@ func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name stri
}
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
-// These are copied into the container at build time.
const assetsDir = "/usr/share/felhom/assets"
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
- // Sanitize: prevent directory traversal
filename = filepath.Base(filename)
path := filepath.Join(assetsDir, filename)
@@ -364,14 +387,9 @@ func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename str
http.ServeFile(w, r, path)
}
-// appDetailHandler serves a local app detail page (description, screenshots, FAQ).
-// TODO: Phase 1.5 — for now, redirect to the stacks page.
-// Future: render a dedicated app page template with baked-in content.
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
- // Find the stack by slug
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
- // For now, redirect to deploy page (if not deployed) or stacks page
if !stack.Deployed {
http.Redirect(w, r, "/stacks/"+stack.Name+"/deploy", http.StatusFound)
} else {
@@ -402,4 +420,4 @@ func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
-}
+}
\ No newline at end of file
diff --git a/controller/internal/web/templates.go b/controller/internal/web/templates.go
index 875f5ff..63fdbca 100644
--- a/controller/internal/web/templates.go
+++ b/controller/internal/web/templates.go
@@ -115,7 +115,7 @@ const dashboardTmpl = `
{{else if not .Deployed}}
🚀 Telepítés
{{else}}
- {{if eq (stateStr .State) "running"}}
+ {{if isOperational .State}}
{{else}}
@@ -192,7 +192,7 @@ const stacksTmpl = `
🚀 Telepítés
ℹ️ Részletek
{{else}}
- {{if eq (stateStr .State) "running"}}
+ {{if isOperational .State}}
@@ -343,7 +343,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
}
}
- // Client-side validation: check all required fields are filled
+ // Client-side validation: check all required fields
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
for (const rf of requiredFields) {
if (!rf.disabled && rf.value.trim() === '') {
@@ -394,64 +394,6 @@ document.getElementById('deploy-form').addEventListener('submit', async function
});
-{{template "layout_end" .}}
-{{end}}
-` + "\n"
-
-
-
{{template "layout_end" .}}
{{end}}
`
@@ -512,6 +454,7 @@ const cssContent = `
--card-bg:#fff; --text:#1a202c; --text-muted:#718096; --border:#e2e8f0;
--green:#38a169; --green-light:#c6f6d5; --red:#e53e3e; --red-light:#fed7d7;
--yellow:#d69e2e; --yellow-light:#fefcbf; --blue:#3182ce; --blue-light:#bee3f8;
+ --orange:#dd6b20; --orange-light:#feebc8;
--gray:#a0aec0; --gray-light:#edf2f7; --radius:8px; --shadow:0 1px 3px rgba(0,0,0,.1);
}
*{margin:0;padding:0;box-sizing:border-box}
@@ -540,7 +483,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.stack-list{display:flex;flex-direction:column;gap:.5rem}
.stack-card{background:var(--card-bg);border-radius:var(--radius);padding:1rem 1.25rem;box-shadow:var(--shadow);display:flex;justify-content:space-between;align-items:center;border-left:4px solid var(--gray)}
-.stack-state-green{border-left-color:var(--green)} .stack-state-red{border-left-color:var(--red)} .stack-state-yellow{border-left-color:var(--yellow)} .stack-state-gray{border-left-color:var(--gray)}
+.stack-state-green{border-left-color:var(--green)} .stack-state-red{border-left-color:var(--red)} .stack-state-yellow{border-left-color:var(--yellow)} .stack-state-orange{border-left-color:var(--orange)} .stack-state-gray{border-left-color:var(--gray)}
.stack-info{display:flex;align-items:center;gap:.75rem}
.stack-logo{width:32px;height:32px;border-radius:6px;object-fit:contain;background:#1c2128;padding:4px}
.stack-logo-lg{width:48px;height:48px;border-radius:8px;object-fit:contain;background:#1c2128;padding:6px}
@@ -549,14 +492,14 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.stack-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:1rem}
.stack-detail-card{background:var(--card-bg);border-radius:var(--radius);padding:1.25rem;box-shadow:var(--shadow);border-top:4px solid var(--gray)}
-.stack-detail-card.stack-state-green{border-top-color:var(--green)} .stack-detail-card.stack-state-red{border-top-color:var(--red)}
+.stack-detail-card.stack-state-green{border-top-color:var(--green)} .stack-detail-card.stack-state-red{border-top-color:var(--red)} .stack-detail-card.stack-state-orange{border-top-color:var(--orange)} .stack-detail-card.stack-state-yellow{border-top-color:var(--yellow)}
.stack-detail-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
.stack-title-row{display:flex;align-items:center;gap:.75rem}
.subdomain-link{font-size:.8rem;color:var(--blue);text-decoration:none} .subdomain-link:hover{text-decoration:underline}
.stack-state-badge{padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:600;white-space:nowrap}
.state-green{background:var(--green-light);color:var(--green)} .state-red{background:var(--red-light);color:var(--red)}
-.state-yellow{background:var(--yellow-light);color:var(--yellow)} .state-gray{background:var(--gray-light);color:var(--gray)}
-.state-text-green{color:var(--green)} .state-text-red{color:var(--red)}
+.state-yellow{background:var(--yellow-light);color:var(--yellow)} .state-orange{background:var(--orange-light);color:var(--orange)} .state-gray{background:var(--gray-light);color:var(--gray)}
+.state-text-green{color:var(--green)} .state-text-red{color:var(--red)} .state-text-orange{color:var(--orange)} .state-text-yellow{color:var(--yellow)}
.stack-detail-desc{color:var(--text-muted);font-size:.85rem;margin-bottom:.75rem}
.stack-meta-badges{display:flex;flex-wrap:wrap;gap:.4rem;margin:.5rem 0}
.meta-badge{background:var(--gray-light);color:var(--text-muted);padding:.15rem .5rem;border-radius:6px;font-size:.75rem}
@@ -620,4 +563,4 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
.stack-grid{grid-template-columns:1fr} .stats-grid{grid-template-columns:repeat(3,1fr)}
.deploy-info{flex-direction:column}
}
-`
+`
\ No newline at end of file