From 563cf07ec8d82c6240435cf66e741869d7b2ece2 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Mon, 23 Feb 2026 12:08:08 +0100 Subject: [PATCH] feat(deploy): async compose-up for instant UI feedback (v0.28.2) Deploy API now returns immediately after validation + config save. docker compose up -d runs in a background goroutine so the UI shows progress during image pulls instead of blocking for 30-60s. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 11 +++++ controller/README.md | 5 ++- controller/internal/stacks/deploy.go | 40 ++++++++++++++----- controller/internal/stacks/manager.go | 7 +++- controller/internal/web/funcmap.go | 6 ++- controller/internal/web/templates/deploy.html | 17 +++++++- 6 files changed, 70 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fbc10..8c17c2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## Changelog +### v0.28.2 — Async Deploy & AdventureLog Fix (2026-02-23) + +#### Changed +- **Async deploy** — `DeployStack()` now runs `docker compose up -d` in a background goroutine instead of blocking the HTTP response. The deploy API returns immediately after validation + config save, so the UI switches to the progress panel instantly (previously waited 30-60s for image pulls). New `StateDeploying` container state shown while compose-up is in progress. On failure, the goroutine reverts both disk and in-memory state and stores the error in `DeployError` for the polling UI to display. +- **Deploy progress UI** — Polling now handles the `deploying` state ("Képek letöltése, konténerek indítása...") and `deploy_error` (shows error message with links to logs). Previous behavior only showed progress after compose-up completed. + +#### Fixed +- **AdventureLog backend healthcheck** — Replaced `wget` (not available in v0.11.0 image) with `python urllib.request`. Also uses `127.0.0.1` instead of `localhost` to avoid IPv6 resolution issues. +- **AdventureLog frontend healthcheck** — Changed `localhost` → `127.0.0.1` to fix IPv6 resolution causing connection refused (Node.js only listens on IPv4). +- **AdventureLog SECRET_KEY** — Added `SECRET_KEY=${SECRET_KEY}` env var alongside `DJANGO_SECRET_KEY` for v0.11.0 compatibility (Django settings now reads `SECRET_KEY` directly). + ### v0.28.1 — Telemetry Debug Section (2026-02-23) #### Added diff --git a/controller/README.md b/controller/README.md index 8bd09fd..9b912cf 100644 --- a/controller/README.md +++ b/controller/README.md @@ -142,8 +142,8 @@ The app catalog lives in a separate Git repository. The controller: - Hard block if `used_mb + new_request > usable_memory` - `CommittedMemory()` (declared sum) still used for soft overcommit warning only - Deploy page shows real memory usage bar (not declared reservations) -5. Pre-generated secret values are submitted as hidden form inputs so the **same values** the user saw are saved to `app.yaml` (no silent re-generation on submit). Controller saves `app.yaml`, sets in-memory `Deployed` flag **before** `docker compose up -d` (avoids stale UI during slow image pulls), reverts on failure -6. 3-step progress panel polls `GET /api/stacks/{name}` every 3s: config saved → containers starting → health check passed +5. Pre-generated secret values are submitted as hidden form inputs so the **same values** the user saw are saved to `app.yaml` (no silent re-generation on submit). Controller saves `app.yaml`, sets in-memory `Deployed` + `Deploying` flags, then runs `docker compose up -d` **asynchronously** in a goroutine — API returns immediately so the UI switches to the progress panel without waiting for image pulls. On failure the goroutine reverts both disk and in-memory state and sets `DeployError`. +6. 3-step progress panel polls `GET /api/stacks/{name}` every 3s: config saved → `deploying` (pulling images) → containers starting → health check passed. New `StateDeploying` state shown while compose-up is in progress (no containers yet). 7. Post-deploy: locked fields (DB_PASSWORD, etc.) become read-only; the "Automatikusan generált értékek" section continues to show the saved values on the settings page #### App Info Pages @@ -195,6 +195,7 @@ When app templates are updated (e.g., a new `APP_KEY` secret is added to `.felho |-------|-------|-------|---------| | Running + healthy | Green | "Fut" | All containers running and healthy | | Running + starting | Orange | "Indulas..." | Healthcheck not yet passed | +| Deploying | Orange | "Telepítés..." | Compose up in progress (image pull, container creation) | | Running + unhealthy | Yellow | "Nem egeszseges" | Healthcheck failing | | Stopped/exited | Red | "Leallitva" | All containers stopped | | Restarting | Yellow | "Ujrainditas..." | Restart loop | diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index 8c33fcd..92019dd 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -271,41 +271,61 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) { m.checkLocalImages(req.StackName, stackDir) } - // Update in-memory stack state BEFORE compose up so the UI reflects - // "deployed" immediately (compose up can take 30-60s for image pulls). - // If compose up fails, we revert both disk and in-memory state below. + // Update in-memory stack state and mark as deploying. The compose-up + // runs in a goroutine so the API can return immediately and the UI + // shows progress via polling (image pull can take 30-60s). m.mu.Lock() if s, ok := m.stacks[req.StackName]; ok { s.Deployed = true + s.Deploying = true + s.DeployError = "" s.AppConfig = appCfg } m.mu.Unlock() - // Run docker compose up -d + // Run docker compose up -d asynchronously + go m.runComposeDeploy(req.StackName, stackDir, env, appCfg) + + return deployWarning, nil +} + +// runComposeDeploy executes docker compose up -d in background. +// On success it refreshes status; on failure it reverts the deploy state. +func (m *Manager) runComposeDeploy(name, stackDir string, env map[string]string, appCfg *AppConfig) { start := time.Now() _, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d") + if composeErr != nil { - m.logger.Printf("[ERROR] Stack %s deploy failed after %.1fs: %v", req.StackName, time.Since(start).Seconds(), composeErr) + m.logger.Printf("[ERROR] Stack %s deploy failed after %.1fs: %v", name, time.Since(start).Seconds(), composeErr) // Revert in-memory state m.mu.Lock() - if s, ok := m.stacks[req.StackName]; ok { + if s, ok := m.stacks[name]; ok { s.Deployed = false + s.Deploying = false + s.DeployError = composeErr.Error() s.AppConfig = nil } m.mu.Unlock() // Revert disk state — keep app.yaml for debugging but mark as not deployed appCfg.Deployed = false _ = SaveAppConfig(stackDir, appCfg) - return "", fmt.Errorf("docker compose up failed: %w", composeErr) + return } - m.logger.Printf("[INFO] Stack %s deployed successfully (took %.1fs)", req.StackName, time.Since(start).Seconds()) + m.logger.Printf("[INFO] Stack %s deployed successfully (took %.1fs)", name, time.Since(start).Seconds()) + + // Clear deploying flag + m.mu.Lock() + if s, ok := m.stacks[name]; ok { + s.Deploying = false + } + m.mu.Unlock() // Post-deploy container state check (async, non-blocking) deployEnv := m.stackEnv(stackDir) - m.logPostStartStatus(req.StackName, stackDir, deployEnv) + m.logPostStartStatus(name, stackDir, deployEnv) - return deployWarning, m.RefreshStatus() + _ = m.RefreshStatus() } // UpdateStackConfig updates non-locked fields for a deployed stack. diff --git a/controller/internal/stacks/manager.go b/controller/internal/stacks/manager.go index 34d86cc..69cb758 100644 --- a/controller/internal/stacks/manager.go +++ b/controller/internal/stacks/manager.go @@ -29,6 +29,7 @@ const ( StatePaused ContainerState = "paused" StateUnknown ContainerState = "unknown" StateNotDeployed ContainerState = "not_deployed" + StateDeploying ContainerState = "deploying" // compose up in progress (image pull, etc.) StateOrphaned ContainerState = "orphaned" ) @@ -51,6 +52,8 @@ type Stack struct { Orphaned bool `json:"orphaned"` // Deployed but no catalog template Containers []ContainerInfo `json:"containers"` AppConfig *AppConfig `json:"app_config,omitempty"` + Deploying bool `json:"deploying"` // compose up in progress + DeployError string `json:"deploy_error,omitempty"` // last async deploy error LastUpdated time.Time `json:"last_updated"` } @@ -250,7 +253,9 @@ func (m *Manager) refreshStatusLocked() error { containers, exists := projectContainers[name] if !exists { stack.Containers = nil - if stack.Deployed { + if stack.Deploying { + stack.State = StateDeploying + } else if stack.Deployed { stack.State = StateStopped } else { stack.State = StateNotDeployed diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index 05cf5d8..ef86202 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -38,7 +38,7 @@ func (s *Server) templateFuncMap() template.FuncMap { switch state { case stacks.StateRunning: return "green" - case stacks.StateStarting: + case stacks.StateStarting, stacks.StateDeploying: return "orange" case stacks.StateUnhealthy: return "yellow" @@ -56,6 +56,8 @@ func (s *Server) templateFuncMap() template.FuncMap { return "Fut" case stacks.StateStarting: return "Indulás..." + case stacks.StateDeploying: + return "Telepítés..." case stacks.StateUnhealthy: return "Nem egészséges" case stacks.StateStopped, stacks.StateExited: @@ -74,7 +76,7 @@ func (s *Server) templateFuncMap() template.FuncMap { switch state { case stacks.StateRunning: return "●" - case stacks.StateStarting: + case stacks.StateStarting, stacks.StateDeploying: return "◐" case stacks.StateUnhealthy: return "◑" diff --git a/controller/internal/web/templates/deploy.html b/controller/internal/web/templates/deploy.html index 8d9e729..8a3fa26 100644 --- a/controller/internal/web/templates/deploy.html +++ b/controller/internal/web/templates/deploy.html @@ -632,8 +632,23 @@ document.getElementById('deploy-form').addEventListener('submit', async function var sd = await sr.json(); if (!sd.ok || !sd.data) return; var state = sd.data.state; + var deployError = sd.data.deploy_error; - if (state === 'running') { + if (deployError) { + // Async compose-up failed + clearInterval(pollTimer); + setStep(stepContainers, 'error', 'Telepítés sikertelen'); + setStep(stepHealth, 'error'); + progressEl.querySelector('h3').textContent = 'Telepítés sikertelen'; + resultEl.innerHTML = '
' + + 'A telepítés nem sikerült: ' + deployError + + '
Naplók megtekintése' + + ' Alkalmazások'; + resultEl.style.display = 'block'; + } else if (state === 'deploying') { + // Compose up in progress (pulling images, creating containers) + setStep(stepContainers, 'active', 'Képek letöltése, konténerek indítása...'); + } else if (state === 'running') { clearInterval(pollTimer); setStep(stepContainers, 'done', 'Konténerek elindultak'); setStep(stepHealth, 'done', 'Alkalmazás kész!');