diff --git a/frontend/src/app/dialogs/connect-aws-frame.tsx b/frontend/src/app/dialogs/connect-aws-frame.tsx index 54f581ea96..42115e1807 100644 --- a/frontend/src/app/dialogs/connect-aws-frame.tsx +++ b/frontend/src/app/dialogs/connect-aws-frame.tsx @@ -19,6 +19,7 @@ import * as ConnectVercelForm from "@/app/forms/connect-vercel-form"; import { cn, type DialogContentProps, Frame } from "@/components"; import { type Region, useEngineCompatDataProvider } from "@/components/actors"; import { defineStepper } from "@/components/ui/stepper"; +import { queryClient } from "@/queries/global"; import { StepperForm } from "../forms/stepper-form"; import { EnvVariablesStep } from "./connect-railway-frame"; diff --git a/frontend/src/app/dialogs/connect-railway-frame.tsx b/frontend/src/app/dialogs/connect-railway-frame.tsx index ffda9f0548..119de38701 100644 --- a/frontend/src/app/dialogs/connect-railway-frame.tsx +++ b/frontend/src/app/dialogs/connect-railway-frame.tsx @@ -25,6 +25,8 @@ import { import { useEngineCompatDataProvider } from "@/components/actors"; import { defineStepper } from "@/components/ui/stepper"; import { engineEnv } from "@/lib/env"; +import { queryClient } from "@/queries/global"; +import { useRailwayTemplateLink } from "@/utils/use-railway-template-link"; import { StepperForm } from "../forms/stepper-form"; const stepper = defineStepper( @@ -120,7 +122,9 @@ function FormStepper({ origin: { x: 1 }, }); - await queryClient.invalidateQueries(provider.runnerConfigsQueryOptions()); + await queryClient.invalidateQueries( + provider.runnerConfigsQueryOptions(), + ); onClose?.(); }, }); @@ -128,7 +132,6 @@ function FormStepper({ { - console.log(values); await mutateAsync({ name: values.runnerName, config: { @@ -328,18 +331,16 @@ function RivetNamespaceEnv() { } function DeployToRailwayButton() { - const dataProvider = useEngineCompatDataProvider(); - const url = useSelectedDatacenter(); - const { data } = useQuery(dataProvider.engineAdminTokenQueryOptions()); const runnerName = useWatch({ name: "runnerName" }); + const url = useRailwayTemplateLink({ + runnerName: runnerName || "default", + datacenter: useWatch({ name: "datacenter" }) || "auto", + }); + return ( ); } - -const useSelectedDatacenter = () => { - const datacenter = useWatch({ name: "datacenter" }); - - const { data } = useQuery( - useEngineCompatDataProvider().regionQueryOptions(datacenter || "auto"), - ); - - return data?.url || engineEnv().VITE_APP_API_URL; -}; diff --git a/frontend/src/app/dialogs/edit-runner-config.tsx b/frontend/src/app/dialogs/edit-runner-config.tsx index 67d49ec8fc..8b501a73bf 100644 --- a/frontend/src/app/dialogs/edit-runner-config.tsx +++ b/frontend/src/app/dialogs/edit-runner-config.tsx @@ -35,8 +35,8 @@ export default function EditRunnerConfigFrameContent({ }, }); - const config = data.find(([id]) => id === name)?.[1].datacenters?.[dc] - .serverless; + const datacenters = data.find(([id]) => id === name)?.[1].datacenters; + const config = datacenters?.[dc].serverless; if (!config) { return ( @@ -52,6 +52,7 @@ export default function EditRunnerConfigFrameContent({ await mutateAsync({ name, config: { + ...datacenters, [dc]: { serverless: { ...values, diff --git a/frontend/src/app/forms/edit-runner-config-form.tsx b/frontend/src/app/forms/edit-runner-config-form.tsx index a6cdf61671..edd3488be0 100644 --- a/frontend/src/app/forms/edit-runner-config-form.tsx +++ b/frontend/src/app/forms/edit-runner-config-form.tsx @@ -20,7 +20,7 @@ import { } from "@/components"; export const formSchema = z.object({ - url: z.string().url().endsWith("/api/rivet"), + url: z.string().url(), maxRunners: z.coerce.number().positive(), minRunners: z.coerce.number().min(0), requestLifespan: z.coerce.number().positive(), diff --git a/frontend/src/app/runner-config-table.tsx b/frontend/src/app/runner-config-table.tsx index 0c20cc762b..3059b0c5a8 100644 --- a/frontend/src/app/runner-config-table.tsx +++ b/frontend/src/app/runner-config-table.tsx @@ -141,7 +141,7 @@ function Row({ + {config.serverless?.url && config.serverless.url.length > 32 diff --git a/frontend/src/app/runners-table.tsx b/frontend/src/app/runners-table.tsx index 75db5f26c8..78bbe44af0 100644 --- a/frontend/src/app/runners-table.tsx +++ b/frontend/src/app/runners-table.tsx @@ -1,5 +1,7 @@ -import { faHourglassClock, Icon } from "@rivet-gg/icons"; +import { faHourglassClock, faPlus, Icon } from "@rivet-gg/icons"; import type { Rivet } from "@rivetkit/engine-api-full"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; import { formatRelative } from "date-fns"; import { Button, @@ -15,6 +17,7 @@ import { Text, WithTooltip, } from "@/components"; +import { useEngineCompatDataProvider } from "@/components/actors"; interface RunnersTableProps { isLoading?: boolean; @@ -46,13 +49,7 @@ export function RunnersTable({ {!isLoading && !isError && runners?.length === 0 ? ( - - - - There's no runners matching criteria. - - - + ) : null} {isError ? ( @@ -197,3 +194,63 @@ function RunnerStatusBadge(runner: Rivet.Runner) { /> ); } + +function EmptyState() { + const { data: serverlessConfig } = useInfiniteQuery({ + ...useEngineCompatDataProvider().runnerConfigsQueryOptions(), + select(data) { + for (const page of data.pages) { + for (const rc of Object.values(page.runnerConfigs)) { + for (const [dc, config] of Object.entries(rc.datacenters)) { + if (config.serverless) { + return config; + } + } + } + } + return null; + }, + }); + + const { data: actorNames } = useInfiniteQuery({ + ...useEngineCompatDataProvider().buildsQueryOptions(), + select(data) { + return data.pages[0].builds.length > 0; + }, + }); + + return ( + + + {serverlessConfig ? ( + <> + + Runners will be created when an actor is created. + + {actorNames ? ( +
+ +
+ ) : null} + + ) : ( + + There are no runners connected. You will not be able to + run actors until a runner appears here. + + )} +
+
+ ); +} diff --git a/frontend/src/components/actors/dialogs/create-actor-dialog.tsx b/frontend/src/components/actors/dialogs/create-actor-dialog.tsx index bdb9937f04..370bd5745a 100644 --- a/frontend/src/components/actors/dialogs/create-actor-dialog.tsx +++ b/frontend/src/components/actors/dialogs/create-actor-dialog.tsx @@ -47,8 +47,8 @@ export default function CreateActorDialog({ onClose }: ContentProps) { }} defaultValues={{ name, + key: "example-key", crashPolicy: CrashPolicy.Destroy, - datacenter: "auto", }} > @@ -62,7 +62,11 @@ export default function CreateActorDialog({ onClose }: ContentProps) { {["engine", "cloud"].includes(__APP_TYPE__) ? ( - + <> + + + + ) : null} diff --git a/frontend/src/components/actors/form/actor-create-form.tsx b/frontend/src/components/actors/form/actor-create-form.tsx index cef852f0bb..b2bebb7e62 100644 --- a/frontend/src/components/actors/form/actor-create-form.tsx +++ b/frontend/src/components/actors/form/actor-create-form.tsx @@ -1,4 +1,7 @@ -import { useInfiniteQuery } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useSuspenseInfiniteQuery, +} from "@tanstack/react-query"; import { useEffect, useRef } from "react"; import { type UseFormReturn, useFormContext } from "react-hook-form"; import z from "zod"; @@ -212,11 +215,34 @@ export const ActorPreview = () => { ); }; +export const PrefillActorName = () => { + const prefilled = useRef(false); + const { watch } = useFormContext(); + + const { data: name, isSuccess } = useSuspenseInfiniteQuery({ + ...useEngineCompatDataProvider().buildsQueryOptions(), + select: (data) => data.pages[0].builds[0].name, + }); + + const watchedValue = watch("name"); + + const { setValue } = useFormContext(); + + useEffect(() => { + if (name && isSuccess && !watchedValue && !prefilled.current) { + setValue("name", name); + prefilled.current = true; + } + }, [name, setValue, isSuccess, watchedValue]); + + return null; +}; + export const PrefillRunnerName = () => { const prefilled = useRef(false); const { watch } = useFormContext(); - const { data = [], isSuccess } = useInfiniteQuery( + const { data = [], isSuccess } = useSuspenseInfiniteQuery( useEngineCompatDataProvider().runnerNamesQueryOptions(), ); @@ -239,6 +265,33 @@ export const PrefillRunnerName = () => { return null; }; +export const PrefillDatacenter = () => { + const prefilled = useRef(false); + const { watch } = useFormContext(); + + const { data: datacenter, isSuccess } = useSuspenseInfiniteQuery({ + ...useEngineCompatDataProvider().runnerConfigsQueryOptions(), + select: (data) => { + return Object.keys( + Object.values(data.pages[0].runnerConfigs)[0].datacenters, + )[0]; + }, + }); + + const watchedValue = watch("datacenter"); + + const { setValue } = useFormContext(); + + useEffect(() => { + if (datacenter && isSuccess && !watchedValue && !prefilled.current) { + setValue("datacenter", datacenter); + prefilled.current = true; + } + }, [datacenter, setValue, isSuccess, watchedValue]); + + return null; +}; + export const Datacenter = () => { const { control } = useFormContext(); @@ -251,6 +304,7 @@ export const Datacenter = () => { Datacenter diff --git a/frontend/src/components/code-preview/code-preview.tsx b/frontend/src/components/code-preview/code-preview.tsx index 2d1b52c0a2..4809ad2f4f 100644 --- a/frontend/src/components/code-preview/code-preview.tsx +++ b/frontend/src/components/code-preview/code-preview.tsx @@ -57,7 +57,15 @@ export function CodePreview({ className, code, language }: CodePreviewProps) { ); if (isLoading) { - return ; + return ( +
+ + + + + +
+ ); } return ( diff --git a/frontend/src/components/code.tsx b/frontend/src/components/code.tsx index 6d28497b8a..c31cad87e0 100644 --- a/frontend/src/components/code.tsx +++ b/frontend/src/components/code.tsx @@ -1,5 +1,10 @@ import { faCopy, faFile, Icon } from "@rivet-gg/icons"; -import { Children, cloneElement, type ReactElement } from "react"; +import { + Children, + cloneElement, + type ReactElement, + type ReactNode, +} from "react"; import { CopyButton } from "./copy-area"; import { cn } from "./lib/utils"; import { Badge } from "./ui/badge"; @@ -96,6 +101,7 @@ interface CodeFrameProps { language: keyof typeof languageNames; isInGroup?: boolean; code?: () => string | string; + footer?: ReactNode; children?: ReactElement; } export const CodeFrame = ({ @@ -104,6 +110,7 @@ export const CodeFrame = ({ language, code, title, + footer, isInGroup, }: CodeFrameProps) => { return ( @@ -118,6 +125,7 @@ export const CodeFrame = ({
+ {footer} {file ? ( <> diff --git a/frontend/src/routes/_context/_cloud/orgs.$organization/projects.$project/ns.$namespace/connect.tsx b/frontend/src/routes/_context/_cloud/orgs.$organization/projects.$project/ns.$namespace/connect.tsx index 56d20e242d..22159b17ac 100644 --- a/frontend/src/routes/_context/_cloud/orgs.$organization/projects.$project/ns.$namespace/connect.tsx +++ b/frontend/src/routes/_context/_cloud/orgs.$organization/projects.$project/ns.$namespace/connect.tsx @@ -1,15 +1,25 @@ import { faAws, + faChevronRight, + faDiagramNext, faGoogleCloud, faHetznerH, + faNextjs, + faNodeJs, faPlus, faQuestionCircle, faRailway, + faReact, faServer, faVercel, Icon, } from "@rivet-gg/icons"; -import { useInfiniteQuery } from "@tanstack/react-query"; +import { + useInfiniteQuery, + useQuery, + useSuspenseInfiniteQuery, + useSuspenseQuery, +} from "@tanstack/react-query"; import { createFileRoute, notFound, @@ -21,15 +31,23 @@ import { RunnerConfigsTable } from "@/app/runner-config-table"; import { RunnersTable } from "@/app/runners-table"; import { Button, + CodeFrame, + CodeGroup, + CodePreview, + DocsSheet, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, + getConfig, H1, + H2, H3, Skeleton, } from "@/components"; import { useEngineCompatDataProvider } from "@/components/actors"; +import { cloudEnv } from "@/lib/env"; +import { useRailwayTemplateLink } from "@/utils/use-railway-template-link"; export const Route = createFileRoute( "/_context/_cloud/orgs/$organization/projects/$project/ns/$namespace/connect", @@ -39,10 +57,11 @@ export const Route = createFileRoute( .otherwise(() => () => { throw notFound(); }), + pendingComponent: DataLoadingPlaceholder, }); export function RouteComponent() { - const { data: runnerConfigsCount, isLoading } = useInfiniteQuery({ + const { data: runnerConfigsCount, isLoading } = useSuspenseInfiniteQuery({ ...useEngineCompatDataProvider().runnerConfigsQueryOptions(), select: (data) => Object.values(data.pages[0].runnerConfigs).length, refetchInterval: 5000, @@ -53,8 +72,8 @@ export function RouteComponent() { if (isLoading) { return ( -
-
+
+

Connect

@@ -90,10 +109,10 @@ export function RouteComponent() { if (!hasConfigs) { return ( -
-
-
-

Connect

+
+
+
+

Connect Existing Project

+
+
+

Connect New Project

+
+

+ Start a new RivetKit project with Rivet Cloud. Use one + of our templates to get started quickly. +

+ +
+
+

1-Click Deploy From Template

+
+ + +
+
+
+

Quickstart Guides

+
+ + + + + + + + + +
+
+
); } return ( -
-
-

Connect

-
- - - +
+
+
+

Connect

+
+ + + +
-
-

- Connect your RivetKit application to Rivet Cloud. Use your cloud - of choice to run Rivet Actors. -

+

+ Connect your RivetKit application to Rivet Cloud. Use your + cloud of choice to run Rivet Actors. +

-
+
+
+
); } @@ -248,7 +342,7 @@ function Providers() { return (
-
+

Providers

@@ -261,6 +355,9 @@ function Providers() {
+

+ Clouds connected to Rivet for running Rivet Actors. +

@@ -291,9 +388,13 @@ function Runners() { return (
-
+

Runners

+

+ Processes connected to Rivet Cloud and ready to start running + Rivet Actors. +

{ + return useSuspenseQuery( + Route.useRouteContext({ + select: (ctx) => ctx.dataProvider, + }).publishableTokenQueryOptions(), + ).data; + }) + .with("engine", () => { + return useSuspenseQuery( + useEngineCompatDataProvider().engineAdminTokenQueryOptions(), + ).data; + }) + .otherwise(() => { + throw new Error("Not in a valid context"); + }); +} + +const useEndpoint = () => { + return match(__APP_TYPE__) + .with("cloud", () => { + return cloudEnv().VITE_APP_API_URL; + }) + .with("engine", () => { + return getConfig().apiUrl; + }) + .otherwise(() => { + throw new Error("Not in a valid context"); + }); +}; + +function ConnectYourFrontend() { + const token = usePublishableToken(); + const endpoint = useEndpoint(); + + return ( +
+
+

Connect Your Frontend

+
+
+ + + + See JavaScript Documentation{" "} + + + + } + > + + + + + + See React Documentation{" "} + + + + } + > + + + + + + See Next.js Documentation{" "} + + + + } + > + + + +
+
+ ); +} + +const javascriptCode = ({ + token, + endpoint, +}: { + token: string; + endpoint: string; +}) => `import { createClient } from "rivetkit/client"; +import type { registry } from "./registry"; + +// Create typed client +const client = createClient({ + endpoint: "${endpoint}", + token: "${token}", +});`; + +const reactCode = ({ + token, + endpoint, +}: { + token: string; + endpoint: string; +}) => `import { createClient, createRivetKit } from "@rivetkit/react"; +import type { registry } from "./registry"; + +// Create typed client +const client = createClient({ + endpoint: "${endpoint}", + token: "${token}", +}); + +const { useActor } = createRivetKit(client);`; + +const nextJsCode = ({ + token, + endpoint, +}: { + token: string; + endpoint: string; +}) => `"use client"; +import { createClient, createRivetKit } from "@rivetkit/next-js/client"; +import type { registry } from "@/rivet/registry"; + +const client = createClient({ + endpoint: \`\${window.location.origin}/api/rivet\`, + token: "${token}", + transport: "sse", +}); + +export const { useActor } = createRivetKit(client);`; + function ProviderDropdown({ children }: { children: React.ReactNode }) { const navigate = Route.useNavigate(); return ( @@ -386,3 +657,58 @@ function ProviderDropdown({ children }: { children: React.ReactNode }) { ); } + +function DataLoadingPlaceholder() { + return ( +
+
+

+ +

+
+

+ +

+
+
+ + +
+ + + +
+
+
+ + +
+ + + +
+
+
+ ); +} + +function OneClickDeployRailwayButton() { + const url = useRailwayTemplateLink({ + runnerName: "rivet-cloud-starter", + datacenter: "us-east-1", + }); + + return ( + + ); +} diff --git a/frontend/src/utils/use-railway-template-link.ts b/frontend/src/utils/use-railway-template-link.ts new file mode 100644 index 0000000000..64e2bcc1b3 --- /dev/null +++ b/frontend/src/utils/use-railway-template-link.ts @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEngineCompatDataProvider } from "@/components/actors"; +import { engineEnv } from "@/lib/env"; + +export function useRailwayTemplateLink({ + runnerName, + datacenter, +}: { + runnerName: string; + datacenter: string; +}) { + const dataProvider = useEngineCompatDataProvider(); + const { data: token } = useQuery( + dataProvider.engineAdminTokenQueryOptions(), + ); + const endpoint = useDatacenterEndpoint({ datacenter }); + + return `https://railway.com/new/template/rivet-cloud-starter?referralCode=RC7bza&utm_medium=integration&utm_source=template&utm_campaign=generic&RIVET_TOKEN=${token || ""}&RIVET_ENDPOINT=${ + endpoint || "" + }&RIVET_NAMESPACE=${ + dataProvider.engineNamespace || "" + }&RIVET_RUNNER=${runnerName || ""}`; +} + +const useDatacenterEndpoint = ({ datacenter }: { datacenter: string }) => { + const { data } = useQuery( + useEngineCompatDataProvider().regionQueryOptions(datacenter), + ); + return data?.url || engineEnv().VITE_APP_API_URL; +};