From 4104c31fe22da8100d31b0b38ac952938b7f490f Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 25 Apr 2026 09:07:46 +1000 Subject: [PATCH 1/2] Update agent status processing --- web/app/api/inngest/route.ts | 2 -- web/app/api/v1/agent/status/route.ts | 21 +++------------------ web/lib/inngest/events/agent.ts | 10 ---------- web/lib/inngest/events/index.ts | 5 +---- web/lib/inngest/functions/agent-status.ts | 14 -------------- web/lib/inngest/functions/index.ts | 1 - 6 files changed, 4 insertions(+), 49 deletions(-) delete mode 100644 web/lib/inngest/events/agent.ts delete mode 100644 web/lib/inngest/functions/agent-status.ts diff --git a/web/app/api/inngest/route.ts b/web/app/api/inngest/route.ts index ad5a389..cc7f78e 100644 --- a/web/app/api/inngest/route.ts +++ b/web/app/api/inngest/route.ts @@ -19,7 +19,6 @@ import { buildTriggerWorkflow, backupTriggerWorkflow, restoreTriggerWorkflow, - processAgentStatus, } from "@/lib/inngest/functions"; export const { GET, POST, PUT } = serve({ @@ -43,6 +42,5 @@ export const { GET, POST, PUT } = serve({ buildTriggerWorkflow, backupTriggerWorkflow, restoreTriggerWorkflow, - processAgentStatus, ], }); diff --git a/web/app/api/v1/agent/status/route.ts b/web/app/api/v1/agent/status/route.ts index e609b75..e7b8dbd 100644 --- a/web/app/api/v1/agent/status/route.ts +++ b/web/app/api/v1/agent/status/route.ts @@ -1,10 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { db } from "@/db"; -import { servers } from "@/db/schema"; -import { eq } from "drizzle-orm"; import { verifyAgentRequest } from "@/lib/agent-auth"; -import { inngest } from "@/lib/inngest/client"; -import type { StatusReport } from "@/lib/agent-status"; +import { applyStatusReport, type StatusReport } from "@/lib/agent-status"; export async function POST(request: NextRequest) { const body = await request.text(); @@ -29,18 +25,7 @@ export async function POST(request: NextRequest) { const { serverId } = auth; - await db - .update(servers) - .set({ lastHeartbeat: new Date(), status: "online" }) - .where(eq(servers.id, serverId)); + await applyStatusReport(serverId, data.statusReport); - await inngest.send({ - name: "agent/status-reported", - data: { - serverId, - report: data.statusReport, - }, - }); - - return NextResponse.json({ ok: true }, { status: 202 }); + return NextResponse.json({ ok: true }); } diff --git a/web/lib/inngest/events/agent.ts b/web/lib/inngest/events/agent.ts deleted file mode 100644 index 1cfc45a..0000000 --- a/web/lib/inngest/events/agent.ts +++ /dev/null @@ -1,10 +0,0 @@ -import type { StatusReport } from "@/lib/agent-status"; - -export type AgentEvents = { - "agent/status-reported": { - data: { - serverId: string; - report: StatusReport; - }; - }; -}; diff --git a/web/lib/inngest/events/index.ts b/web/lib/inngest/events/index.ts index f60549e..c15c495 100644 --- a/web/lib/inngest/events/index.ts +++ b/web/lib/inngest/events/index.ts @@ -3,18 +3,15 @@ export type { MigrationEvents } from "./migration"; export type { BackupEvents } from "./backup"; export type { RestoreEvents } from "./restore"; export type { BuildEvents } from "./build"; -export type { AgentEvents } from "./agent"; import type { RolloutEvents } from "./rollout"; import type { MigrationEvents } from "./migration"; import type { BackupEvents } from "./backup"; import type { RestoreEvents } from "./restore"; import type { BuildEvents } from "./build"; -import type { AgentEvents } from "./agent"; export type Events = RolloutEvents & MigrationEvents & BackupEvents & RestoreEvents & - BuildEvents & - AgentEvents; + BuildEvents; diff --git a/web/lib/inngest/functions/agent-status.ts b/web/lib/inngest/functions/agent-status.ts deleted file mode 100644 index a6fd729..0000000 --- a/web/lib/inngest/functions/agent-status.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { inngest } from "../client"; -import { applyStatusReport } from "@/lib/agent-status"; - -export const processAgentStatus = inngest.createFunction( - { - id: "process-agent-status", - concurrency: [{ limit: 5, key: "event.data.serverId" }], - }, - { event: "agent/status-reported" }, - async ({ event }) => { - const { serverId, report } = event.data; - await applyStatusReport(serverId, report); - }, -); diff --git a/web/lib/inngest/functions/index.ts b/web/lib/inngest/functions/index.ts index f89bdd1..a5f4a94 100644 --- a/web/lib/inngest/functions/index.ts +++ b/web/lib/inngest/functions/index.ts @@ -16,4 +16,3 @@ export { buildWorkflow } from "./build-workflow"; export { buildTriggerWorkflow } from "./build-trigger-workflow"; export { backupTriggerWorkflow } from "./backup-trigger-workflow"; export { restoreTriggerWorkflow } from "./restore-trigger-workflow"; -export { processAgentStatus } from "./agent-status"; From b16e5e81077a485ae7ebdd144574f047c5e5dd39 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 25 Apr 2026 21:19:46 +1000 Subject: [PATCH 2/2] Fix deployment config bugs --- agent/internal/agent/agent.go | 68 ++++++----- agent/internal/agent/drift.go | 52 ++++++++- agent/internal/agent/run.go | 37 ++++-- agent/internal/agent/workqueue.go | 2 +- .../[env]/services/[serviceId]/page.tsx | 33 +++--- web/app/api/v1/agent/work-queue/route.ts | 5 +- .../service/details/deployment-progress.tsx | 11 +- .../details/pending-changes-banner.tsx | 13 ++- web/lib/agent-status.ts | 14 +-- web/lib/inngest/functions/rollout-helpers.ts | 1 + web/lib/inngest/functions/rollout-workflow.ts | 45 ++++++-- web/lib/service-config.ts | 106 +++++++++++++----- 12 files changed, 277 insertions(+), 110 deletions(-) diff --git a/agent/internal/agent/agent.go b/agent/internal/agent/agent.go index 8b46348..26cbb5b 100644 --- a/agent/internal/agent/agent.go +++ b/agent/internal/agent/agent.go @@ -59,25 +59,29 @@ type ActualState struct { } type Agent struct { - state AgentState - stateMutex sync.RWMutex - Client *agenthttp.Client - Reconciler *reconcile.Reconciler - Config *Config - PublicIP string - PrivateIP string - DataDir string - expectedState *agenthttp.ExpectedState - processingStart time.Time - LogCollector *logs.Collector - TraefikLogCollector *logs.TraefikCollector - Builder *build.Builder - isBuilding bool - buildMutex sync.Mutex - currentBuildID string - IsProxy bool - dnsInSync bool - DisableDNS bool + state AgentState + stateMutex sync.RWMutex + reconcileRequested chan struct{} + statusReportRequested chan string + refreshMutex sync.Mutex + pendingExpectedStateRefresh bool + Client *agenthttp.Client + Reconciler *reconcile.Reconciler + Config *Config + PublicIP string + PrivateIP string + DataDir string + expectedState *agenthttp.ExpectedState + processingStart time.Time + LogCollector *logs.Collector + TraefikLogCollector *logs.TraefikCollector + Builder *build.Builder + isBuilding bool + buildMutex sync.Mutex + currentBuildID string + IsProxy bool + dnsInSync bool + DisableDNS bool } func NewAgent( @@ -92,18 +96,20 @@ func NewAgent( disableDNS bool, ) *Agent { return &Agent{ - state: StateIdle, - Client: client, - Reconciler: reconciler, - Config: config, - PublicIP: publicIP, - PrivateIP: privateIP, - DataDir: dataDir, - LogCollector: logCollector, - TraefikLogCollector: traefikLogCollector, - Builder: builder, - IsProxy: isProxy, - DisableDNS: disableDNS, + state: StateIdle, + reconcileRequested: make(chan struct{}, 1), + statusReportRequested: make(chan string, 1), + Client: client, + Reconciler: reconciler, + Config: config, + PublicIP: publicIP, + PrivateIP: privateIP, + DataDir: dataDir, + LogCollector: logCollector, + TraefikLogCollector: traefikLogCollector, + Builder: builder, + IsProxy: isProxy, + DisableDNS: disableDNS, } } diff --git a/agent/internal/agent/drift.go b/agent/internal/agent/drift.go index 81e8002..021048c 100644 --- a/agent/internal/agent/drift.go +++ b/agent/internal/agent/drift.go @@ -24,6 +24,48 @@ func (a *Agent) Tick() { } } +func (a *Agent) RequestReconcile(reason string) { + if a.GetState() == StateProcessing { + a.requestExpectedStateRefresh() + log.Printf("[reconcile] refresh requested during processing: %s", reason) + } else { + log.Printf("[reconcile] immediate reconcile requested: %s", reason) + } + + select { + case a.reconcileRequested <- struct{}{}: + default: + } +} + +func (a *Agent) requestExpectedStateRefresh() { + a.refreshMutex.Lock() + defer a.refreshMutex.Unlock() + a.pendingExpectedStateRefresh = true +} + +func (a *Agent) consumeExpectedStateRefresh() bool { + a.refreshMutex.Lock() + defer a.refreshMutex.Unlock() + + if !a.pendingExpectedStateRefresh { + return false + } + + a.pendingExpectedStateRefresh = false + return true +} + +func (a *Agent) transitionToIdle() { + a.SetState(StateIdle) + if a.consumeExpectedStateRefresh() { + log.Printf("[processing] fetching latest expected state after pending refresh") + // A deploy wake can arrive while processing a previous snapshot. Run one + // immediate idle pass after processing to pick up the latest expected state. + a.handleIdle() + } +} + func (a *Agent) handleIdle() { expected, fromCache, err := a.Client.GetExpectedStateWithFallback() if err != nil { @@ -61,14 +103,14 @@ func (a *Agent) handleIdle() { func (a *Agent) handleProcessing() { if time.Since(a.processingStart) > ProcessingTimeout { log.Printf("[processing] timeout after %v, forcing transition to IDLE", ProcessingTimeout) - a.SetState(StateIdle) + a.transitionToIdle() return } actual, err := a.getActualState() if err != nil { log.Printf("[processing] failed to get actual state: %v", err) - a.SetState(StateIdle) + a.transitionToIdle() return } @@ -76,16 +118,18 @@ func (a *Agent) handleProcessing() { if len(a.detectChanges(a.expectedState, actual)) == 0 { log.Printf("[processing] state converged, transitioning to IDLE") - a.SetState(StateIdle) + a.transitionToIdle() return } err = a.reconcileOne(actual) if err != nil { log.Printf("[processing] reconciliation failed: %v, transitioning to IDLE", err) - a.SetState(StateIdle) + a.transitionToIdle() return } + + a.RequestStatusReport("reconcile completed") } func (a *Agent) updateDnsInSync(expected *agenthttp.ExpectedState, actual *ActualState) { diff --git a/agent/internal/agent/run.go b/agent/internal/agent/run.go index 489bde2..0760dd2 100644 --- a/agent/internal/agent/run.go +++ b/agent/internal/agent/run.go @@ -56,6 +56,13 @@ func (a *Agent) Run(ctx context.Context) { return case <-ticker.C: a.Tick() + case <-a.reconcileRequested: + if a.GetState() == StateProcessing { + a.requestExpectedStateRefresh() + } else { + a.consumeExpectedStateRefresh() + } + a.Tick() case <-logTickerC: a.CollectLogs() case <-cleanupTickerC: @@ -65,10 +72,7 @@ func (a *Agent) Run(ctx context.Context) { } func (a *Agent) StatusReportLoop(ctx context.Context) { - report := a.BuildStatusReport(true) - if err := a.Client.ReportStatus(report); err != nil { - log.Printf("[status] failed to report: %v", err) - } + a.reportStatus("startup") ticker := time.NewTicker(WorkQueueStatusInterval) defer ticker.Stop() @@ -78,14 +82,31 @@ func (a *Agent) StatusReportLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - report := a.BuildStatusReport(true) - if err := a.Client.ReportStatus(report); err != nil { - log.Printf("[status] failed to report: %v", err) - } + a.reportStatus("periodic") + case reason := <-a.statusReportRequested: + a.reportStatus(reason) } } } +func (a *Agent) RequestStatusReport(reason string) { + log.Printf("[status] immediate report requested: %s", reason) + select { + case a.statusReportRequested <- reason: + default: + log.Printf("[status] immediate report already queued, dropping reason: %s", reason) + } +} + +func (a *Agent) reportStatus(reason string) { + report := a.BuildStatusReport(true) + if err := a.Client.ReportStatus(report); err != nil { + log.Printf("[status] failed to report (%s): %v", reason, err) + return + } + log.Printf("[status] reported (%s)", reason) +} + func (a *Agent) WorkQueueLoop(ctx context.Context) { for { select { diff --git a/agent/internal/agent/workqueue.go b/agent/internal/agent/workqueue.go index c0bd36e..ffc4adc 100644 --- a/agent/internal/agent/workqueue.go +++ b/agent/internal/agent/workqueue.go @@ -28,7 +28,7 @@ func (a *Agent) ProcessWorkQueue() { case "stop": processErr = a.ProcessStop(item) case "deploy": - log.Printf("[work-queue] deploy handled via expected state reconciliation, marking complete") + a.RequestReconcile("deploy work item " + Truncate(item.ID, 8)) case "force_cleanup": processErr = a.ProcessForceCleanup(item) case "cleanup_volumes": diff --git a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx index 0314f07..55850e3 100644 --- a/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx +++ b/web/app/(dashboard)/dashboard/projects/[slug]/[env]/services/[serviceId]/page.tsx @@ -1,23 +1,23 @@ "use client"; -import { useState } from "react"; -import { toast } from "sonner"; import { + AlertTriangleIcon, ChevronDownIcon, + PlayIcon, RefreshCwIcon, RotateCcwIcon, - PlayIcon, StopCircleIcon, Trash2Icon, - AlertTriangleIcon, } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { useSWRConfig } from "swr"; import { deleteDeployments, deployService, restartService, stopService, } from "@/actions/projects"; -import { useService } from "@/components/service/service-layout-client"; import { DeploymentCanvas } from "@/components/service/details/deployment-canvas"; import { DeploymentProgress, @@ -25,15 +25,7 @@ import { } from "@/components/service/details/deployment-progress"; import { PendingChangesBanner } from "@/components/service/details/pending-changes-banner"; import { RolloutHistory } from "@/components/service/details/rollout-history"; -import { Button } from "@/components/ui/button"; -import { ButtonGroup } from "@/components/ui/button-group"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; +import { useService } from "@/components/service/service-layout-client"; import { AlertDialog, AlertDialogAction, @@ -45,12 +37,22 @@ import { AlertDialogMedia, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; type ConfirmAction = "redeploy" | "stop" | "delete" | null; export default function DeploymentsPage() { const { service, pendingChanges, projectSlug, envName, onUpdate } = useService(); + const { mutate: mutateCache } = useSWRConfig(); const [isLoading, setIsLoading] = useState(null); const [confirmAction, setConfirmAction] = useState(null); @@ -68,6 +70,9 @@ export default function DeploymentsPage() { if (successMessage) { toast.success(successMessage); } + if (actionName === "redeploy" || actionName === "start") { + await mutateCache(`/api/services/${service.id}/rollouts`); + } onUpdate(); } catch (error) { const errorMessage = diff --git a/web/app/api/v1/agent/work-queue/route.ts b/web/app/api/v1/agent/work-queue/route.ts index 4481762..ac06f04 100644 --- a/web/app/api/v1/agent/work-queue/route.ts +++ b/web/app/api/v1/agent/work-queue/route.ts @@ -5,7 +5,10 @@ import { eq, and, inArray } from "drizzle-orm"; import { verifyAgentRequest } from "@/lib/agent-auth"; const MAX_TIMEOUT = 30000; -const POLL_INTERVAL = 2000; +// Short interval keeps deploy wake latency low for now, but increases empty +// polling load. Replace with Postgres LISTEN/NOTIFY or another wake mechanism +// before scaling this broadly. +const POLL_INTERVAL = 500; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/web/components/service/details/deployment-progress.tsx b/web/components/service/details/deployment-progress.tsx index ecb34d8..6e11386 100644 --- a/web/components/service/details/deployment-progress.tsx +++ b/web/components/service/details/deployment-progress.tsx @@ -1,18 +1,18 @@ "use client"; -import { memo, useMemo, useState, useEffect, useRef } from "react"; -import { useRouter } from "next/navigation"; import { Loader2, XCircle } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { memo, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; -import { abortRollout } from "@/actions/projects"; import { cancelMigration } from "@/actions/migrations"; +import { abortRollout } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import type { ConfigChange } from "@/lib/service-config"; import type { DeploymentStatus, ServiceWithDetails as Service, } from "@/db/types"; +import type { ConfigChange } from "@/lib/service-config"; type StageInfo = { id: string; @@ -278,6 +278,9 @@ export const DeploymentProgress = memo(function DeploymentProgress({ const isMigrationFailed = service.migrationStatus === "failed"; let status = currentStage?.label || "Deploying"; + if (barState.stage === "health_check" && !service.healthCheckCmd) { + status = "Starting container"; + } if (isMigrating && service.migrationStatus) { status = MIGRATION_STAGES[service.migrationStatus] || diff --git a/web/components/service/details/pending-changes-banner.tsx b/web/components/service/details/pending-changes-banner.tsx index 5dcc18d..72504a0 100644 --- a/web/components/service/details/pending-changes-banner.tsx +++ b/web/components/service/details/pending-changes-banner.tsx @@ -1,14 +1,15 @@ "use client"; -import { memo, useState } from "react"; -import { useRouter } from "next/navigation"; import { AlertTriangle, ArrowRight, Rocket } from "lucide-react"; -import { deployService } from "@/actions/projects"; +import { useRouter } from "next/navigation"; +import { memo, useState } from "react"; +import { useSWRConfig } from "swr"; import { triggerBuild } from "@/actions/builds"; +import { deployService } from "@/actions/projects"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; -import type { ConfigChange } from "@/lib/service-config"; import type { ServiceWithDetails as Service } from "@/db/types"; +import type { ConfigChange } from "@/lib/service-config"; interface PendingChangesBannerProps { service: Service; @@ -28,6 +29,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ barMode, }: PendingChangesBannerProps) { const router = useRouter(); + const { mutate } = useSWRConfig(); const [isDeploying, setIsDeploying] = useState(false); const totalReplicas = service.autoPlace @@ -52,6 +54,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({ ); } else { await deployService(service.id); + await mutate(`/api/services/${service.id}/rollouts`); } onUpdate(); } finally { @@ -83,7 +86,7 @@ export const PendingChangesBanner = memo(function PendingChangesBanner({

{hasChanges ? (
- {changes.map((change, i) => ( + {changes.map((change) => (
{ + const service = await getService(serviceId); + const hasHealthCheck = service?.healthCheckCmd != null; + await db .update(rollouts) .set({ currentStage: "health_check" }) @@ -203,12 +206,34 @@ export const rolloutWorkflow = inngest.createFunction( rolloutId, serviceId, "health_check", - "Waiting for health checks", + hasHealthCheck ? "Waiting for health checks" : "Starting container", ); }); + const pendingHealthDeploymentIds = await step.run( + "get-pending-health-deployments", + async () => { + if (deploymentIds.length === 0) { + return []; + } + + const alreadyHealthy = await db + .select({ id: deployments.id }) + .from(deployments) + .where( + and( + inArray(deployments.id, deploymentIds), + inArray(deployments.status, ["healthy", "running"]), + ), + ); + + const alreadyHealthyIds = new Set(alreadyHealthy.map((d) => d.id)); + return deploymentIds.filter((id) => !alreadyHealthyIds.has(id)); + }, + ); + const healthResults = await Promise.all( - deploymentIds.map((deploymentId) => + pendingHealthDeploymentIds.map((deploymentId) => step.waitForEvent(`wait-healthy-${deploymentId}`, { event: "deployment/healthy", timeout: "10m", @@ -219,7 +244,7 @@ export const rolloutWorkflow = inngest.createFunction( const timedOutIndex = healthResults.indexOf(null); if (timedOutIndex !== -1) { - const failedDeploymentId = deploymentIds[timedOutIndex]; + const failedDeploymentId = pendingHealthDeploymentIds[timedOutIndex]; await step.run("log-health-timeout", async () => { await ingestRolloutLog( rolloutId, diff --git a/web/lib/service-config.ts b/web/lib/service-config.ts index fed68ef..8848a72 100644 --- a/web/lib/service-config.ts +++ b/web/lib/service-config.ts @@ -40,9 +40,15 @@ export type ResourceLimitsConfig = { memoryMb?: number | null; }; +export type PlacementConfig = { + autoPlace: boolean; + replicas: number; +}; + export type DeployedConfig = { source: SourceConfig; hostname?: string; + placement?: PlacementConfig; replicas: ReplicaConfig[]; healthCheck: HealthCheckConfig | null; startCommand?: string | null; @@ -71,6 +77,8 @@ export function buildCurrentConfig( startCommand: string | null; resourceCpuLimit: number | null; resourceMemoryLimitMb: number | null; + autoPlace: boolean; + replicas: number; }, replicas: { serverId: string; serverName: string; count: number }[], ports: { port: number; isPublic: boolean; domain: string | null }[], @@ -86,6 +94,12 @@ export function buildCurrentConfig( image: service.image, }, hostname: service.hostname ?? undefined, + placement: { + autoPlace: service.autoPlace, + replicas: service.autoPlace + ? service.replicas + : replicas.reduce((sum, r) => sum + r.count, 0), + }, replicas: replicas.map((r) => ({ serverId: r.serverId, serverName: r.serverName, @@ -138,12 +152,20 @@ export function diffConfigs( to: current.source.image, }); } - for (const replica of current.replicas) { + if (current.placement?.autoPlace) { changes.push({ - field: `${replica.serverName} replicas`, + field: "Replicas", from: "0", - to: String(replica.count), + to: String(current.placement.replicas), }); + } else { + for (const replica of current.replicas) { + changes.push({ + field: `${replica.serverName} replicas`, + from: "0", + to: String(replica.count), + }); + } } if (current.healthCheck) { changes.push({ @@ -214,38 +236,72 @@ export function diffConfigs( }); } - const deployedReplicasMap = new Map( - (deployed.replicas || []).map((r) => [r.serverId, r]), - ); - const currentReplicasMap = new Map( - (current.replicas || []).map((r) => [r.serverId, r]), + const deployedTotalReplicas = (deployed.replicas || []).reduce( + (sum, r) => sum + r.count, + 0, ); - for (const [serverId, currentReplica] of currentReplicasMap) { - const deployedReplica = deployedReplicasMap.get(serverId); - if (!deployedReplica) { + if (current.placement?.autoPlace) { + if (deployed.placement && !deployed.placement.autoPlace) { changes.push({ - field: `${currentReplica.serverName} replicas`, - from: "0", - to: String(currentReplica.count), + field: "Placement", + from: "Manual", + to: "Auto-placement", }); - } else if (deployedReplica.count !== currentReplica.count) { + } + + const deployedReplicaCount = + deployed.placement?.replicas ?? deployedTotalReplicas; + + if (deployedReplicaCount !== current.placement.replicas) { changes.push({ - field: `${currentReplica.serverName} replicas`, - from: String(deployedReplica.count), - to: String(currentReplica.count), + field: "Replicas", + from: String(deployedReplicaCount), + to: String(current.placement.replicas), }); } - } - - for (const [serverId, deployedReplica] of deployedReplicasMap) { - if (!currentReplicasMap.has(serverId)) { + } else { + if (deployed.placement?.autoPlace) { changes.push({ - field: `${deployedReplica.serverName} replicas`, - from: String(deployedReplica.count), - to: "0 (removed)", + field: "Placement", + from: "Auto-placement", + to: "Manual", }); } + + const deployedReplicasMap = new Map( + (deployed.replicas || []).map((r) => [r.serverId, r]), + ); + const currentReplicasMap = new Map( + (current.replicas || []).map((r) => [r.serverId, r]), + ); + + for (const [serverId, currentReplica] of currentReplicasMap) { + const deployedReplica = deployedReplicasMap.get(serverId); + if (!deployedReplica) { + changes.push({ + field: `${currentReplica.serverName} replicas`, + from: "0", + to: String(currentReplica.count), + }); + } else if (deployedReplica.count !== currentReplica.count) { + changes.push({ + field: `${currentReplica.serverName} replicas`, + from: String(deployedReplica.count), + to: String(currentReplica.count), + }); + } + } + + for (const [serverId, deployedReplica] of deployedReplicasMap) { + if (!currentReplicasMap.has(serverId)) { + changes.push({ + field: `${deployedReplica.serverName} replicas`, + from: String(deployedReplica.count), + to: "0 (removed)", + }); + } + } } const deployedHc = deployed.healthCheck;