diff --git a/svc/ctrl/worker/deploy/deploy_handler.go b/svc/ctrl/worker/deploy/deploy_handler.go index c6d678371f9..0ccfc0177e2 100644 --- a/svc/ctrl/worker/deploy/deploy_handler.go +++ b/svc/ctrl/worker/deploy/deploy_handler.go @@ -14,7 +14,6 @@ import ( "github.com/unkeyed/unkey/pkg/db" "github.com/unkeyed/unkey/pkg/fault" "github.com/unkeyed/unkey/pkg/logger" - "github.com/unkeyed/unkey/pkg/ptr" "github.com/unkeyed/unkey/pkg/uid" githubclient "github.com/unkeyed/unkey/svc/ctrl/worker/github" ) @@ -58,8 +57,10 @@ const ( // // Returns terminal errors for validation failures and retryable errors for // transient system failures. -func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.DeployRequest) (*hydrav1.DeployResponse, error) { +func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.DeployRequest) (_ *hydrav1.DeployResponse, retErr error) { finishedSuccessfully := false + var currentStep *db.DeploymentStepsStep + var failureReason string err := assert.All( assert.NotEmpty(req.GetDeploymentId(), "deployment_id is required"), @@ -80,14 +81,29 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepQueued); err != nil { return nil, err } + queuedStep := db.DeploymentStepsStepQueued + currentStep = &queuedStep defer func() { if finishedSuccessfully { return } - if err = w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusFailed); err != nil { - logger.Error("deployment failed but we can not set the status", "error", err.Error()) + if currentStep != nil { + msg := failureReason + if msg == "" { + msg = fault.UserFacingMessage(retErr) + } + if msg == "" { + msg = fmt.Sprintf("Deployment failed during %s step", string(*currentStep)) + } + if stepErr := w.endDeploymentStep(ctx, deployment.ID, *currentStep, &msg); stepErr != nil { + logger.Error("failed to end deployment step on failure", "step", *currentStep, "error", stepErr) + } + } + + if statusErr := w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusFailed); statusErr != nil { + logger.Error("deployment failed but we can not set the status", "error", statusErr.Error()) } }() @@ -137,10 +153,13 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepQueued, nil); err != nil { return nil, err } + currentStep = nil if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepBuilding); err != nil { return nil, err } + buildingStep := db.DeploymentStepsStepBuilding + currentStep = &buildingStep dockerImage := "" @@ -202,9 +221,8 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy WorkspaceID: deployment.WorkspaceID, }) if err != nil { - buildErr := fmt.Errorf("failed to build docker image from git: %w", err) - _ = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepBuilding, ptr.P(fault.UserFacingMessage(err))) - return nil, buildErr + failureReason = fault.UserFacingMessage(err) + return nil, fmt.Errorf("failed to build docker image from git: %w", err) } dockerImage = build.ImageName @@ -237,6 +255,7 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepBuilding, nil); err != nil { return nil, err } + currentStep = nil if err = w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusDeploying); err != nil { return nil, err @@ -245,6 +264,8 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepDeploying); err != nil { return nil, err } + deployingStep := db.DeploymentStepsStepDeploying + currentStep = &deployingStep // Read region config from runtime settings to determine per-region replica counts. // If regionConfig is empty, deploy to all available regions with 1 replica each (default). @@ -309,6 +330,9 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy existingSentinels, err := restate.Run(ctx, func(runCtx restate.RunContext) ([]db.Sentinel, error) { return db.Query.FindSentinelsByEnvironmentID(runCtx, w.db.RO(), environment.ID) }, restate.WithName("find existing sentinels")) + if err != nil { + return nil, fmt.Errorf("failed to find existing sentinels: %w", err) + } existingSentinelsByRegion := make(map[string]db.Sentinel) for _, sentinel := range existingSentinels { @@ -417,10 +441,13 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepDeploying, nil); err != nil { return nil, err } + currentStep = nil if err = w.startDeploymentStep(ctx, deployment, db.DeploymentStepsStepNetwork); err != nil { return nil, err } + networkStep := db.DeploymentStepsStepNetwork + currentStep = &networkStep allDomains := buildDomains( workspace.Slug, @@ -500,6 +527,7 @@ func (w *Workflow) Deploy(ctx restate.WorkflowSharedContext, req *hydrav1.Deploy if err = w.endDeploymentStep(ctx, deployment.ID, db.DeploymentStepsStepNetwork, nil); err != nil { return nil, err } + currentStep = nil if err = w.updateDeploymentStatus(ctx, deployment.ID, db.DeploymentsStatusReady); err != nil { return nil, err diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/page.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/page.tsx index 255743a5b9a..b4f7d9513ff 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/page.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/page.tsx @@ -1,11 +1,8 @@ "use client"; +import { PRODUCT_HOME_ROUTES, useProductSelection } from "@/hooks/use-product-selection"; +import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; import { useRouter } from "next/navigation"; import { useEffect } from "react"; -import { - PRODUCT_HOME_ROUTES, - useProductSelection, -} from "@/hooks/use-product-selection"; -import { useWorkspaceNavigation } from "@/hooks/use-workspace-navigation"; export default function WorkspacePage() { const router = useRouter(); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/build-step-logs-expanded.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/build-step-logs-expanded.tsx index a0035c393e3..e34dfebb32f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/build-step-logs-expanded.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/build-step-logs-expanded.tsx @@ -33,8 +33,8 @@ export function BuildStepLogsExpanded({ step }: { step: BuildStepRow }) { /> - - + + {log.message} diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/columns.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/columns.tsx index c2b271eb8d1..2b7e0066349 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/columns.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/build-steps-table/columns.tsx @@ -32,7 +32,7 @@ export const buildStepsColumns: Column[] = [ key: "started_at", width: "85px", render: (step) => ( -
+
[] = [ width: "32px", render: (step) => { if (step.error) { - return ; + return ; } if (step.cached) { return ( - + ); } @@ -63,7 +63,7 @@ export const buildStepsColumns: Column[] = [ width: "250px", render: (step) => (
{step.name} @@ -78,7 +78,7 @@ export const buildStepsColumns: Column[] = [ return null; } return ( - + {step.error} ); diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx index b627047be57..c84f53fd079 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-progress.tsx @@ -2,11 +2,12 @@ import { trpc } from "@/lib/trpc/client"; import { CloudUp, Earth, Hammer2, LayerFront } from "@unkey/icons"; -import { SettingCardGroup } from "@unkey/ui"; +import { Button, SettingCardGroup } from "@unkey/ui"; import { useParams, useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { DeploymentDomainsCard } from "../../../../components/deployment-domains-card"; import { useProjectData } from "../../../data-provider"; +import { RedeployDialog } from "../../components/table/components/actions/redeploy-dialog"; import { useDeployment } from "../layout-provider"; import { DeploymentBuildStepsTable } from "./build-steps-table/deployment-build-steps-table"; import { DeploymentStep } from "./deployment-step"; @@ -17,6 +18,7 @@ export function DeploymentProgress() { const params = useParams(); const workspaceSlug = params.workspaceSlug as string; const projectId = params.projectId as string; + const isFailed = deployment.status === "failed"; const steps = trpc.deploy.deployment.steps.useQuery( { @@ -33,7 +35,7 @@ export function DeploymentProgress() { includeStepLogs: true, }, { - refetchInterval: 1000, + refetchInterval: 1_000, }, ); @@ -41,13 +43,18 @@ export function DeploymentProgress() { const [now, setNow] = useState(0); useEffect(() => { + if (isFailed) { + return; + } const interval = setInterval(() => setNow(Date.now()), 500); return () => { clearInterval(interval); }; - }, []); + }, [isFailed]); + const { building, deploying, network, queued } = steps.data ?? {}; + const [redeployOpen, setRedeployOpen] = useState(false); const domainsForDeployment = getDomainsForDeployment(deployment.id); // Latch true once we observe the build actively in progress; stays true after it completes @@ -55,7 +62,7 @@ export function DeploymentProgress() { if (building && !building.endedAt) { hasFreshBuild.current = true; } - const isPrebuilt = !hasFreshBuild.current; + const isPrebuilt = !hasFreshBuild.current && !building?.error; useEffect(() => { if (network?.completed) { @@ -126,7 +133,9 @@ export function DeploymentProgress() { ? deploying.endedAt ? (deploying.error ?? "Deployed to all machines") : "Deploying to all machines" - : "Waiting for build" + : isFailed + ? "Skipped" + : "Waiting for build" } duration={deploying ? (deploying.endedAt ?? now) - deploying.startedAt : undefined} status={ @@ -136,7 +145,9 @@ export function DeploymentProgress() { ? "completed" : deploying ? "started" - : "pending" + : isFailed + ? "skipped" + : "pending" } /> + {isFailed && ( +
+
+
+
+ Deployment failed + + {[queued, building, deploying, network].find((s) => s?.error)?.error ?? + "Deployment failed"} + +
+
+ +
+ setRedeployOpen(false)} + selectedDeployment={deployment} + /> +
+ )} {network?.completed && (
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx index bb77f1ecc38..97b50d38dd4 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/[deploymentId]/(deployment-progress)/deployment-step.tsx @@ -10,7 +10,7 @@ type DeploymentStepProps = { title: string; description: string; duration?: number; - status: "pending" | "started" | "completed" | "error"; + status: "pending" | "started" | "completed" | "error" | "skipped"; expandable?: React.ReactNode; defaultExpanded?: boolean; badgeText?: string; @@ -25,61 +25,79 @@ export function DeploymentStep({ expandable, defaultExpanded, }: DeploymentStepProps) { - const showGlow = status === "started"; + const showGlow = status === "started" || status === "error"; + const isError = status === "error"; + const isSkipped = status === "skipped"; return ( - -
-
- {icon} +
+ +
+
+ {icon} +
-
- } - title={ -
- {title} - + {title} + {isError ? ( + + Failed + + ) : ( + + Complete + )} - > - Complete - +
+ } + iconClassName={showGlow ? "bg-transparent shadow-none dark:ring-0" : undefined} + className="relative" + description={description} + expandable={expandable} + defaultExpanded={defaultExpanded} + contentWidth="w-fit" + > +
+ {duration ? ms(duration) : null} + {status === "completed" ? ( + + ) : status === "started" ? ( + + ) : status === "error" ? ( + + ) : null}
- } - iconClassName={showGlow ? "bg-transparent shadow-none dark:ring-0" : undefined} - className="relative" - description={description} - expandable={expandable} - defaultExpanded={defaultExpanded} - contentWidth="w-fit" - > -
- {duration ? ms(duration) : null} - {status === "completed" ? ( - - ) : status === "started" ? ( - - ) : status === "error" ? ( - - ) : null} -
- + +
); } diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx index 6092cb2340a..be9baf0a9de 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/actions/deployment-list-table-action.popover.constants.tsx @@ -36,7 +36,8 @@ export const DeploymentListTableActions = ({ selectedDeployment.status === "ready" && selectedDeployment.id !== liveDeployment.id; - const canRedeploy = selectedDeployment.status === "ready"; + const canRedeploy = + selectedDeployment.status === "ready" || selectedDeployment.status === "failed"; return [ { @@ -95,9 +96,7 @@ export const DeploymentListTableActions = ({ label: "Go to logs...", icon: , onClick: () => { - router.push( - `/${workspace.slug}/projects/${selectedDeployment.projectId}/deployments/${selectedDeployment.id}/logs`, - ); + router.push(`/${workspace.slug}/projects/${selectedDeployment.projectId}/logs`); }, }, ]; diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx index 9ec8da7a585..2f6de02bc93 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/deployments/components/table/components/deployment-status-badge.tsx @@ -86,7 +86,7 @@ export const DeploymentStatusBadge = ({ status, className }: DeploymentStatusBad return (
{ const isLive = liveDeploymentId === deployment.id; const iconContainer = ; return ( -
+
{iconContainer}
@@ -133,7 +133,7 @@ export const DeploymentsList = () => { const mem = isFailed ? null : formatMemoryParts(deployment.memoryMib); return (
-
+
{isFailed ? ( ) : ( @@ -146,20 +146,24 @@ export const DeploymentsList = () => {
)}
- {!isFailed && cpu && mem && ( -
-
- - {cpu.value} - {cpu.unit} -
-
- - {mem.value} - {mem.unit} -
-
- )} +
+ {isFailed || !cpu || !mem ? ( + + ) : ( + <> +
+ + {cpu.value} + {cpu.unit} +
+
+ + {mem.value} + {mem.unit} +
+ + )} +
diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx index 3a945c5ed49..66be2449d47 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/build-settings/dockerfile-settings.tsx @@ -12,7 +12,7 @@ const dockerfileSchema = z.object({ }); export const Dockerfile = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId, dockerfile: defaultValue } = settings; const { @@ -48,6 +48,7 @@ export const Dockerfile = () => { displayValue={defaultValue} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} > { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId, dockerContext: defaultValue } = settings; const { @@ -48,6 +48,7 @@ export const RootDirectory = () => { displayValue={defaultValue || "."} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} > ; export const Command = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { command, environmentId } = settings; const defaultCommand = command.join(" "); @@ -70,6 +70,7 @@ export const Command = () => { } onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} > ; export const Cpu = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId, cpuMillicores: defaultCpu } = settings; const { @@ -83,6 +83,7 @@ export const Cpu = () => { })()} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} >
CPU per instance @@ -97,6 +98,7 @@ export const Cpu = () => { setValue("cpu", indexToValue(CPU_OPTIONS, value, 256), { shouldValidate: true }); } }} + onValueCommit={autoSave ? () => handleSubmit(onSubmit)() : undefined} className="flex-1 max-w-[480px]" rangeStyle={{ background: "linear-gradient(to right, hsla(var(--infoA-4)), hsla(var(--infoA-12)))", diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx index 040a420eb8c..aec22ed73f1 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/healthcheck/index.tsx @@ -20,7 +20,7 @@ import { HTTP_METHODS, type HealthcheckFormValues, healthcheckSchema } from "./s import { intervalToSeconds, secondsToInterval } from "./utils"; export const Healthcheck = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { healthcheck, environmentId } = settings; const defaultValues: HealthcheckFormValues = { @@ -94,6 +94,7 @@ export const Healthcheck = () => { } onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} >
{/* TODO: multi-check when API supports diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx index 09979aeca1b..1fa1d272a30 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/instances.tsx @@ -20,7 +20,7 @@ const instancesSchema = z.object({ type InstancesFormValues = z.infer; export const Instances = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId, regionConfig } = settings; const selectedRegions = Object.keys(regionConfig); @@ -82,6 +82,7 @@ export const Instances = () => { } onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} >
Instances per region @@ -96,6 +97,15 @@ export const Instances = () => { setValue("instances", value, { shouldValidate: true }); } }} + onValueCommit={ + autoSave + ? ([value]) => { + if (value !== undefined) { + handleSubmit(onSubmit)(); + } + } + : undefined + } className="flex-1 max-w-[480px]" rangeStyle={{ background: diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx index a6ab7a084bf..d5eeafd8b9f 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/memory.tsx @@ -31,7 +31,7 @@ const memorySchema = z.object({ type MemoryFormValues = z.infer; export const Memory = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { memoryMib: defaultMemory, environmentId } = settings; const { @@ -83,6 +83,7 @@ export const Memory = () => { })()} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} >
Memory per instance @@ -99,6 +100,7 @@ export const Memory = () => { }); } }} + onValueCommit={autoSave ? () => handleSubmit(onSubmit)() : undefined} className="flex-1 max-w-[480px]" rangeStyle={{ background: diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx index d64afab86fa..b8b9f017d23 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/runtime-settings/port-settings.tsx @@ -12,7 +12,7 @@ const portSchema = z.object({ }); export const Port = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId, port: defaultValue } = settings; const { @@ -48,6 +48,7 @@ export const Port = () => { displayValue={String(defaultValue)} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} > ; export const Regions = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId, regionConfig } = settings; const defaultRegions = Object.keys(regionConfig); @@ -35,6 +35,7 @@ export const Regions = () => { environmentId={environmentId} defaultRegions={defaultRegions} availableRegions={availableRegions ?? []} + autoSave={autoSave} /> ); }; @@ -43,12 +44,14 @@ type RegionsFormProps = { environmentId: string; defaultRegions: string[]; availableRegions: string[]; + autoSave?: boolean; }; const RegionsForm: React.FC = ({ environmentId, defaultRegions, availableRegions, + autoSave, }) => { const { handleSubmit, @@ -150,6 +153,7 @@ const RegionsForm: React.FC = ({ displayValue={displayValue} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} > ; export const Keyspaces = () => { - const { settings } = useEnvironmentSettings(); + const { settings, autoSave } = useEnvironmentSettings(); const { environmentId } = settings; const { data: availableKeyspaces } = @@ -39,6 +39,7 @@ export const Keyspaces = () => { environmentId={environmentId} defaultKeyspaceIds={defaultKeyspaceIds} availableKeyspaces={availableKeyspaces ?? {}} + autoSave={autoSave} /> ); }; @@ -47,12 +48,14 @@ type KeyspacesFormProps = { environmentId: string; defaultKeyspaceIds: string[]; availableKeyspaces: Record; + autoSave?: boolean; }; const KeyspacesForm: React.FC = ({ environmentId, defaultKeyspaceIds, availableKeyspaces, + autoSave, }) => { const { handleSubmit, @@ -160,6 +163,7 @@ const KeyspacesForm: React.FC = ({ displayValue={displayValue} onSubmit={handleSubmit(onSubmit)} saveState={saveState} + autoSave={autoSave} > ; className?: string; + autoSave?: boolean; }; export const FormSettingCard = ({ @@ -31,6 +32,7 @@ export const FormSettingCard = ({ saveState, ref, className, + autoSave, }: EditableSettingCardProps) => { return ( { + if (!autoSave || saveState.status !== "ready") { + return; + } + const relatedTarget = e.relatedTarget instanceof Node ? e.relatedTarget : null; + if (!e.currentTarget.contains(relatedTarget)) { + e.currentTarget.requestSubmit(); + } + }} > -
+
{children}
-
- - - -
+ + +
+ )} } > diff --git a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx index 15737428294..95e6162747b 100644 --- a/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx +++ b/web/apps/dashboard/app/(app)/[workspaceSlug]/projects/[projectId]/(overview)/settings/components/shared/settings-group.tsx @@ -1,7 +1,7 @@ "use client"; import { ChevronRight } from "@unkey/icons"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; type SettingsGroupProps = { icon: React.ReactNode; @@ -20,6 +20,10 @@ export const SettingsGroup = ({ }: SettingsGroupProps) => { const [expanded, setExpanded] = useState(defaultExpanded); + useEffect(() => { + setExpanded(defaultExpanded); + }, [defaultExpanded]); + return (
@@ -30,7 +34,7 @@ export const SettingsGroup = ({