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' && ( + + )}