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:
2026-02-23 12:08:08 +01:00
parent 4a6ab4d61c
commit 563cf07ec8
6 changed files with 70 additions and 16 deletions
+30 -10
View File
@@ -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.
+6 -1
View File
@@ -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