From fd29e602e8b3dc55bb1f3d60b93f21180971ab9b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 13 Feb 2026 21:15:00 +0100 Subject: [PATCH] updated --- controller/internal/stacks/deploy.go | 49 ++++++-------- controller/internal/stacks/manager.go | 98 +++++++++++++++++++++------ controller/internal/web/server.go | 36 +++++++--- controller/internal/web/templates.go | 75 +++----------------- 4 files changed, 134 insertions(+), 124 deletions(-) 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