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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,16 @@
|
|||||||
## Changelog
|
## 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)
|
### v0.28.1 — Telemetry Debug Section (2026-02-23)
|
||||||
|
|
||||||
#### Added
|
#### Added
|
||||||
|
|||||||
@@ -142,8 +142,8 @@ The app catalog lives in a separate Git repository. The controller:
|
|||||||
- Hard block if `used_mb + new_request > usable_memory`
|
- Hard block if `used_mb + new_request > usable_memory`
|
||||||
- `CommittedMemory()` (declared sum) still used for soft overcommit warning only
|
- `CommittedMemory()` (declared sum) still used for soft overcommit warning only
|
||||||
- Deploy page shows real memory usage bar (not declared reservations)
|
- 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
|
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 → containers starting → health check passed
|
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
|
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
|
#### 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 + healthy | Green | "Fut" | All containers running and healthy |
|
||||||
| Running + starting | Orange | "Indulas..." | Healthcheck not yet passed |
|
| 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 |
|
| Running + unhealthy | Yellow | "Nem egeszseges" | Healthcheck failing |
|
||||||
| Stopped/exited | Red | "Leallitva" | All containers stopped |
|
| Stopped/exited | Red | "Leallitva" | All containers stopped |
|
||||||
| Restarting | Yellow | "Ujrainditas..." | Restart loop |
|
| Restarting | Yellow | "Ujrainditas..." | Restart loop |
|
||||||
|
|||||||
@@ -271,41 +271,61 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
|||||||
m.checkLocalImages(req.StackName, stackDir)
|
m.checkLocalImages(req.StackName, stackDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update in-memory stack state BEFORE compose up so the UI reflects
|
// Update in-memory stack state and mark as deploying. The compose-up
|
||||||
// "deployed" immediately (compose up can take 30-60s for image pulls).
|
// runs in a goroutine so the API can return immediately and the UI
|
||||||
// If compose up fails, we revert both disk and in-memory state below.
|
// shows progress via polling (image pull can take 30-60s).
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if s, ok := m.stacks[req.StackName]; ok {
|
if s, ok := m.stacks[req.StackName]; ok {
|
||||||
s.Deployed = true
|
s.Deployed = true
|
||||||
|
s.Deploying = true
|
||||||
|
s.DeployError = ""
|
||||||
s.AppConfig = appCfg
|
s.AppConfig = appCfg
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
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()
|
start := time.Now()
|
||||||
_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")
|
_, composeErr := m.composeExecWithEnv(stackDir, env, "up", "-d")
|
||||||
|
|
||||||
if composeErr != nil {
|
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
|
// Revert in-memory state
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
if s, ok := m.stacks[req.StackName]; ok {
|
if s, ok := m.stacks[name]; ok {
|
||||||
s.Deployed = false
|
s.Deployed = false
|
||||||
|
s.Deploying = false
|
||||||
|
s.DeployError = composeErr.Error()
|
||||||
s.AppConfig = nil
|
s.AppConfig = nil
|
||||||
}
|
}
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
// Revert disk state — keep app.yaml for debugging but mark as not deployed
|
// Revert disk state — keep app.yaml for debugging but mark as not deployed
|
||||||
appCfg.Deployed = false
|
appCfg.Deployed = false
|
||||||
_ = SaveAppConfig(stackDir, appCfg)
|
_ = 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)
|
// Post-deploy container state check (async, non-blocking)
|
||||||
deployEnv := m.stackEnv(stackDir)
|
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.
|
// UpdateStackConfig updates non-locked fields for a deployed stack.
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
StatePaused ContainerState = "paused"
|
StatePaused ContainerState = "paused"
|
||||||
StateUnknown ContainerState = "unknown"
|
StateUnknown ContainerState = "unknown"
|
||||||
StateNotDeployed ContainerState = "not_deployed"
|
StateNotDeployed ContainerState = "not_deployed"
|
||||||
|
StateDeploying ContainerState = "deploying" // compose up in progress (image pull, etc.)
|
||||||
StateOrphaned ContainerState = "orphaned"
|
StateOrphaned ContainerState = "orphaned"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ type Stack struct {
|
|||||||
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
Orphaned bool `json:"orphaned"` // Deployed but no catalog template
|
||||||
Containers []ContainerInfo `json:"containers"`
|
Containers []ContainerInfo `json:"containers"`
|
||||||
AppConfig *AppConfig `json:"app_config,omitempty"`
|
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"`
|
LastUpdated time.Time `json:"last_updated"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +253,9 @@ func (m *Manager) refreshStatusLocked() error {
|
|||||||
containers, exists := projectContainers[name]
|
containers, exists := projectContainers[name]
|
||||||
if !exists {
|
if !exists {
|
||||||
stack.Containers = nil
|
stack.Containers = nil
|
||||||
if stack.Deployed {
|
if stack.Deploying {
|
||||||
|
stack.State = StateDeploying
|
||||||
|
} else if stack.Deployed {
|
||||||
stack.State = StateStopped
|
stack.State = StateStopped
|
||||||
} else {
|
} else {
|
||||||
stack.State = StateNotDeployed
|
stack.State = StateNotDeployed
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
switch state {
|
switch state {
|
||||||
case stacks.StateRunning:
|
case stacks.StateRunning:
|
||||||
return "green"
|
return "green"
|
||||||
case stacks.StateStarting:
|
case stacks.StateStarting, stacks.StateDeploying:
|
||||||
return "orange"
|
return "orange"
|
||||||
case stacks.StateUnhealthy:
|
case stacks.StateUnhealthy:
|
||||||
return "yellow"
|
return "yellow"
|
||||||
@@ -56,6 +56,8 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
return "Fut"
|
return "Fut"
|
||||||
case stacks.StateStarting:
|
case stacks.StateStarting:
|
||||||
return "Indulás..."
|
return "Indulás..."
|
||||||
|
case stacks.StateDeploying:
|
||||||
|
return "Telepítés..."
|
||||||
case stacks.StateUnhealthy:
|
case stacks.StateUnhealthy:
|
||||||
return "Nem egészséges"
|
return "Nem egészséges"
|
||||||
case stacks.StateStopped, stacks.StateExited:
|
case stacks.StateStopped, stacks.StateExited:
|
||||||
@@ -74,7 +76,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
switch state {
|
switch state {
|
||||||
case stacks.StateRunning:
|
case stacks.StateRunning:
|
||||||
return "●"
|
return "●"
|
||||||
case stacks.StateStarting:
|
case stacks.StateStarting, stacks.StateDeploying:
|
||||||
return "◐"
|
return "◐"
|
||||||
case stacks.StateUnhealthy:
|
case stacks.StateUnhealthy:
|
||||||
return "◑"
|
return "◑"
|
||||||
|
|||||||
@@ -632,8 +632,23 @@ document.getElementById('deploy-form').addEventListener('submit', async function
|
|||||||
var sd = await sr.json();
|
var sd = await sr.json();
|
||||||
if (!sd.ok || !sd.data) return;
|
if (!sd.ok || !sd.data) return;
|
||||||
var state = sd.data.state;
|
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 = '<div class="alert alert-error" style="margin-top:1rem">' +
|
||||||
|
'A telepítés nem sikerült: ' + deployError +
|
||||||
|
'</div><a href="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">Naplók megtekintése</a>' +
|
||||||
|
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások</a>';
|
||||||
|
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);
|
clearInterval(pollTimer);
|
||||||
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
setStep(stepContainers, 'done', 'Konténerek elindultak');
|
||||||
setStep(stepHealth, 'done', 'Alkalmazás kész!');
|
setStep(stepHealth, 'done', 'Alkalmazás kész!');
|
||||||
|
|||||||
Reference in New Issue
Block a user