From b7e2e93261d48a995d2ec72d66c6c9baeb2d2165 Mon Sep 17 00:00:00 2001 From: Tomas Dvorak Date: Mon, 21 Sep 2026 20:29:31 +0200 Subject: [PATCH] fix: polish service runtime UX and harden reconcile edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - tolerate concurrent project-network creation (re-resolve on conflict) - always publish an ephemeral host port when a port is configured, even with a domain set — Traefik routes on top when the edge network exists - public_url now prefers the configured domain, falls back to the first published port - service logs resolve replicas by containr.service label and merge all replica streams — previously queried a nonexistent containr- name Canvas/detail: - degraded status renders amber end-to-end (node card, detail badge, status dot) instead of falling through to "Stopped" - edges pick handle sides by relative node position — no more backward loops when the source sits right of the target - context menu gains Start for stopped services, Visit uses the published URL when no domain is set, menu clamps to canvas bounds - node cards show the image tag instead of a redundant hint - scaling tab falls back to live runtime replica counts when no autoscaler state exists; manual scale no longer requires it Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .gitignore | 2 + app/backend/internal/api/logs.go | 42 +++++-- app/backend/internal/api/runtime.go | 4 +- app/backend/internal/deployment/runtime.go | 16 ++- .../service/pages/ServiceDetailPage.tsx | 28 +++-- .../workspace/canvas/ProjectCanvas.tsx | 110 ++++++++++++------ .../src/features/workspace/canvas/nodes.tsx | 39 +++++-- app/frontend/src/index.css | 17 +++ app/frontend/src/lib/api-client.ts | 4 +- 9 files changed, 193 insertions(+), 69 deletions(-) diff --git a/.gitignore b/.gitignore index beabec8..8548445 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,5 @@ bin # Local reference checkouts (not project code) ref/ +.playwright-mcp/ +canvas-*.png diff --git a/app/backend/internal/api/logs.go b/app/backend/internal/api/logs.go index c33cf95..6b2d587 100644 --- a/app/backend/internal/api/logs.go +++ b/app/backend/internal/api/logs.go @@ -8,9 +8,11 @@ import ( "fmt" "io" "net/http" + "sort" "strings" "time" + "github.com/docker/docker/api/types/filters" "github.com/gin-gonic/gin" "github.com/google/uuid" ) @@ -73,7 +75,21 @@ func handleGetLogs(c *gin.Context) { respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") return } - containerName := fmt.Sprintf("containr-%s", serviceID) + // Replicas are named containr--; resolve them by label and + // merge logs across all of them. + ctx := context.Background() + containers, err := client.ListContainersFiltered(ctx, filters.NewArgs( + filters.Arg("label", "containr.service="+serviceID.String()), + ), false) + if err != nil { + respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") + return + } + if len(containers) == 0 { + respondEmptyServiceLogs(c, "No container logs are available for this service yet.") + return + } + sort.Slice(containers, func(i, j int) bool { return containers[i].Names[0] < containers[j].Names[0] }) logOpts := docker.LogOptions{ Stdout: true, @@ -83,17 +99,25 @@ func handleGetLogs(c *gin.Context) { Timestamps: true, } - ctx := context.Background() - logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts) - if err != nil { - if isDockerNotFoundError(err) { - respondEmptyServiceLogs(c, "No container logs are available for this service yet.") - return + var readers []io.ReadCloser + var streams []io.Reader + for _, ctr := range containers { + r, rerr := client.GetContainerLogs(ctx, ctr.ID, logOpts) + if rerr == nil { + readers = append(readers, r) + streams = append(streams, r) } - respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") + } + if len(readers) == 0 { + respondEmptyServiceLogs(c, "No container logs are available for this service yet.") return } - defer logsReader.Close() + logsReader := io.MultiReader(streams...) + defer func() { + for _, r := range readers { + _ = r.Close() + } + }() if follow { c.Header("Content-Type", "text/event-stream") diff --git a/app/backend/internal/api/runtime.go b/app/backend/internal/api/runtime.go index a8bd090..bdb8300 100644 --- a/app/backend/internal/api/runtime.go +++ b/app/backend/internal/api/runtime.go @@ -372,7 +372,9 @@ func liveServiceStatus(c *gin.Context, db *database.DB, service *Service) { service.Status = state.Status _, _ = db.Exec(`UPDATE services SET status = $1, updated_at = $2 WHERE id = $3`, state.Status, time.Now(), service.ID) } - if len(state.URLs) > 0 && service.Domain == "" { + if service.Domain != "" { + service.PublicURL = "https://" + service.Domain + } else if len(state.URLs) > 0 { service.PublicURL = state.URLs[0] } } diff --git a/app/backend/internal/deployment/runtime.go b/app/backend/internal/deployment/runtime.go index b7fdd82..7c0bfaf 100644 --- a/app/backend/internal/deployment/runtime.go +++ b/app/backend/internal/deployment/runtime.go @@ -97,6 +97,15 @@ func (de *DeploymentEngine) EnsureProjectNetwork(ctx context.Context, projectID }, }) if err != nil { + // Concurrent reconciles race on create; re-resolve before failing. + networks, lerr := de.dockerClient.ListNetworks(ctx) + if lerr == nil { + for _, n := range networks { + if n.Name == name { + return name, nil + } + } + } return "", fmt.Errorf("create project network: %w", err) } return name, nil @@ -231,10 +240,9 @@ func (de *DeploymentEngine) createReplica(ctx context.Context, spec RuntimeSpec, if spec.Port > 0 { port := nat.Port(fmt.Sprintf("%d/tcp", spec.Port)) cfg.ExposedPorts = nat.PortSet{port: struct{}{}} - if spec.Domain == "" { - // No edge router: publish an ephemeral host port for direct access. - cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}} - } + // Always publish an ephemeral host port for direct access; a configured + // domain is additionally routed via Traefik when the edge network exists. + cfg.PortBindings = nat.PortMap{port: []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: ""}}} if spec.HealthPath != "" { cfg.Healthcheck = &container.HealthConfig{ Test: []string{"CMD-SHELL", fmt.Sprintf("wget -q -O /dev/null http://127.0.0.1:%d%s || exit 1", spec.Port, spec.HealthPath)}, diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 66848fe..dbd79c7 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -288,7 +288,11 @@ export function ServiceDetailPage() { }); const manualScaleMutation = useMutation({ mutationFn: (replicas: number) => manualScaleService(serviceId, replicas), - onSuccess: invalidateScaling, + onSuccess: () => { + invalidateScaling(); + queryClient.invalidateQueries({ queryKey: ['service-runtime', serviceId] }); + queryClient.invalidateQueries({ queryKey: ['project-services'] }); + }, }); // --- Console --- @@ -589,9 +593,11 @@ export function ServiceDetailPage() {
{service.name} - + {service.status === 'running' && } - {service.status === 'running' ? 'Active' : 'Stopped'} + {service.status === 'running' ? 'Active' : service.status === 'degraded' ? 'Degraded' : 'Stopped'}
@@ -628,8 +634,8 @@ export function ServiceDetailPage() { <>
- {contextService.domain && ( + {(contextService.domain || contextService.publicUrl) && ( - Visit {contextService.domain} + Visit {(contextService.domain ?? contextService.publicUrl ?? '').replace(/^https?:\/\//, '')} )} + {contextService.status !== 'running' && ( + + )}