diff --git a/frontend/src/app/dev-toolbar.tsx b/frontend/src/app/dev-toolbar.tsx index f8fde87248..87b2d2c887 100644 --- a/frontend/src/app/dev-toolbar.tsx +++ b/frontend/src/app/dev-toolbar.tsx @@ -13,6 +13,7 @@ import { authClient } from "@/lib/auth"; import { features } from "@/lib/features"; export const DevToolbar = () => { + console.log(ls.get("__I_SOLELY_SWORE_TO_ONLY_ENABLE_DEV_TOOLBAR_FOR_DEBUGGING_PURPOSES_AND_WILL_NOT_USE_IT_FOR_ANY_MALICIOUS_ACTIVITIES__")); if ( ls.get( "__I_SOLELY_SWORE_TO_ONLY_ENABLE_DEV_TOOLBAR_FOR_DEBUGGING_PURPOSES_AND_WILL_NOT_USE_IT_FOR_ANY_MALICIOUS_ACTIVITIES__", @@ -92,8 +93,8 @@ const Content = () => {
{user?.email || "Unknown"} - {user?.id || "Unknown"}{" "} - {session?.activeOrganizationId || "Unknown"} + id: {user?.id || "Unknown"}{" "} + org: {session?.activeOrganizationId || "Unknown"}
diff --git a/frontend/src/app/getting-started.tsx b/frontend/src/app/getting-started.tsx index 78c08f1f64..7934777d27 100644 --- a/frontend/src/app/getting-started.tsx +++ b/frontend/src/app/getting-started.tsx @@ -25,6 +25,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import { toast } from "sonner"; import { match } from "ts-pattern"; import z from "zod"; +import * as ConnectServerfullForm from "@/app/forms/connect-manual-serverfull-form"; import * as ConnectServerlessForm from "@/app/forms/connect-manual-serverless-form"; import { AccordionItem, @@ -42,11 +43,12 @@ import { import { useCloudNamespaceDataProvider, useDataProvider, + useEngineCompatDataProvider, } from "@/components/actors"; import { defineStepper } from "@/components/ui/stepper"; import { deriveProviderFromMetadata } from "@/lib/data"; import { successfulBackendSetupEffect } from "@/lib/effects"; -import { cloudEnv } from "@/lib/env"; +import { cloudEnv, engineEnv } from "@/lib/env"; import { features } from "@/lib/features"; import { usePublishableToken } from "@/queries/accessors"; import { queryClient } from "@/queries/global"; @@ -55,6 +57,7 @@ import { Badge } from "../components/ui/badge"; import { Button } from "../components/ui/button"; import { TEST_IDS } from "../utils/test-ids"; import { DeploymentCheck } from "./deployment-check"; +import { RunnerConfigToggleGroup } from "./runner-config-toggle-group"; import { useEndpoint } from "./dialogs/connect-manual-serverfull-frame"; import { buildServerlessConfig, @@ -101,7 +104,27 @@ const stepper = defineStepper( success: z.boolean(), }); } + if ((values.mode as string) === "serverfull") { + return z.object({ + mode: z.literal("serverfull"), + runnerName: z + .string() + .min(1, "Runner name is required"), + datacenter: z + .string() + .min(1, "Please select a region"), + customName: z + .string() + .trim() + .max(32, "Name is too long") + .optional(), + customIcon: z.string().optional(), + }); + } return z.object({ + mode: z + .union([z.literal("serverless"), z.literal("serverfull")]) + .optional(), ...ConnectServerlessForm.configurationSchema.shape, ...ConnectServerlessForm.deploymentSchema.shape, }); @@ -125,12 +148,20 @@ export function GettingStarted({ provider?: Provider; displayFrontendOnboarding?: boolean; }) { - const dataProvider = useCloudNamespaceDataProvider(); + const dataProvider = useEngineCompatDataProvider(); useSuspenseInfiniteQuery(dataProvider.datacentersQueryOptions()); - const { mutateAsync: mutateAsyncManagedPool } = useMutation({ - ...dataProvider.upsertCurrentNamespaceManagedPoolMutationOptions(), - }); + const { mutateAsync: mutateAsyncManagedPool } = useMutation( + "upsertCurrentNamespaceManagedPoolMutationOptions" in dataProvider + ? dataProvider.upsertCurrentNamespaceManagedPoolMutationOptions() + : { + mutationFn: async () => { + throw new Error( + "Managed pools are only available in cloud", + ); + }, + }, + ); const { data: initialRunnerConfig } = useSuspenseQuery({ ...dataProvider.runnerConfigQueryOptions({ @@ -180,6 +211,8 @@ export function GettingStarted({ drainGracePeriod: 0, provider: provider || "", datacenters: {}, + datacenter: "", + mode: "serverless" as "serverless" | "serverfull", ...(initialRunnerConfig || {}), }; @@ -262,7 +295,9 @@ export function GettingStarted({ } await Promise.all([ - ...(features.auth + ...(features.auth && + "publishableTokenQueryOptions" in + dataProvider ? [ queryClient.prefetchQuery( dataProvider.publishableTokenQueryOptions(), @@ -280,22 +315,70 @@ export function GettingStarted({ stepper.current.id === "backend" && values.provider !== "rivet" ) { - const config = await buildServerlessConfig( - dataProvider, - values as unknown as Parameters< - typeof buildServerlessConfig - >[1], - { provider: values.provider as string }, - ); - - await mutateAsync({ - name: ( - values as unknown as { - runnerName: string; - } - ).runnerName, - config, - }); + const v = values as unknown as { + runnerName: string; + provider: string; + mode?: "serverless" | "serverfull"; + datacenter?: string; + customName?: string; + customIcon?: string; + }; + + if (v.mode === "serverfull") { + const existingConfig = + await queryClient.fetchQuery( + dataProvider.runnerConfigQueryOptions( + { + name: v.runnerName, + safe: true, + }, + ), + ); + const existing = + existingConfig?.datacenters || {}; + const isCustom = + v.provider === "custom" || + v.provider === "custom-platform"; + const customName = isCustom + ? v.customName?.trim() || undefined + : undefined; + const customIcon = isCustom + ? v.customIcon || undefined + : undefined; + + await mutateAsync({ + name: v.runnerName, + config: { + ...existing, + [v.datacenter as string]: { + normal: {}, + metadata: { + provider: v.provider, + ...(customName + ? { customName } + : {}), + ...(customIcon + ? { customIcon } + : {}), + }, + }, + }, + }); + } else { + const config = + await buildServerlessConfig( + dataProvider, + values as unknown as Parameters< + typeof buildServerlessConfig + >[1], + { provider: v.provider }, + ); + + await mutateAsync({ + name: v.runnerName, + config, + }); + } await navigate({ to: ".", @@ -339,6 +422,28 @@ function StepContent({ function StepperFooter() { const s = stepper.useStepper(); + const navigate = useNavigate(); + + const skipOnboardingLink = !features.platform ? ( + + ) : null; if (s.current.group === "local" && s.current.id !== "explore") { return ( @@ -354,6 +459,15 @@ function StepperFooter() { > Already have a project working locally? Skip to deploy + {skipOnboardingLink} + + ); + } + + if (skipOnboardingLink) { + return ( +
+ {skipOnboardingLink}
); } @@ -1239,69 +1353,148 @@ function BackendSetupRivet() { function BackendSetup() { const provider = useWatch({ name: "provider" }); + const mode = useWatch({ name: "mode" }) as + | "serverless" + | "serverfull" + | undefined; + const { setValue } = useFormContext(); + + if (provider === "rivet") { + return ; + } + + return ( +
+ + + setValue("mode", value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }) + } + /> + {mode === "serverfull" ? ( + + ) : ( + + )} +
+ ); +} + +function BackendSetupServerless({ provider }: { provider: Provider }) { const endpoint = useWatch({ name: "endpoint" }); + const isCustom = provider === "custom" || provider === "custom-platform"; + return ( + <> +
+ +
+

Set environment variables

+

+ Configure the following environment variables in your + deployment. +

+
+ +
+
+
+
+ +
+

+ Paste your deployment endpoint +

+
+ + + "https://your-vercel-deployment.vercel.app", + ) + .with( + "railway", + () => "https://your-app.up.railway.app", + ) + .otherwise(() => "https://your-deployment.com")} + /> + + ) : null + } + /> + +
+
+
+ + ); +} - if (provider !== "rivet") { - return ( -
- - -
- -
-

- Set environment variables -

-

- Configure the following environment variables in - your deployment. -

-
- -
+function BackendSetupServerfull({ provider }: { provider: Provider }) { + const isCustom = + provider === "custom" || provider === "custom-platform"; + const endpoint = useServerfullEndpoint(); + const runnerName = useWatch({ name: "runnerName" }); + + return ( + <> +
+ +
+

Configure your runner

+
+ + {isCustom ? : null} +
-
- -
-

- Paste your deployment endpoint -

-
- - - "https://your-vercel-deployment.vercel.app", - ) - .with( - "railway", - () => "https://your-app.up.railway.app", - ) - .otherwise( - () => "https://your-deployment.com", - )} - /> - - -
+
+
+ +
+

Set environment variables

+

+ Set the following environment variables on the machine + running your runner. +

+
+
- ); - } + + ); +} - return ; +function useServerfullEndpoint() { + const datacenter = useWatch({ name: "datacenter" }); + const dataProvider = useEngineCompatDataProvider(); + const { data } = useQuery( + dataProvider.datacenterQueryOptions(datacenter || "auto"), + ); + return data?.url || engineEnv().VITE_APP_API_URL; } function FrontendSetup() { diff --git a/frontend/src/components/lib/utils.ts b/frontend/src/components/lib/utils.ts index 601cdca729..e30db6ac4b 100644 --- a/frontend/src/components/lib/utils.ts +++ b/frontend/src/components/lib/utils.ts @@ -69,6 +69,17 @@ export const ls = { )}`, ); }, + skipWelcomeEngine: (ns: string) => { + ls.set( + `onboarding-skip-welcome-engine-${btoa(JSON.stringify({ ns }))}`, + true, + ); + }, + getSkipWelcomeEngine: (ns: string) => { + return ls.get( + `onboarding-skip-welcome-engine-${btoa(JSON.stringify({ ns }))}`, + ); + }, }, actorsEphemeralFilters: { key: "actors-ephemeral-filters", diff --git a/frontend/src/routes/__root.tsx b/frontend/src/routes/__root.tsx index c0bfbb6004..3ae317ec9d 100644 --- a/frontend/src/routes/__root.tsx +++ b/frontend/src/routes/__root.tsx @@ -29,6 +29,7 @@ function CloudRoute() { return ( <> + {import.meta.env.DEV ? ( ) : null} diff --git a/frontend/src/routes/_context/ns.$namespace.tsx b/frontend/src/routes/_context/ns.$namespace.tsx index 04455f9ac5..a507766a59 100644 --- a/frontend/src/routes/_context/ns.$namespace.tsx +++ b/frontend/src/routes/_context/ns.$namespace.tsx @@ -1,8 +1,13 @@ -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { match } from "ts-pattern"; +import { GettingStarted } from "@/app/getting-started"; +import { SidebarlessHeader } from "@/app/layout"; import { NotFoundCard } from "@/app/not-found-card"; import { RouteLayout } from "@/app/route-layout"; import { useDialog } from "@/app/use-dialog"; +import { ls } from "@/components"; +import { deriveProviderFromMetadata } from "@/lib/data"; +import { posthog } from "@/lib/posthog"; import { RECENT_NAMESPACES_KEY, recordRecentVisit, @@ -20,15 +25,118 @@ export const Route = createFileRoute("/_context/ns/$namespace")({ .otherwise(() => { throw new Error("Invalid context type for this route"); }), - beforeLoad: ({ params }) => { + beforeLoad: ({ params, search }) => { recordRecentVisit(RECENT_NAMESPACES_KEY, params.namespace); + + const s = search as unknown as { + skipOnboarding?: boolean; + onboardingSuccess?: boolean; + }; + if (s.skipOnboarding) { + ls.onboarding.skipWelcomeEngine(params.namespace); + posthog.capture("onboarding_skipped", { + namespace: params.namespace, + }); + throw redirect({ to: ".", search: {} }); + } + if (s.onboardingSuccess) { + throw redirect({ to: ".", search: {} }); + } + }, + loaderDeps(opts) { + const s = opts.search as unknown as { + skipOnboarding?: boolean; + backendOnboardingSuccess?: boolean; + onboardingSuccess?: boolean; + }; + return { + skipOnboarding: s.skipOnboarding, + backendOnboardingSuccess: s.backendOnboardingSuccess, + onboardingSuccess: s.onboardingSuccess, + }; + }, + async loader({ params, deps, context }) { + const d = deps as { + skipOnboarding?: boolean; + backendOnboardingSuccess?: boolean; + onboardingSuccess?: boolean; + }; + const isSkipped = + ls.onboarding.getSkipWelcomeEngine(params.namespace) || + d.skipOnboarding; + + if (isSkipped === true) { + return { + dataProvider: context.dataProvider, + displayOnboarding: false, + displayFrontendOnboarding: false, + }; + } + + const [runnerNames, runnerConfigs] = await Promise.all([ + context.queryClient.fetchInfiniteQuery( + context.dataProvider.runnerNamesQueryOptions(), + ), + context.queryClient.fetchInfiniteQuery( + context.dataProvider.runnerConfigsQueryOptions(), + ), + ]); + + const runnerProvider = runnerConfigs.pages + .flatMap((page) => + Object.values(page.runnerConfigs).flatMap((config) => + Object.values(config.datacenters).map((dc) => + deriveProviderFromMetadata(dc.metadata), + ), + ), + ) + .find((provider) => provider !== undefined); + + const actors = await context.queryClient.fetchQuery( + context.dataProvider.actorsCountQueryOptions(), + ); + + const hasRunnerNames = runnerNames.pages[0].names.length > 0; + const hasRunnerConfigs = + Object.entries(runnerConfigs.pages[0].runnerConfigs).length > 0; + const hasActors = actors > 0; + + const hasBackendConfigured = hasRunnerNames || hasRunnerConfigs; + + return { + dataProvider: context.dataProvider, + displayOnboarding: !hasBackendConfigured && !hasActors, + displayFrontendOnboarding: hasBackendConfigured && !hasActors, + provider: runnerProvider, + }; }, - loader: ({ context }) => ({ dataProvider: context.dataProvider }), component: RouteComponent, notFoundComponent: () => , }); function RouteComponent() { + const { + displayOnboarding, + displayFrontendOnboarding, + provider: runnerProvider, + } = Route.useLoaderData(); + const { provider } = Route.useSearch(); + const { namespace } = Route.useParams(); + + if (displayOnboarding || displayFrontendOnboarding) { + return ( + <> + + + + + ); + } + return ( <>