diff --git a/apps/api/README.md b/apps/api/README.md index e12b31db7..3bd3f9783 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -6,3 +6,13 @@ npm run dev ``` open http://localhost:3000 ``` + +## Inngest + +This service uses Inngest to queue deployments. Run the local dev server in a separate terminal: + +``` +npm run dev:inngest +``` + +It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`. diff --git a/apps/api/package.json b/apps/api/package.json index cd1689041..119c2b3f1 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "PORT=4000 tsx watch src/index.ts", + "dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery", "build": "rimraf dist && tsc --project tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc --noEmit" diff --git a/apps/dokploy/components/dashboard/compose/general/actions.tsx b/apps/dokploy/components/dashboard/compose/general/actions.tsx index b64d1fed9..af3e8aced 100644 --- a/apps/dokploy/components/dashboard/compose/general/actions.tsx +++ b/apps/dokploy/components/dashboard/compose/general/actions.tsx @@ -1,4 +1,11 @@ -import { Ban, CheckCircle2, RefreshCcw, Rocket, Terminal } from "lucide-react"; +import { + Ban, + CheckCircle2, + HardDriveDownload, + RefreshCcw, + Rocket, + Terminal, +} from "lucide-react"; import { useRouter } from "next/router"; import { Tooltip as TooltipPrimitive } from "radix-ui"; import { toast } from "sonner"; @@ -82,6 +89,51 @@ export const ComposeActions = ({ composeId }: Props) => { )} + {canDeploy && data?.composeType === "docker-compose" && ( + { + await deploy({ + composeId: composeId, + freshVolumes: true, + }) + .then(() => { + toast.success("Compose deployed with fresh volumes"); + refetch(); + router.push( + `/dashboard/project/${data?.environment.projectId}/environment/${data?.environmentId}/services/compose/${composeId}?tab=deployments`, + ); + }) + .catch(() => { + toast.error("Error deploying compose"); + }); + }} + > + + + )} {canDeploy && ( { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(ONBOARDING_ACTIVE_KEY) === "true"; + } catch { + return false; + } +}; + +export const markOnboardingActive = () => { + try { + window.localStorage.setItem(ONBOARDING_ACTIVE_KEY, "true"); + } catch {} +}; + +export const clearOnboardingActive = () => { + try { + window.localStorage.removeItem(ONBOARDING_ACTIVE_KEY); + window.localStorage.removeItem(ONBOARDING_STATE_KEY); + } catch {} +}; + +export const getOnboardingState = (): OnboardingState => { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(ONBOARDING_STATE_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } +}; + +export const setOnboardingState = (state: OnboardingState) => { + try { + window.localStorage.setItem( + ONBOARDING_STATE_KEY, + JSON.stringify({ ...getOnboardingState(), ...state }), + ); + } catch {} +}; diff --git a/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx new file mode 100644 index 000000000..da29cae7c --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/onboarding-wizard.tsx @@ -0,0 +1,295 @@ +import { defineStepper } from "@stepperize/react"; +import { CheckIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import { GithubIcon } from "@/components/icons/data-tools-icons"; +import { Logo } from "@/components/shared/logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/utils/api"; +import { getOnboardingState, setOnboardingState } from "./onboarding-lock"; +import { CompleteStep } from "./steps/complete-step"; +import { DeployStep } from "./steps/deploy-step"; +import { PlanStep } from "./steps/plan-step"; +import { ProjectStep } from "./steps/project-step"; +import { ServerStep } from "./steps/server-step"; +import { WelcomeStep } from "./steps/welcome-step"; + +const { useStepper, steps, Scoped } = defineStepper( + { id: "welcome", title: "Welcome" }, + { id: "plan", title: "Pick a plan" }, + { id: "project", title: "New project" }, + { id: "server", title: "Connect server" }, + { id: "deploy", title: "Ship something" }, + { id: "complete", title: "You're live" }, +); + +type StepId = (typeof steps)[number]["id"]; +const isStepId = (id: string | undefined): id is StepId => + !!id && steps.some((step) => step.id === id); + +interface Props { + onClose: () => void | Promise; +} + +export const OnboardingWizard = ({ onClose }: Props) => { + const router = useRouter(); + const persisted = getOnboardingState(); + const stepper = useStepper( + isStepId(persisted.stepId) ? persisted.stepId : undefined, + ); + const [projectId, setProjectId] = useState( + persisted.projectId, + ); + const [environmentId, setEnvironmentId] = useState( + persisted.environmentId, + ); + + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const visibleStepIds = isCloud + ? steps.map((step) => step.id) + : steps + .filter((step) => step.id !== "plan" && step.id !== "server") + .map((step) => step.id); + const visibleIndex = visibleStepIds.indexOf(stepper.current.id); + const isLastVisible = visibleIndex === visibleStepIds.length - 1; + const goToNextVisible = () => { + const nextId = visibleStepIds[visibleIndex + 1]; + if (nextId) stepper.goTo(nextId); + }; + + const [skipAllOpen, setSkipAllOpen] = useState(false); + const handleSkipAll = async () => { + await onClose(); + router.push( + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects", + ); + }; + + useEffect(() => { + setOnboardingState({ stepId: stepper.current.id }); + }, [stepper.current.id]); + + const { error: projectCheckError } = api.project.one.useQuery( + { projectId: projectId ?? "" }, + { enabled: !!projectId, retry: false }, + ); + useEffect(() => { + if (!projectCheckError || !projectId) return; + setProjectId(undefined); + setEnvironmentId(undefined); + setOnboardingState({ projectId: undefined, environmentId: undefined }); + stepper.goTo("project"); + }, [projectCheckError, projectId]); + + return ( + <> +
+ + +
+
+ {stepper.switch({ + welcome: () => , + plan: () => , + project: () => ( + { + setProjectId(project.projectId); + setEnvironmentId(project.environmentId); + setOnboardingState({ + projectId: project.projectId, + environmentId: project.environmentId, + }); + goToNextVisible(); + }} + /> + ), + server: () => , + deploy: () => ( + + ), + complete: () => ( + + ), + })} +
+
+
+ + + + + Skip onboarding? + + If this is your first time using Dokploy, we recommend going + through these steps — it only takes a couple of minutes and gives + you a feel for how projects, servers and deployments fit together. + + + + + + + + + + ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx new file mode 100644 index 000000000..7378ab57d --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/complete-step.tsx @@ -0,0 +1,133 @@ +import { + BookIcon, + DatabaseIcon, + GitMergeIcon, + GlobeIcon, + UsersIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import { useEffect, useState } from "react"; +import ConfettiExplosion from "react-confetti-explosion"; +import { Button } from "@/components/ui/button"; +import { displayFont } from "../font"; + +interface Props { + projectId?: string; + environmentId?: string; + onFinish: () => void | Promise; +} + +const features = [ + { + icon: DatabaseIcon, + title: "Databases", + description: "Postgres, MySQL, MongoDB, Redis and more, one click away.", + }, + { + icon: GlobeIcon, + title: "Custom domains", + description: "Attach your own domains and get automatic HTTPS.", + }, + { + icon: GitMergeIcon, + title: "CI/CD", + description: "Auto-deploy on every push from GitHub, GitLab or Bitbucket.", + }, + { + icon: UsersIcon, + title: "Team collaboration", + description: "Invite teammates with fine-grained permissions.", + }, +]; + +export const CompleteStep = ({ projectId, environmentId, onFinish }: Props) => { + const router = useRouter(); + const [showConfetti, setShowConfetti] = useState(false); + const [isFinishing, setIsFinishing] = useState(false); + + useEffect(() => { + setShowConfetti(true); + }, []); + + const projectHref = + projectId && environmentId + ? `/dashboard/project/${projectId}/environment/${environmentId}` + : "/dashboard/projects"; + + const handleFinish = async () => { + setIsFinishing(true); + await onFinish(); + router.push(projectHref); + }; + + return ( +
+
+ {showConfetti && ( + + )} +
+ +
+ + Done + +

+ You're all set. +

+

+ This is just the beginning — here's some of what else you can do. +

+
+ +
+ {features.map((feature, index) => ( +
+
+ {String(index + 1).padStart(2, "0")} +
+
+ + + {feature.title} + + + {feature.description} + +
+
+ ))} +
+ +
+ + +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx new file mode 100644 index 000000000..141aef2d3 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/deploy-step.tsx @@ -0,0 +1,295 @@ +import copy from "copy-to-clipboard"; +import { + ArrowUpRightIcon, + CheckCircle2, + CopyIcon, + Loader2, + XCircleIcon, +} from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { AlertBlock } from "@/components/shared/alert-block"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +interface Props { + environmentId?: string; + onNext: () => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +const CURATED_TEMPLATES = [ + { id: "wordpress", name: "WordPress", logo: "wordpress.png" }, + { id: "ghost", name: "Ghost", logo: "ghost.jpeg" }, + { id: "n8n", name: "n8n", logo: "n8n.png" }, + { id: "uptime-kuma", name: "Uptime Kuma", logo: "uptime-kuma.png" }, +]; + +type Deploying = { kind: "app" | "template"; id: string; url: string }; + +export const DeployStep = ({ environmentId, onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { data: isCloud = true } = api.settings.isCloud.useQuery(); + const { data: servers } = api.server.withSSHKey.useQuery(); + const serverId = servers?.[0]?.serverId; + + const [deploying, setDeploying] = useState(null); + const [starting, setStarting] = useState(null); + + const { mutateAsync: deployNginx } = + api.application.deployNginxQuickstart.useMutation(); + const { mutateAsync: deployTemplate } = + api.compose.deployTemplate.useMutation(); + const { mutateAsync: deployCompose } = api.compose.deploy.useMutation(); + const utils = api.useUtils(); + + const { data: application } = api.application.one.useQuery( + { applicationId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "app", + refetchInterval: (query) => { + const status = query.state.data?.applicationStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + const { data: compose } = api.compose.one.useQuery( + { composeId: deploying?.id ?? "" }, + { + enabled: deploying?.kind === "template", + refetchInterval: (query) => { + const status = query.state.data?.composeStatus; + return status === "done" || status === "error" ? false : 2500; + }, + }, + ); + + const status = + deploying?.kind === "app" + ? application?.applicationStatus + : compose?.composeStatus; + + if (!environmentId || (isCloud && !serverId)) { + return ( +
+
+ + First deploy + +

Deploy something.

+
+ + {isCloud + ? "You'll need a project and a connected server before deploying — you can do that anytime from the dashboard." + : "You'll need a project before deploying — you can do that anytime from the dashboard."} + + +
+ ); + } + + const handleNginx = async () => { + setStarting("nginx"); + try { + const res = await deployNginx({ environmentId, serverId }); + setDeploying({ kind: "app", id: res.applicationId, url: res.domainUrl }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the app", + ); + } finally { + setStarting(null); + } + }; + + const handleTemplate = async (id: string) => { + setStarting(id); + try { + const composeResult = await deployTemplate({ + environmentId, + serverId, + id, + }); + await deployCompose({ composeId: composeResult.composeId }); + const domains = await utils.domain.byComposeId.fetch({ + composeId: composeResult.composeId, + }); + const host = domains?.[0]?.host; + setDeploying({ + kind: "template", + id: composeResult.composeId, + url: host ? `http://${host}` : "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error deploying the template", + ); + } finally { + setStarting(null); + } + }; + + if (deploying) { + const isDone = status === "done"; + const isError = status === "error"; + + return ( +
+
+ + {isDone ? ( + + ) : isError ? ( + + ) : ( + + )} + {isDone ? "Live" : isError ? "Deploy failed" : "Deploying"} + +

+ {isDone + ? "It's live." + : isError + ? "Something went wrong." + : "Building your app..."} +

+

+ {isDone + ? "Your app is up and reachable at:" + : isError + ? "The deployment failed — you can check the logs from the dashboard later." + : "This usually takes a minute or two the first time."} +

+
+ + {isDone && deploying.url ? ( +
+
+ + + Reachable now + +
+
+ + {deploying.url} + +
+ + +
+
+
+ ) : !isDone && !isError ? ( +
+
+ + Waiting for the build to finish... +
+
+ ) : null} + + +
+ ); + } + + return ( +
+
+ + First deploy + +

Ship something.

+

+ Pick a quickstart — we'll generate a domain and deploy it for you. +

+
+ +
+ + +
+
+

Docker Compose template

+

+ Popular open source stacks, one click away. +

+
+
+ {CURATED_TEMPLATES.map((template) => ( + + ))} +
+
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx new file mode 100644 index 000000000..3524332ca --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx @@ -0,0 +1,213 @@ +import { loadStripe } from "@stripe/stripe-js"; +import { ArrowRightIcon, CheckIcon } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { + calculatePriceHobby, + calculatePriceStartup, + STARTUP_SERVERS_INCLUDED, +} from "@/components/dashboard/settings/billing/show-billing"; +import { Button } from "@/components/ui/button"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const stripePromise = loadStripe( + process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!, +); + +interface Props { + onNext: () => void; +} + +export const PlanStep = ({ onNext }: Props) => { + const [loadingTier, setLoadingTier] = useState< + "hobby" | "startup" | "trial" | null + >(null); + const { data } = api.stripe.getProducts.useQuery(); + const { mutateAsync: createCheckoutSession } = + api.stripe.createCheckoutSession.useMutation(); + const { mutateAsync: startFreeTrial } = + api.stripe.startFreeTrial.useMutation(); + const utils = api.useUtils(); + + const handleCheckout = async (tier: "hobby" | "startup") => { + if (!data) return; + const productId = + tier === "hobby" ? data.hobbyProductId : data.startupProductId; + if (!productId) return; + setLoadingTier(tier); + try { + const stripe = await stripePromise; + const session = await createCheckoutSession({ + tier, + productId, + serverQuantity: tier === "startup" ? STARTUP_SERVERS_INCLUDED : 1, + isAnnual: false, + }); + await stripe?.redirectToCheckout({ sessionId: session.sessionId }); + } catch { + toast.error("Error starting checkout"); + setLoadingTier(null); + } + }; + + const handleTrial = async () => { + setLoadingTier("trial"); + try { + await startFreeTrial(); + await utils.project.onboardingStatus.invalidate(); + toast.success("Your 14-day trial has started"); + onNext(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error starting trial", + ); + } finally { + setLoadingTier(null); + } + }; + + return ( +
+
+ + Billing + +

+ Start free, upgrade when ready. +

+

+ No credit card for the trial — add one only if you decide to stay. +

+
+ +
+
+
+ + Recommended + +

+ 14-day free trial +

+

+ No card required — cancel anytime. +

+
    + {[ + "1 server included", + "Unlimited apps & databases", + "Community support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+
+ +
+
+
+

Hobby

+

+ For individual developers +

+

+ ${calculatePriceHobby(1, false).toFixed(2)} + + {" "} + /mo + +

+
    + {[ + "1 server included", + "Unlimited apps & databases", + "2 environments", + "Community support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+ +
+
+

Startup

+

+ For small to mid-size teams +

+

+ $ + {calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed( + 2, + )} + + {" "} + /mo + +

+
    + {[ + `${STARTUP_SERVERS_INCLUDED} servers included`, + "Unlimited users & environments", + "Basic RBAC + 2FA", + "Email & chat support", + ].map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ +
+
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx new file mode 100644 index 000000000..24c37d9d9 --- /dev/null +++ b/apps/dokploy/components/dashboard/onboarding/steps/project-step.tsx @@ -0,0 +1,118 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; +import { displayFont } from "../font"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + description: z.string().optional(), +}); +type Schema = z.infer; + +interface Props { + onNext: (project: { projectId: string; environmentId: string }) => void; + /** Drops the onboarding wizard's display serif for callers (e.g. the + * post-checkout welcome modal) that want the app's regular typography. */ + plainTitle?: boolean; +} + +export const ProjectStep = ({ onNext, plainTitle }: Props) => { + const titleClassName = plainTitle + ? "text-xl font-semibold tracking-tight" + : `${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`; + const { mutateAsync, isPending } = api.project.create.useMutation(); + const utils = api.useUtils(); + + const form = useForm({ + defaultValues: { name: "My First Project", description: "" }, + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: Schema) => { + try { + const result = await mutateAsync(data); + await utils.project.all.invalidate(); + toast.success("Project created"); + onNext({ + projectId: result.project.projectId, + environmentId: result.environment?.environmentId ?? "", + }); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Error creating the project", + ); + } + }; + + return ( +
+
+ + Workspace + +

Create your first project.

+

+ Projects group your apps, databases and environments together. +

+
+ +
+ + ( + + Name + + + + + + )} + /> + ( + + Description (optional) + +