From bf2809fa00f04feb4f47a3d63ab8d45df9e9f61b Mon Sep 17 00:00:00 2001 From: Kacper Wojciechowski <39823706+jog1t@users.noreply.github.com> Date: Mon, 11 May 2026 17:44:33 +0200 Subject: [PATCH] refactor(frontend): datacenter aware provider configuration --- .../connect-manual-serverfull-frame.tsx | 136 +++++++++++++----- .../connect-manual-serverless-frame.tsx | 2 - .../src/app/dialogs/edit-runner-config.tsx | 101 ++----------- frontend/src/app/env-variables.tsx | 6 +- .../app/forms/confirmable-submit-button.tsx | 110 ++++++++++++++ frontend/src/app/forms/stepper-form.tsx | 94 +++++++++++- frontend/src/app/getting-started.tsx | 2 +- .../ns.$namespace/settings.tsx | 10 +- .../ns.$namespace/tokens.tsx | 4 +- .../src/utils/use-railway-template-link.ts | 2 +- 10 files changed, 321 insertions(+), 146 deletions(-) create mode 100644 frontend/src/app/forms/confirmable-submit-button.tsx diff --git a/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx b/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx index b72baac9b2..9315ded1db 100644 --- a/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx +++ b/frontend/src/app/dialogs/connect-manual-serverfull-frame.tsx @@ -6,48 +6,94 @@ import { useQuery, useSuspenseInfiniteQuery, } from "@tanstack/react-query"; +import { useMemo, useRef } from "react"; import { useWatch } from "react-hook-form"; import z from "zod"; -import * as ConnectRailwayForm from "@/app/forms/connect-manual-serverfull-form"; +import * as ConnectServerfullForm from "@/app/forms/connect-manual-serverfull-form"; import type { DialogContentProps } from "@/components"; -import { useEngineCompatDataProvider } from "@/components/actors"; +import { + ActorRegion, + useEngineCompatDataProvider, +} from "@/components/actors"; import { defineStepper } from "@/components/ui/stepper"; import { successfulBackendSetupEffect } from "@/lib/effects"; import { engineEnv } from "@/lib/env"; import { queryClient } from "@/queries/global"; import { EnvVariables } from "../env-variables"; -import { StepperForm } from "../forms/stepper-form"; - -const stepper = defineStepper( - { - id: "step-1", - title: "Configure", - assist: false, - next: "Next", - schema: z.object({ - runnerName: z.string().min(1, "Runner name is required"), - datacenter: z.string().min(1, "Please select a region"), - }), - }, - { - id: "step-2", - title: "Deploy", - assist: false, - schema: z.object({}), - next: "Add", - }, - // { - // id: "step-3", - // title: "Wait for the Runner to connect", - // assist: true, - // schema: z.object({ - // success: z.boolean().refine((v) => v === true, { - // message: "Runner must be connected to proceed", - // }), - // }), - // next: "Add", - // }, -); +import { type StepConfirm, StepperForm } from "../forms/stepper-form"; + +type FormValues = { + runnerName: string; + datacenter: string; +}; + +function useStepperConfig() { + const dataProvider = useEngineCompatDataProvider(); + const dataProviderRef = useRef(dataProvider); + dataProviderRef.current = dataProvider; + + return useMemo(() => { + const confirmStep2: StepConfirm = async ({ + runnerName, + datacenter, + }) => { + if (!runnerName) return null; + const data = await queryClient.fetchQuery( + dataProviderRef.current.runnerConfigQueryOptions({ + name: runnerName, + safe: true, + }), + ); + const existingDatacenters = data + ? Object.keys(data.datacenters || {}) + : []; + if (existingDatacenters.length === 0) return null; + const willReplaceDatacenter = + !!datacenter && existingDatacenters.includes(datacenter); + return ( + <> + A runner config named{" "} + + {runnerName} + {" "} + already exists + {willReplaceDatacenter ? ( + <> + . Submitting will overwrite its existing + configuration for{" "} + . + + ) : ( + <> + . Submitting will add{" "} + to it. + + )} + + ); + }; + + return defineStepper( + { + id: "step-1", + title: "Configure", + assist: false, + schema: z.object({ + runnerName: z.string().min(1, "Runner name is required"), + datacenter: z.string().min(1, "Please select a region"), + }), + }, + { + id: "step-2", + title: "Deploy", + assist: false, + schema: z.object({}), + next: "Add", + confirm: confirmStep2, + }, + ); + }, []); +} interface ConnectManualServerlfullFrameContentProps extends DialogContentProps { provider: Provider; @@ -74,7 +120,8 @@ export default function ConnectManualServerlfullFrameContent({ ?.name || data.find((region) => region.name.toLowerCase().includes("ore")) ?.name || - "auto"; + data[0]?.name || + ""; return ( , "step-2": () => , - // "step-3": () => , }} /> ); @@ -158,8 +205,8 @@ function Step1() { We're going to help you deploy a RivetKit project to your cloud provider of choice. - - + + ); } @@ -168,6 +215,8 @@ function Step2({ provider }: { provider: Provider }) { const providerOptions = deployOptions.find( (option) => option.name === provider, ); + const runnerName = useWatch({ name: "runnerName" }); + return ( <>

@@ -184,12 +233,21 @@ function Step2({ provider }: { provider: Provider }) { ); } +function DatacenterLabel({ regionId }: { regionId?: string }) { + return ( + + + + ); +} + export const useEndpoint = () => { const datacenter = useWatch({ name: "datacenter" }); diff --git a/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx b/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx index 70fd8f23ae..b268876585 100644 --- a/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx +++ b/frontend/src/app/dialogs/connect-manual-serverless-frame.tsx @@ -29,7 +29,6 @@ const stepper = defineStepper( id: "step-1", title: "Configure", assist: false, - next: "Next", schema: ConnectServerlessForm.configurationSchema, }, { @@ -37,7 +36,6 @@ const stepper = defineStepper( title: "Deploy", assist: false, schema: z.object({}), - next: "Next", }, { id: "step-3", diff --git a/frontend/src/app/dialogs/edit-runner-config.tsx b/frontend/src/app/dialogs/edit-runner-config.tsx index f21b4f9a55..598889cadc 100644 --- a/frontend/src/app/dialogs/edit-runner-config.tsx +++ b/frontend/src/app/dialogs/edit-runner-config.tsx @@ -1,4 +1,3 @@ -import { faTriangleExclamation, Icon } from "@rivet-gg/icons"; import type { Rivet } from "@rivetkit/engine-api-full"; import { useMutation, @@ -6,7 +5,8 @@ import { useSuspenseQuery, } from "@tanstack/react-query"; import { type ReactNode, useEffect, useMemo, useRef, useState } from "react"; -import { useFormContext, useFormState, useWatch } from "react-hook-form"; +import { useFormContext, useWatch } from "react-hook-form"; +import { ConfirmableSubmitButton } from "@/app/forms/confirmable-submit-button"; const SERVERLESS_FIELDS = [ "url", @@ -32,13 +32,11 @@ import { AccordionContent, AccordionItem, AccordionTrigger, - Button, Combobox, type DialogContentProps, Frame, ToggleGroup, ToggleGroupItem, - WithTooltip, } from "@/components"; import { ActorRegion, useEngineCompatDataProvider } from "@/components/actors"; import { queryClient } from "@/queries/global"; @@ -719,94 +717,21 @@ function ConfirmableSaveButton({ blockedReason: string | null; computeSwitches: () => ModeSwitch[]; }) { - const form = useFormContext(); - const { isSubmitting, isValidating } = useFormState(); - const [pending, setPending] = useState(null); - const hiddenSubmitRef = useRef(null); - - const allValues = useWatch(); - useEffect(() => { - setPending(null); - }, [allValues]); - - const onSaveClick = async (e: React.MouseEvent) => { - e.preventDefault(); - const valid = await form.trigger(); - if (!valid) return; + const getConfirmation = () => { const switches = computeSwitches(); - if (switches.length === 0) { - hiddenSubmitRef.current?.click(); - } else { - setPending(switches); - } - }; - - const onConfirmClick = (e: React.MouseEvent) => { - e.preventDefault(); - setPending(null); - hiddenSubmitRef.current?.click(); + if (switches.length === 0) return null; + return ( + <>Saving will overwrite the existing configuration: {describeSwitches(switches)}. + ); }; - const onCancelClick = () => setPending(null); - - const saveButton = ( - - ); - return ( - <> - - - - - ) : blocked && blockedReason ? ( - {saveButton}} - content={blockedReason} - /> - ) : ( - saveButton - )} - + ); } diff --git a/frontend/src/app/env-variables.tsx b/frontend/src/app/env-variables.tsx index ef589d440b..81a650ca2d 100644 --- a/frontend/src/app/env-variables.tsx +++ b/frontend/src/app/env-variables.tsx @@ -15,6 +15,7 @@ export function EnvVariables({ endpoint, showRunnerName = true, showEndpoint = true, + showPublicEndpoint = true, showCopyButton = true, }: { id?: string; @@ -22,6 +23,7 @@ export function EnvVariables({ endpoint: string; showRunnerName?: boolean; showEndpoint?: boolean; + showPublicEndpoint?: boolean; showCopyButton?: boolean; }) { const rId = useId(); @@ -41,7 +43,7 @@ export function EnvVariables({

Value

- {showEndpoint && } + {showPublicEndpoint && showEndpoint && } {showEndpoint && } {showRunnerName && } @@ -85,7 +87,7 @@ function RivetRunnerEnv({ <> + | ReactNode + | null + | Promise; +} + +export function ConfirmableSubmitButton({ + label, + confirmLabel, + blocked = false, + blockedReason = null, + getConfirmation, +}: ConfirmableSubmitButtonProps) { + const form = useFormContext(); + const { isSubmitting, isValidating } = useFormState(); + const [pending, setPending] = useState(null); + const hiddenSubmitRef = useRef(null); + + const allValues = useWatch(); + useEffect(() => { + setPending(null); + }, [allValues]); + + const onSubmitClick = async (e: React.MouseEvent) => { + e.preventDefault(); + const valid = await form.trigger(); + if (!valid) return; + const confirmation = await getConfirmation(); + if (confirmation === null) { + hiddenSubmitRef.current?.click(); + } else { + setPending(confirmation); + } + }; + + const onConfirmClick = (e: React.MouseEvent) => { + e.preventDefault(); + setPending(null); + hiddenSubmitRef.current?.click(); + }; + + const button = ( + + ); + + return ( + <> + + + + + ) : blocked && blockedReason ? ( + {button}} + content={blockedReason} + /> + ) : ( + button + )} + + ); +} diff --git a/frontend/src/app/forms/stepper-form.tsx b/frontend/src/app/forms/stepper-form.tsx index 36bf599b28..8bba811ec2 100644 --- a/frontend/src/app/forms/stepper-form.tsx +++ b/frontend/src/app/forms/stepper-form.tsx @@ -1,5 +1,10 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { faArrowLeft, faArrowRight, Icon } from "@rivet-gg/icons"; +import { + faArrowLeft, + faArrowRight, + faTriangleExclamation, + Icon, +} from "@rivet-gg/icons"; import type * as Stepperize from "@stepperize/react"; import { AnimatePresence, motion } from "framer-motion"; import { posthog } from "@/lib/posthog"; @@ -9,6 +14,7 @@ import { type ReactNode, useContext, useRef, + useState, } from "react"; import { FormProvider, @@ -16,12 +22,17 @@ import { type UseFormReturn, useForm, useFormContext, + useWatch, } from "react-hook-form"; import type * as z from "zod"; import { Button, cn } from "@/components"; import type { defineStepper } from "@/components/ui/stepper"; import { HelpDropdown } from "../help-dropdown"; +export type StepConfirm> = ( + values: TValues, +) => ReactNode | null | Promise; + type Step = Stepperize.Step & { assist?: boolean; schema: z.ZodSchema | ((values: Record) => z.ZodSchema); @@ -31,6 +42,9 @@ type Step = Stepperize.Step & { showPrevious?: boolean; group?: string; isVisible?: (values: Record) => boolean; + // method-style declaration so consumers can supply a narrower values type + // (parameter contravariance would otherwise reject typed callbacks). + confirm?(values: Record): ReactNode | null | Promise; }; type StepVisibilityContextType = { @@ -487,6 +501,35 @@ function StepPanel({ controls?: ReactNode; }) { const form = useFormContext(); + const liveValues = useWatch({ control: form.control }); + const mergedValues = { + ...(valuesRef.current ?? {}), + ...liveValues, + } as Record; + const stepSchema = + typeof step.schema === "function" + ? step.schema(mergedValues) + : step.schema; + const isStepValid = stepSchema + ? stepSchema.safeParse(mergedValues).success + : true; + + const [confirmNode, setConfirmNode] = useState(null); + const hiddenSubmitRef = useRef(null); + const stepConfirm = (step as Step).confirm; + + const onNextClick = async (e: React.MouseEvent) => { + if (!stepConfirm) return; + e.preventDefault(); + const valid = await form.trigger(); + if (!valid) return; + const result = await stepConfirm(mergedValues); + if (result == null) { + hiddenSubmitRef.current?.click(); + } else { + setConfirmNode(result); + } + }; const goToPrev = () => { const allLive = form.getValues() as Record; @@ -511,6 +554,15 @@ function StepPanel({ return ( {stepper.match(step.id, content)} + {confirmNode ? ( +

+ + {confirmNode} +

+ ) : null} {showControls ? ( ({ stepper.isLast && !showNext && "justify-start", )} > + ) : null} - {showNext ? ( + {showNext && confirmNode ? ( + <> + + + + ) : showNext ? ( ) : null} diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 7e9ce923fd..78c08f1f64 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -980,7 +980,7 @@ function useOtherAgentInstructionsCode(provider?: Provider) { RIVET_ENDPOINT=${secretToken}${ runnerName !== "default" ? ` - RIVET_RUNNER_NAME=${runnerName}` + RIVET_POOL=${runnerName}` : "" } diff --git a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx index 3edcffef4f..d03471a148 100644 --- a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx +++ b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/settings.tsx @@ -284,11 +284,11 @@ function Advanced() { - - - - +
+ + + +
} > diff --git a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens.tsx b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens.tsx index af63002cbf..638558e68b 100644 --- a/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens.tsx +++ b/frontend/src/routes/_context/orgs.$organization/projects.$project/ns.$namespace/tokens.tsx @@ -158,8 +158,8 @@ export function PublishableToken() { export function SecretToken() { return ( -
-
+
+

Backend Configuration

diff --git a/frontend/src/utils/use-railway-template-link.ts b/frontend/src/utils/use-railway-template-link.ts index 44f26db0f7..cdfaab0a5f 100644 --- a/frontend/src/utils/use-railway-template-link.ts +++ b/frontend/src/utils/use-railway-template-link.ts @@ -16,7 +16,7 @@ export function useRailwayTemplateLink({ runnerName }: { runnerName: string }) { url.searchParams.set("utm_source", "template"); url.searchParams.set("utm_campaign", "generic"); - url.searchParams.set("RIVET_RUNNER", runnerName || ""); + url.searchParams.set("RIVET_POOL", runnerName || ""); if (secretDsn) { url.searchParams.set("RIVET_ENDPOINT", secretDsn); }