From b8fca7c1026908762daaa02fd4275cc067835223 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 11 Jul 2026 15:33:33 +1000 Subject: [PATCH 1/7] Deduplicate service configuration toasts --- web/components/service/details/secrets-section.tsx | 1 - web/components/service/details/serverless-section.tsx | 1 - web/components/service/details/source-section.tsx | 2 -- 3 files changed, 4 deletions(-) diff --git a/web/components/service/details/secrets-section.tsx b/web/components/service/details/secrets-section.tsx index bb829df0..517a8c4b 100644 --- a/web/components/service/details/secrets-section.tsx +++ b/web/components/service/details/secrets-section.tsx @@ -184,7 +184,6 @@ export const SecretsSection = memo(function SecretsSection({ await mutate(); setPendingVars([]); setPendingDeletes([]); - toast.success("Environment variables saved"); onUpdate(); } catch { toast.error("Failed to save changes"); diff --git a/web/components/service/details/serverless-section.tsx b/web/components/service/details/serverless-section.tsx index 94814241..11fa8a4a 100644 --- a/web/components/service/details/serverless-section.tsx +++ b/web/components/service/details/serverless-section.tsx @@ -81,7 +81,6 @@ export const ServerlessSection = memo(function ServerlessSection({ wakeTimeoutSeconds: parsed.wakeTimeoutSeconds, }); onUpdate(); - toast.success("Serverless settings saved. Deploy to apply."); } catch (error) { toast.error( error instanceof Error diff --git a/web/components/service/details/source-section.tsx b/web/components/service/details/source-section.tsx index 377084f1..73ea7329 100644 --- a/web/components/service/details/source-section.tsx +++ b/web/components/service/details/source-section.tsx @@ -75,7 +75,6 @@ export const SourceSection = memo(function SourceSection({ branch, rootDir, ); - toast.success("Repository settings updated"); setIsEditing(false); onUpdate?.(); } catch (error) { @@ -99,7 +98,6 @@ export const SourceSection = memo(function SourceSection({ await updateServiceConfig(service.id, { source: { type: "image", image: image.trim() }, }); - toast.success("Docker image updated"); setIsEditing(false); onUpdate?.(); } catch (error) { From 18423a5e1f174ea0fd8fb0834492b59e17cfc5e8 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 11 Jul 2026 15:47:25 +1000 Subject: [PATCH 2/7] Default new services to unlimited resources --- cli/internal/cli/app.go | 3 --- cli/internal/cli/app_test.go | 3 +++ web/actions/projects.ts | 6 ++++-- web/components/service/details/resource-limits-section.tsx | 5 ++--- web/db/schema.ts | 4 ++-- web/lib/cli-service.ts | 3 +-- web/lib/constants.ts | 5 ----- 7 files changed, 12 insertions(+), 17 deletions(-) diff --git a/cli/internal/cli/app.go b/cli/internal/cli/app.go index 14a69ef2..29a04d96 100644 --- a/cli/internal/cli/app.go +++ b/cli/internal/cli/app.go @@ -287,9 +287,6 @@ service: image: nginx:1.27 replicas: count: 1 - resources: - cpuCores: 2 - memoryMb: 1024 ports: - port: 80 public: false diff --git a/cli/internal/cli/app_test.go b/cli/internal/cli/app_test.go index f7992cfc..f5c253c4 100644 --- a/cli/internal/cli/app_test.go +++ b/cli/internal/cli/app_test.go @@ -33,6 +33,9 @@ func TestInitCreatesManifest(t *testing.T) { if !strings.Contains(string(raw), "image: nginx:1.27") { t.Fatalf("manifest = %s", raw) } + if strings.Contains(string(raw), "resources:") { + t.Fatalf("manifest should not set default resource limits: %s", raw) + } } func TestLogsRejectsInvalidTailBeforeConfig(t *testing.T) { diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 3d8385aa..33d51dfa 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -29,7 +29,6 @@ import { workQueue, } from "@/db/schema"; import { requireDeveloperRole, verifyDeleteConfirmation } from "@/lib/auth"; -import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; import { deployServiceInternal } from "@/lib/deploy-service"; import { isObservedReady, @@ -429,7 +428,10 @@ const SERVICE_CARD_WIDTH = 320; export async function createService(input: CreateServiceInput) { await requireDeveloperRole(); const { projectId, environmentId, name, image, github } = input; - const resourceLimits = input.resourceLimits ?? DEFAULT_RESOURCE_LIMITS; + const resourceLimits = input.resourceLimits ?? { + cpuCores: null, + memoryMb: null, + }; const env = await getEnvironment(environmentId); if (!env) { throw new Error("Environment not found"); diff --git a/web/components/service/details/resource-limits-section.tsx b/web/components/service/details/resource-limits-section.tsx index bd1a52c2..06ff7ac2 100644 --- a/web/components/service/details/resource-limits-section.tsx +++ b/web/components/service/details/resource-limits-section.tsx @@ -11,7 +11,6 @@ import { NativeSelectOption, } from "@/components/ui/native-select"; import type { ServiceWithDetails as Service } from "@/db/types"; -import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; type Preset = { label: string; @@ -25,8 +24,8 @@ const PRESETS: Record = { medium: { label: "Medium (1 CPU, 512MB)", cpuCores: 1, memoryMb: 512 }, large: { label: "Large (2 CPU, 1024MB)", - cpuCores: DEFAULT_RESOURCE_LIMITS.cpuCores, - memoryMb: DEFAULT_RESOURCE_LIMITS.memoryMb, + cpuCores: 2, + memoryMb: 1024, }, xlarge: { label: "X-Large (4 CPU, 2048MB)", cpuCores: 4, memoryMb: 2048 }, custom: { label: "Custom", cpuCores: null, memoryMb: null }, diff --git a/web/db/schema.ts b/web/db/schema.ts index 86da744c..a3bc22ca 100644 --- a/web/db/schema.ts +++ b/web/db/schema.ts @@ -408,8 +408,8 @@ export const services = pgTable("services", { healthCheckRetries: integer("health_check_retries").default(3), healthCheckStartPeriod: integer("health_check_start_period").default(30), startCommand: text("start_command"), - resourceCpuLimit: real("resource_cpu_limit").default(2), - resourceMemoryLimitMb: integer("resource_memory_limit_mb").default(1024), + resourceCpuLimit: real("resource_cpu_limit"), + resourceMemoryLimitMb: integer("resource_memory_limit_mb"), serverlessEnabled: boolean("serverless_enabled").notNull().default(false), serverlessSleepAfterSeconds: integer("serverless_sleep_after_seconds") .notNull() diff --git a/web/lib/cli-service.ts b/web/lib/cli-service.ts index e41ca193..4bcd5368 100644 --- a/web/lib/cli-service.ts +++ b/web/lib/cli-service.ts @@ -23,7 +23,6 @@ import { type TechulusManifest, techulusManifestSchema, } from "@/lib/cli-manifest"; -import { DEFAULT_RESOURCE_LIMITS } from "@/lib/constants"; import { deployServiceInternal } from "@/lib/deploy-service"; import { slugify } from "@/lib/utils"; @@ -36,7 +35,7 @@ export type ManifestChange = { function getManifestResourceLimits(manifest: TechulusManifest) { const resources = manifest.service.resources; if (resources === undefined) { - return DEFAULT_RESOURCE_LIMITS; + return { cpuCores: null, memoryMb: null }; } return { diff --git a/web/lib/constants.ts b/web/lib/constants.ts index 318b66aa..88493e47 100644 --- a/web/lib/constants.ts +++ b/web/lib/constants.ts @@ -1,7 +1,2 @@ export const WIREGUARD_SUBNET_PREFIX = "10.100"; export const CONTAINER_SUBNET_PREFIX = "10.200"; - -export const DEFAULT_RESOURCE_LIMITS = { - cpuCores: 2, - memoryMb: 1024, -} as const; From ec58874cd23a364171d404b29022f205f80a9c4c Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 11 Jul 2026 16:05:14 +1000 Subject: [PATCH 3/7] Preserve limits omitted from CLI manifests --- web/lib/cli-service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/web/lib/cli-service.ts b/web/lib/cli-service.ts index 4bcd5368..a3191c1b 100644 --- a/web/lib/cli-service.ts +++ b/web/lib/cli-service.ts @@ -35,7 +35,7 @@ export type ManifestChange = { function getManifestResourceLimits(manifest: TechulusManifest) { const resources = manifest.service.resources; if (resources === undefined) { - return { cpuCores: null, memoryMb: null }; + return null; } return { @@ -557,6 +557,9 @@ async function syncResources( changes: ManifestChange[], ) { const desiredResources = getManifestResourceLimits(manifest); + if (desiredResources === null) { + return; + } const desiredCpu = desiredResources.cpuCores; const desiredMemory = desiredResources.memoryMb; From 46deaf425cd80907e32849bed523ec05a1f006e3 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Sun, 12 Jul 2026 15:39:27 +1000 Subject: [PATCH 4/7] Restrict serverless services to proxy nodes --- web/actions/projects.ts | 75 +++++++++++++++---- web/app/api/projects/[id]/services/route.ts | 1 + .../service/details/replicas-section.tsx | 27 ++++++- .../service/details/serverless-section.tsx | 7 +- web/db/types.ts | 4 +- 5 files changed, 96 insertions(+), 18 deletions(-) diff --git a/web/actions/projects.ts b/web/actions/projects.ts index 33d51dfa..a3b9ed25 100644 --- a/web/actions/projects.ts +++ b/web/actions/projects.ts @@ -1068,8 +1068,12 @@ export async function updateServiceServerlessSettings( } const configuredReplicas = await tx - .select({ count: serviceReplicas.count }) + .select({ + count: serviceReplicas.count, + serverIsProxy: servers.isProxy, + }) .from(serviceReplicas) + .innerJoin(servers, eq(serviceReplicas.serverId, servers.id)) .where(eq(serviceReplicas.serviceId, serviceId)); const totalConfiguredReplicas = configuredReplicas.reduce( (total, replica) => total + replica.count, @@ -1079,6 +1083,16 @@ export async function updateServiceServerlessSettings( if (totalConfiguredReplicas < 1) { throw new Error("Serverless services require at least one replica"); } + + if ( + configuredReplicas.some( + (replica) => replica.count > 0 && !replica.serverIsProxy, + ) + ) { + throw new Error( + "Serverless services can only be deployed to proxy nodes", + ); + } } await tx @@ -1224,20 +1238,55 @@ export async function updateServiceConfig( } if (config.replicas) { - await db - .delete(serviceReplicas) - .where(eq(serviceReplicas.serviceId, serviceId)); + const replicas = config.replicas; + await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${serviceId}))`, + ); - for (const replica of config.replicas) { - if (replica.count > 0) { - await db.insert(serviceReplicas).values({ - id: randomUUID(), - serviceId, - serverId: replica.serverId, - count: replica.count, - }); + const [currentService] = await tx + .select({ serverlessEnabled: services.serverlessEnabled }) + .from(services) + .where(eq(services.id, serviceId)) + .limit(1); + + if (currentService?.serverlessEnabled) { + const selectedServerIds = replicas + .filter((replica) => replica.count > 0) + .map((replica) => replica.serverId); + if (selectedServerIds.length > 0) { + const workerServers = await tx + .select({ id: servers.id }) + .from(servers) + .where( + and( + inArray(servers.id, selectedServerIds), + eq(servers.isProxy, false), + ), + ); + if (workerServers.length > 0) { + throw new Error( + "Disable serverless before deploying to worker nodes", + ); + } + } } - } + + await tx + .delete(serviceReplicas) + .where(eq(serviceReplicas.serviceId, serviceId)); + + for (const replica of replicas) { + if (replica.count > 0) { + await tx.insert(serviceReplicas).values({ + id: randomUUID(), + serviceId, + serverId: replica.serverId, + count: replica.count, + }); + } + } + }); } return { success: true }; diff --git a/web/app/api/projects/[id]/services/route.ts b/web/app/api/projects/[id]/services/route.ts index 105ad5d4..7f69ca9d 100644 --- a/web/app/api/projects/[id]/services/route.ts +++ b/web/app/api/projects/[id]/services/route.ts @@ -76,6 +76,7 @@ export async function GET( id: serviceReplicas.id, serverId: serviceReplicas.serverId, serverName: servers.name, + serverIsProxy: servers.isProxy, count: serviceReplicas.count, }) .from(serviceReplicas) diff --git a/web/components/service/details/replicas-section.tsx b/web/components/service/details/replicas-section.tsx index 82ea6a21..2137260c 100644 --- a/web/components/service/details/replicas-section.tsx +++ b/web/components/service/details/replicas-section.tsx @@ -145,6 +145,9 @@ export const ReplicasSection = memo(function ReplicasSection({ : Object.values(localReplicas).reduce((sum, n) => sum + n, 0); const formatServerRole = (server: ServerInfo) => server.isProxy ? "Proxy node" : "Worker node"; + const workerUnavailableReason = service.serverlessEnabled + ? "Disable serverless before deploying to worker nodes" + : null; if (service.stateful) { return ( @@ -209,11 +212,17 @@ export const ReplicasSection = memo(function ReplicasSection({ type="button" key={server.id} onClick={() => setSelectedServerId(server.id)} + disabled={service.serverlessEnabled && !server.isProxy} + title={ + service.serverlessEnabled && !server.isProxy + ? workerUnavailableReason || undefined + : undefined + } className={`flex items-center gap-4 p-3 rounded-md text-left transition-colors ${ selectedServerId === server.id ? "bg-primary text-primary-foreground" : "bg-muted hover:bg-muted/80" - }`} + } disabled:cursor-not-allowed disabled:opacity-50`} >

{server.name}

@@ -280,6 +289,11 @@ export const ReplicasSection = memo(function ReplicasSection({ {servers.map((server) => (
@@ -299,7 +313,10 @@ export const ReplicasSection = memo(function ReplicasSection({ (localReplicas[server.id] || 0) - 1, ) } - disabled={(localReplicas[server.id] || 0) <= 0} + disabled={ + (localReplicas[server.id] || 0) <= 0 || + (service.serverlessEnabled && !server.isProxy) + } > - @@ -312,6 +329,7 @@ export const ReplicasSection = memo(function ReplicasSection({ parseInt(e.target.value, 10) || 0, ) } + disabled={service.serverlessEnabled && !server.isProxy} min={0} max={10} className="w-16 h-8 text-center" @@ -326,7 +344,10 @@ export const ReplicasSection = memo(function ReplicasSection({ (localReplicas[server.id] || 0) + 1, ) } - disabled={(localReplicas[server.id] || 0) >= 10} + disabled={ + (localReplicas[server.id] || 0) >= 10 || + (service.serverlessEnabled && !server.isProxy) + } > + diff --git a/web/components/service/details/serverless-section.tsx b/web/components/service/details/serverless-section.tsx index 11fa8a4a..a2f4408d 100644 --- a/web/components/service/details/serverless-section.tsx +++ b/web/components/service/details/serverless-section.tsx @@ -33,9 +33,14 @@ export const ServerlessSection = memo(function ServerlessSection({ ), [service.ports], ); + const hasWorkerReplica = service.configuredReplicas.some( + (replica) => replica.count > 0 && !replica.serverIsProxy, + ); const unavailableReason = !hasPublicHttpEndpoint ? "Add a public HTTP port with a domain to enable serverless" - : null; + : hasWorkerReplica + ? "Serverless services can only be deployed to proxy nodes" + : null; const optionsDisabled = !!unavailableReason || isSaving; const parsed = useMemo( diff --git a/web/db/types.ts b/web/db/types.ts index e5674209..32c4d446 100644 --- a/web/db/types.ts +++ b/web/db/types.ts @@ -55,7 +55,9 @@ export type HealthStats = { export type ServiceWithDetails = Service & { ports: ServicePort[]; - configuredReplicas: Array; + configuredReplicas: Array< + ServiceReplica & { serverName: string; serverIsProxy: boolean } + >; deployments: Array< Deployment & { server: Pick | null; From 95c698e98512d497891f2e2af9a61412645d0971 Mon Sep 17 00:00:00 2001 From: Techulus Agent <291950465+techulus-agent@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:12:16 +1000 Subject: [PATCH 5/7] Retry stale serverless upstreams --- agent/internal/serverless/gateway.go | 96 +++++++++++-- agent/internal/serverless/gateway_test.go | 159 ++++++++++++++++++++++ 2 files changed, 245 insertions(+), 10 deletions(-) diff --git a/agent/internal/serverless/gateway.go b/agent/internal/serverless/gateway.go index 9bc4a7b3..cc81b367 100644 --- a/agent/internal/serverless/gateway.go +++ b/agent/internal/serverless/gateway.go @@ -20,8 +20,9 @@ import ( ) const ( - GatewayPort = 18080 - upstreamCacheTTL = 10 * time.Second + GatewayPort = 18080 + upstreamCacheTTL = 10 * time.Second + proxyRetryResolveTimeout = 2 * time.Second ) var ( @@ -183,28 +184,68 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) { } upstream := upstreams[g.nextIndex(len(upstreams))] + proxyErr := g.proxyOnce(w, r, upstream) + if proxyErr == nil { + return + } + if requestCancelled(r, proxyErr) { + return + } + + log.Printf("[serverless-gateway] proxy error for host %s to %s: %v", host, upstream.Url, proxyErr) + g.evictUpstreams(host) + if !retryableProxyRequest(r) { + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + + retryCtx, cancel := context.WithTimeout(r.Context(), proxyRetryResolveTimeout) + defer cancel() + retryUpstreams, err := g.getUpstreams(retryCtx, host) + if err != nil || len(retryUpstreams) == 0 { + if r.Context().Err() != nil { + return + } + if err == nil { + err = errors.New("no upstreams after cache eviction") + } + log.Printf("[serverless-gateway] proxy retry resolution failed for host %s: %v", host, err) + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + + retryUpstream := selectRetryUpstream(retryUpstreams, upstream.Url, g.nextIndex(len(retryUpstreams))) + proxyErr = g.proxyOnce(w, r, retryUpstream) + if proxyErr == nil || requestCancelled(r, proxyErr) { + return + } + log.Printf("[serverless-gateway] proxy retry failed for host %s to %s: %v", host, retryUpstream.Url, proxyErr) + g.evictUpstreams(host) + http.Error(w, "bad gateway", http.StatusBadGateway) +} + +func (g *Gateway) proxyOnce(w http.ResponseWriter, r *http.Request, upstream agenthttp.ServerlessUpstream) error { target, err := url.Parse("http://" + upstream.Url) if err != nil { - log.Printf("[serverless-gateway] invalid upstream %q for host %s: %v", upstream.Url, host, err) - http.Error(w, "bad upstream", http.StatusBadGateway) - return + return fmt.Errorf("invalid upstream %q: %w", upstream.Url, err) } proxy := httputil.NewSingleHostReverseProxy(target) originalDirector := proxy.Director originalHost := r.Host + originalProto := forwardedProto(r) proxy.Director = func(req *http.Request) { originalDirector(req) req.Host = originalHost req.Header.Set("X-Forwarded-Host", originalHost) - req.Header.Set("X-Forwarded-Proto", forwardedProto(r)) + req.Header.Set("X-Forwarded-Proto", originalProto) } - proxy.ErrorHandler = func(w http.ResponseWriter, req *http.Request, err error) { - log.Printf("[serverless-gateway] proxy error for host %s to %s: %v", host, upstream.Url, err) - g.evictUpstreams(host) - http.Error(w, "bad gateway", http.StatusBadGateway) + var proxyErr error + proxy.ErrorHandler = func(_ http.ResponseWriter, _ *http.Request, err error) { + proxyErr = err } proxy.ServeHTTP(w, r) + return proxyErr } func (g *Gateway) getUpstreams(ctx context.Context, host string) ([]agenthttp.ServerlessUpstream, error) { @@ -1307,6 +1348,41 @@ func (g *Gateway) nextIndex(length int) int { return int(atomic.AddUint64(&g.counter, 1)-1) % length } +func retryableProxyRequest(r *http.Request) bool { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + return false + } + if r.ContentLength != 0 || (r.Body != nil && r.Body != http.NoBody) { + return false + } + return !headerContainsToken(r.Header, "Connection", "upgrade") && r.Header.Get("Upgrade") == "" +} + +func requestCancelled(r *http.Request, err error) bool { + return r.Context().Err() != nil || errors.Is(err, context.Canceled) +} + +func selectRetryUpstream(upstreams []agenthttp.ServerlessUpstream, failedURL string, start int) agenthttp.ServerlessUpstream { + for offset := range len(upstreams) { + upstream := upstreams[(start+offset)%len(upstreams)] + if upstream.Url != failedURL { + return upstream + } + } + return upstreams[start%len(upstreams)] +} + +func headerContainsToken(header http.Header, name string, token string) bool { + for _, value := range header.Values(name) { + for part := range strings.SplitSeq(value, ",") { + if strings.EqualFold(strings.TrimSpace(part), token) { + return true + } + } + } + return false +} + func normalizeHost(host string) string { if h, _, err := net.SplitHostPort(host); err == nil { return strings.ToLower(strings.TrimSpace(h)) diff --git a/agent/internal/serverless/gateway_test.go b/agent/internal/serverless/gateway_test.go index 45dc0e12..aa07ec45 100644 --- a/agent/internal/serverless/gateway_test.go +++ b/agent/internal/serverless/gateway_test.go @@ -5,9 +5,14 @@ import ( "context" "errors" "log" + "net" + "net/http" + "net/http/httptest" "slices" + "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -164,6 +169,160 @@ func assertProbeSet(t *testing.T, got []string, want ...string) { } } +func TestServeHTTPRetriesStaleUpstreamForSafeRequest(t *testing.T) { + var requests atomic.Int32 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(backend.Close) + + gateway := testProxyGateway(t, backend.Listener.Addr().String()) + gateway.cacheUpstreams("app.example.com", []agenthttp.ServerlessUpstream{{Url: "127.0.0.1:0"}}) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://app.example.com/health", nil) + gateway.ServeHTTP(response, request) + + if response.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent) + } + if requests.Load() != 1 { + t.Fatalf("backend requests = %d, want 1", requests.Load()) + } +} + +func TestServeHTTPDoesNotRetryUnsafeRequest(t *testing.T) { + var requests atomic.Int32 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(backend.Close) + + gateway := testProxyGateway(t, backend.Listener.Addr().String()) + gateway.cacheUpstreams("app.example.com", []agenthttp.ServerlessUpstream{{Url: "127.0.0.1:0"}}) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "http://app.example.com/items", strings.NewReader("item")) + gateway.ServeHTTP(response, request) + + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway) + } + if requests.Load() != 0 { + t.Fatalf("backend requests = %d, want 0", requests.Load()) + } +} + +func TestServeHTTPDoesNotRetryUpgradeRequest(t *testing.T) { + var requests atomic.Int32 + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(backend.Close) + + gateway := testProxyGateway(t, backend.Listener.Addr().String()) + gateway.cacheUpstreams("app.example.com", []agenthttp.ServerlessUpstream{{Url: "127.0.0.1:0"}}) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://app.example.com/socket", nil) + request.Header.Set("Connection", "keep-alive, Upgrade") + request.Header.Set("Upgrade", "websocket") + gateway.ServeHTTP(response, request) + + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway) + } + if requests.Load() != 0 { + t.Fatalf("backend requests = %d, want 0", requests.Load()) + } +} + +func TestServeHTTPDoesNotEvictCacheWhenRequestIsCancelled(t *testing.T) { + gateway := testProxyGateway(t, "127.0.0.1:0") + cached := []agenthttp.ServerlessUpstream{{Url: "127.0.0.1:0"}} + gateway.cacheUpstreams("app.example.com", cached) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + request := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil).WithContext(ctx) + gateway.ServeHTTP(httptest.NewRecorder(), request) + + upstreams, ok := gateway.cachedUpstreams("app.example.com") + if !ok || len(upstreams) != 1 || upstreams[0].Url != cached[0].Url { + t.Fatalf("cached upstreams = %+v, ok=%t; want original cache entry", upstreams, ok) + } +} + +func TestServeHTTPRetriesOnlyOnce(t *testing.T) { + retryAddress, accepts := acceptAndCloseAddress(t) + gateway := testProxyGateway(t, retryAddress) + gateway.cacheUpstreams("app.example.com", []agenthttp.ServerlessUpstream{{Url: "127.0.0.1:0"}}) + + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodGet, "http://app.example.com/", nil) + gateway.ServeHTTP(response, request) + + if response.Code != http.StatusBadGateway { + t.Fatalf("status = %d, want %d", response.Code, http.StatusBadGateway) + } + if _, ok := gateway.cachedUpstreams("app.example.com"); ok { + t.Fatal("failed retry left upstream cache populated") + } + if accepts.Load() != 1 { + t.Fatalf("retry upstream connections = %d, want 1", accepts.Load()) + } +} + +func testProxyGateway(t *testing.T, upstreamAddress string) *Gateway { + t.Helper() + host, portText, err := net.SplitHostPort(upstreamAddress) + if err != nil { + t.Fatalf("split upstream address: %v", err) + } + port, err := strconv.Atoi(portText) + if err != nil { + t.Fatalf("parse upstream port: %v", err) + } + state := testExpectedState("running") + state.Containers[0].IPAddress = host + state.Serverless.Routes[0].Port = port + state.Serverless.Routes[0].Upstreams = nil + runtime := &fakeRuntime{ + state: state, + containers: []container.Container{{ + ID: "ctr-local", + State: "running", + DeploymentID: "dep_local", + ServiceID: "svc_1", + }}, + } + return NewGateway(runtime) +} + +func acceptAndCloseAddress(t *testing.T) (string, *atomic.Int32) { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen for closing upstream: %v", err) + } + t.Cleanup(func() { listener.Close() }) + var accepts atomic.Int32 + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + accepts.Add(1) + conn.Close() + } + }() + return listener.Addr().String(), &accepts +} + func TestGetUpstreamsReturnsWhenFollowerContextIsCancelled(t *testing.T) { state := testExpectedState("stopped") g := NewGateway(&fakeRuntime{state: state}) From bc1dcba362b98387be23867838cbf0b1cfc371c4 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 12 Jul 2026 19:16:42 +1000 Subject: [PATCH 6/7] Add service metrics page --- .../services/[serviceId]/metrics/page.tsx | 5 ++ .../details/service-details-overview.tsx | 52 ++++++++--- .../service/details/service-metrics-page.tsx | 90 +++++++++++++++++++ .../service/service-layout-client.tsx | 2 + 4 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/metrics/page.tsx create mode 100644 web/components/service/details/service-metrics-page.tsx diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/metrics/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/metrics/page.tsx new file mode 100644 index 00000000..926a4121 --- /dev/null +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/metrics/page.tsx @@ -0,0 +1,5 @@ +import { ServiceMetricsPage } from "@/components/service/details/service-metrics-page"; + +export default function MetricsPage() { + return ; +} diff --git a/web/components/service/details/service-details-overview.tsx b/web/components/service/details/service-details-overview.tsx index 479fc5dd..6eeb3449 100644 --- a/web/components/service/details/service-details-overview.tsx +++ b/web/components/service/details/service-details-overview.tsx @@ -33,7 +33,7 @@ import { isObservedStarting } from "@/lib/deployment-status"; import { fetcher } from "@/lib/fetcher"; import { cn } from "@/lib/utils"; -type ServiceMetricsResponse = { +export type ServiceMetricsResponse = { metricsEnabled: boolean; range: string; windowStart: string; @@ -101,7 +101,7 @@ type ChartRow = { totalRequests: number; } & Record; -type ServiceChartMode = "requests" | "latency" | "traffic" | "resources"; +export type ServiceChartMode = "requests" | "latency" | "traffic" | "resources"; type StatusSeries = { status: string; @@ -203,18 +203,26 @@ export function ServiceDetailsOverview({ service }: { service: Service }) { ); } -function ServiceMetricsPanel({ +export function ServiceMetricsPanel({ hasPublicHttp, stats, error, isLoading, + fixedMode, + rangeLabel = "24h", + useRangeAwareTimeAxis = false, }: { hasPublicHttp: boolean; stats?: ServiceMetricsResponse; error?: unknown; isLoading: boolean; + fixedMode?: ServiceChartMode; + rangeLabel?: string; + useRangeAwareTimeAxis?: boolean; }) { - const [chartMode, setChartMode] = useState("requests"); + const [selectedMode, setSelectedMode] = + useState("requests"); + const chartMode = fixedMode ?? selectedMode; const chartRows = useMemo(() => buildChartRows(stats), [stats]); const statusSeries = useMemo(() => buildStatusSeries(stats), [stats]); const activeSeries = useMemo( @@ -231,12 +239,23 @@ function ServiceMetricsPanel({ stats, chartRows, hasMetricData, + rangeLabel, ), - [chartMode, stats, chartRows, hasMetricData], + [chartMode, stats, chartRows, hasMetricData, rangeLabel], ); return (
-
+
+ {fixedMode ? ( +

{fixedMode}

+ ) : null}
{isLoading ? (
@@ -256,11 +275,13 @@ function ServiceMetricsPanel({
)}
- + {fixedMode ? null : ( + + )}
@@ -307,7 +328,11 @@ function ServiceMetricsPanel({ minTickGap={32} tickLine={false} axisLine={false} - tickFormatter={(value) => formatCompactDate(value)} + tickFormatter={(value) => + !useRangeAwareTimeAxis || rangeLabel === "7d" + ? formatCompactDate(value) + : formatCompactDateTime(value) + } className="text-xs" /> = { + "1h": "Last hour", + "6h": "Last 6 hours", + "24h": "Last 24 hours", + "7d": "Last 7 days", +}; +const MODES: ServiceChartMode[] = [ + "requests", + "latency", + "traffic", + "resources", +]; + +export function ServiceMetricsPage() { + const { service } = useService(); + const [range, setRange] = useQueryState( + "range", + parseAsStringLiteral(LOG_TIME_RANGES).withDefault("24h"), + ); + const { data, error, isLoading } = useSWR( + `/api/services/${service.id}/metrics?range=${range}`, + fetcher, + { refreshInterval: 60000, keepPreviousData: true }, + ); + const hasPublicHttp = + service.ports?.some( + (port) => port.isPublic && port.domain && port.protocol === "http", + ) ?? false; + + return ( +
+
+ + }> + {LABELS[range]} + + + setRange(value as typeof range)} + > + {LOG_TIME_RANGES.map((value) => ( + + {LABELS[value]} + + ))} + + + +
+
+ {MODES.map((mode) => ( +
+ +
+ ))} +
+
+ ); +} diff --git a/web/components/service/service-layout-client.tsx b/web/components/service/service-layout-client.tsx index 67fafb1c..7d4d0fe4 100644 --- a/web/components/service/service-layout-client.tsx +++ b/web/components/service/service-layout-client.tsx @@ -130,6 +130,7 @@ export function ServiceLayoutClient({ const basePath = `/dashboard/projects/${projectSlug}/${envName}/services/${service?.id}`; const isConstrainedTab = + pathname.includes("/metrics") || pathname.includes("/configuration") || pathname.includes("/builds") || pathname.includes("/backups"); @@ -139,6 +140,7 @@ export function ServiceLayoutClient({ const tabs = [ { name: "Deployments", href: basePath }, { name: "Configuration", href: `${basePath}/configuration` }, + { name: "Metrics", href: `${basePath}/metrics` }, { name: "Logs", href: `${basePath}/logs` }, ...(hasPublicPorts ? [{ name: "Requests", href: `${basePath}/requests` }] From 66f9779c67f22b3dab8954a5291a14e8f52ac369 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 12 Jul 2026 19:17:18 +1000 Subject: [PATCH 7/7] Default service metrics to one hour --- web/components/service/details/service-metrics-page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/components/service/details/service-metrics-page.tsx b/web/components/service/details/service-metrics-page.tsx index 49c4b0c0..98b33739 100644 --- a/web/components/service/details/service-metrics-page.tsx +++ b/web/components/service/details/service-metrics-page.tsx @@ -37,7 +37,7 @@ export function ServiceMetricsPage() { const { service } = useService(); const [range, setRange] = useQueryState( "range", - parseAsStringLiteral(LOG_TIME_RANGES).withDefault("24h"), + parseAsStringLiteral(LOG_TIME_RANGES).withDefault("1h"), ); const { data, error, isLoading } = useSWR( `/api/services/${service.id}/metrics?range=${range}`,