mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-12 19:51:00 +05:00
Merge canary and regenerate the migration at a free number
canary landed its own 0190 (0190_nappy_anita_blake), which collides with this branch's 0190_perpetual_red_skull. Renaming would not have been enough: drizzle applies migrations by comparing timestamps against the last applied one, so this branch's older `when` would have been silently skipped on any deployment that had already run canary's 0190, leaving the enum without its new value. Regenerated as 0191_cool_christian_walker with a current timestamp.
This commit is contained in:
commit
18e617eb14
@ -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`.
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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) => {
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && data?.composeType === "docker-compose" && (
|
||||
<DialogAction
|
||||
title="Deploy with Fresh Volumes"
|
||||
description="This will remove all volumes and redeploy with a clean state. All persistent data will be permanently deleted."
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
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");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={data?.composeStatus === "running"}
|
||||
className="flex items-center gap-1.5 group focus-visible:ring-2 focus-visible:ring-offset-2"
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<HardDriveDownload className="size-4 mr-1" />
|
||||
Fresh Volumes
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipContent sideOffset={5} className="z-[60]">
|
||||
<p>
|
||||
Deploy with fresh volumes (removes all persistent data)
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</TooltipPrimitive.Portal>
|
||||
</Tooltip>
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
{canDeploy && (
|
||||
<DialogAction
|
||||
title="Rebuild Compose"
|
||||
|
||||
8
apps/dokploy/components/dashboard/onboarding/font.ts
Normal file
8
apps/dokploy/components/dashboard/onboarding/font.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { Fraunces } from "next/font/google";
|
||||
|
||||
export const displayFont = Fraunces({
|
||||
subsets: ["latin"],
|
||||
weight: ["600"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
const ONBOARDING_ACTIVE_KEY = "dokploy_onboarding_active";
|
||||
const ONBOARDING_STATE_KEY = "dokploy_onboarding_state";
|
||||
|
||||
interface OnboardingState {
|
||||
stepId?: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
}
|
||||
|
||||
export const isOnboardingActive = () => {
|
||||
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 {}
|
||||
};
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
export const OnboardingWizard = ({ onClose }: Props) => {
|
||||
const router = useRouter();
|
||||
const persisted = getOnboardingState();
|
||||
const stepper = useStepper(
|
||||
isStepId(persisted.stepId) ? persisted.stepId : undefined,
|
||||
);
|
||||
const [projectId, setProjectId] = useState<string | undefined>(
|
||||
persisted.projectId,
|
||||
);
|
||||
const [environmentId, setEnvironmentId] = useState<string | undefined>(
|
||||
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 (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex flex-col md:flex-row bg-background text-foreground">
|
||||
<aside className="relative shrink-0 md:w-[300px] lg:w-[340px] overflow-hidden bg-zinc-950 text-zinc-400">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-[0.07]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"linear-gradient(to right, white 1px, transparent 1px), linear-gradient(to bottom, white 1px, transparent 1px)",
|
||||
backgroundSize: "28px 28px",
|
||||
}}
|
||||
/>
|
||||
<div className="pointer-events-none absolute -top-24 -left-24 size-72 rounded-full bg-white/10 blur-[100px]" />
|
||||
|
||||
<div className="relative flex md:h-full flex-col p-6 lg:p-8">
|
||||
<div className="flex items-center justify-between md:block">
|
||||
<div className="flex items-center gap-2.5 text-zinc-50 [&_path]:!fill-white [&_path]:!stroke-white">
|
||||
<Logo className="size-7" />
|
||||
<span className="text-lg font-semibold tracking-tight">
|
||||
Dokploy
|
||||
</span>
|
||||
</div>
|
||||
{!isLastVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkipAllOpen(true)}
|
||||
className="font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors md:hidden"
|
||||
>
|
||||
Skip all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Scoped>
|
||||
<ol className="hidden md:flex flex-col gap-0.5 mt-12">
|
||||
{visibleStepIds.map((id, index) => {
|
||||
const step = steps.find((s) => s.id === id)!;
|
||||
const isDone = index < visibleIndex;
|
||||
const isCurrent = stepper.current.id === step.id;
|
||||
return (
|
||||
<li key={step.id} className="flex gap-3.5">
|
||||
<div className="flex flex-col items-center">
|
||||
<span
|
||||
className={`flex items-center justify-center font-mono text-[11px] tabular-nums size-5 rounded-full ${
|
||||
isCurrent
|
||||
? "bg-white text-zinc-950 font-semibold"
|
||||
: isDone
|
||||
? "text-zinc-100"
|
||||
: "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckIcon className="size-3" />
|
||||
) : (
|
||||
String(index + 1).padStart(2, "0")
|
||||
)}
|
||||
</span>
|
||||
{index < visibleStepIds.length - 1 && (
|
||||
<span
|
||||
className={`w-px flex-1 min-h-7 my-1.5 ${
|
||||
isDone ? "bg-zinc-400" : "bg-zinc-700"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isDone ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
className="text-sm font-medium transition-colors pb-6 last:pb-0 text-zinc-100 hover:text-white text-left"
|
||||
>
|
||||
{step.title}
|
||||
</button>
|
||||
) : (
|
||||
<p
|
||||
className={`text-sm font-medium transition-colors pb-6 last:pb-0 ${
|
||||
isCurrent ? "text-white" : "text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</Scoped>
|
||||
|
||||
<div className="hidden md:flex flex-col gap-4 mt-auto pt-8 border-t border-zinc-800">
|
||||
{!isLastVisible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSkipAllOpen(true)}
|
||||
className="w-fit font-mono text-[11px] uppercase tracking-wider text-zinc-400 hover:text-zinc-100 transition-colors"
|
||||
>
|
||||
Skip all →
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-3 text-zinc-500">
|
||||
<Link
|
||||
href="https://github.com/dokploy/dokploy"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<GithubIcon />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://x.com/getdokploy"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="0"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="size-4"
|
||||
>
|
||||
<path d="M10.4883 14.651L15.25 21H22.25L14.3917 10.5223L20.9308 3H18.2808L13.1643 8.88578L8.75 3H1.75L9.26086 13.0145L2.31915 21H4.96917L10.4883 14.651ZM16.25 19L5.75 5H7.75L18.25 19H16.25Z" />
|
||||
</svg>
|
||||
</Link>
|
||||
<Link
|
||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||
target="_blank"
|
||||
className="hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 48 48"
|
||||
className="size-4"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M39.248,10.177c-2.804-1.287-5.812-2.235-8.956-2.778c-0.057-0.01-0.114,0.016-0.144,0.068 c-0.387,0.688-0.815,1.585-1.115,2.291c-3.382-0.506-6.747-0.506-10.059,0c-0.3-0.721-0.744-1.603-1.133-2.291 c-0.03-0.051-0.087-0.077-0.144-0.068c-3.143,0.541-6.15,1.489-8.956,2.778c-0.024,0.01-0.045,0.028-0.059,0.051 c-5.704,8.522-7.267,16.835-6.5,25.044c0.003,0.04,0.026,0.079,0.057,0.103c3.763,2.764,7.409,4.442,10.987,5.554 c0.057,0.017,0.118-0.003,0.154-0.051c0.846-1.156,1.601-2.374,2.248-3.656c0.038-0.075,0.002-0.164-0.076-0.194 c-1.197-0.454-2.336-1.007-3.432-1.636c-0.087-0.051-0.094-0.175-0.014-0.234c0.231-0.173,0.461-0.353,0.682-0.534 c0.04-0.033,0.095-0.04,0.142-0.019c7.201,3.288,14.997,3.288,22.113,0c0.047-0.023,0.102-0.016,0.144,0.017 c0.22,0.182,0.451,0.363,0.683,0.536c0.08,0.059,0.075,0.183-0.012,0.234c-1.096,0.641-2.236,1.182-3.434,1.634 c-0.078,0.03-0.113,0.12-0.075,0.196c0.661,1.28,1.415,2.498,2.246,3.654c0.035,0.049,0.097,0.07,0.154,0.052 c3.595-1.112,7.241-2.79,11.004-5.554c0.033-0.024,0.054-0.061,0.057-0.101c0.917-9.491-1.537-17.735-6.505-25.044 C39.293,10.205,39.272,10.187,39.248,10.177z M16.703,30.273c-2.168,0-3.954-1.99-3.954-4.435s1.752-4.435,3.954-4.435 c2.22,0,3.989,2.008,3.954,4.435C20.658,28.282,18.906,30.273,16.703,30.273z M31.324,30.273c-2.168,0-3.954-1.99-3.954-4.435 s1.752-4.435,3.954-4.435c2.22,0,3.989,2.008,3.954,4.435C35.278,28.282,33.544,30.273,31.324,30.273z"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto w-full max-w-2xl px-6 py-14 lg:py-20">
|
||||
{stepper.switch({
|
||||
welcome: () => <WelcomeStep onNext={goToNextVisible} />,
|
||||
plan: () => <PlanStep onNext={goToNextVisible} />,
|
||||
project: () => (
|
||||
<ProjectStep
|
||||
onNext={(project) => {
|
||||
setProjectId(project.projectId);
|
||||
setEnvironmentId(project.environmentId);
|
||||
setOnboardingState({
|
||||
projectId: project.projectId,
|
||||
environmentId: project.environmentId,
|
||||
});
|
||||
goToNextVisible();
|
||||
}}
|
||||
/>
|
||||
),
|
||||
server: () => <ServerStep onNext={goToNextVisible} />,
|
||||
deploy: () => (
|
||||
<DeployStep
|
||||
environmentId={environmentId}
|
||||
onNext={goToNextVisible}
|
||||
/>
|
||||
),
|
||||
complete: () => (
|
||||
<CompleteStep
|
||||
projectId={projectId}
|
||||
environmentId={environmentId}
|
||||
onFinish={onClose}
|
||||
/>
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={skipAllOpen} onOpenChange={setSkipAllOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Skip onboarding?</DialogTitle>
|
||||
<DialogDescription>
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSkipAllOpen(false)}>
|
||||
Continue setup
|
||||
</Button>
|
||||
<Button onClick={handleSkipAll}>Skip anyway</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -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<void>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.35}
|
||||
particleCount={200}
|
||||
width={1200}
|
||||
zIndex={60}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Done
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
You're all set.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
This is just the beginning — here's some of what else you can do.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className="flex gap-6 py-5 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
{feature.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
size="lg"
|
||||
className="w-fit px-8"
|
||||
isLoading={isFinishing}
|
||||
onClick={handleFinish}
|
||||
>
|
||||
Go to my project
|
||||
</Button>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
target="_blank"
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<BookIcon size={14} />
|
||||
Read the docs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<Deploying | null>(null);
|
||||
const [starting, setStarting] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
First deploy
|
||||
</span>
|
||||
<h1 className={titleClassName}>Deploy something.</h1>
|
||||
</div>
|
||||
<AlertBlock type="info" className="max-w-md">
|
||||
{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."}
|
||||
</AlertBlock>
|
||||
<Button onClick={onNext} className="w-fit px-8">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span
|
||||
className={`font-mono text-xs uppercase tracking-[0.2em] flex items-center gap-2 ${
|
||||
isError ? "text-destructive" : "text-primary"
|
||||
}`}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
) : isError ? (
|
||||
<XCircleIcon className="size-3.5" />
|
||||
) : (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{isDone ? "Live" : isError ? "Deploy failed" : "Deploying"}
|
||||
</span>
|
||||
<h1 className={titleClassName}>
|
||||
{isDone
|
||||
? "It's live."
|
||||
: isError
|
||||
? "Something went wrong."
|
||||
: "Building your app..."}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
{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."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isDone && deploying.url ? (
|
||||
<div className="flex flex-col rounded-2xl border overflow-hidden max-w-md">
|
||||
<div className="flex items-center gap-2 px-5 py-3 border-b bg-muted/30">
|
||||
<span className="size-2 rounded-full bg-green-500 shrink-0" />
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
Reachable now
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-5">
|
||||
<span className="font-mono text-sm truncate">
|
||||
{deploying.url}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
copy(deploying.url);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<a href={deploying.url} target="_blank" rel="noreferrer">
|
||||
<ArrowUpRightIcon className="size-4" />
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !isDone && !isError ? (
|
||||
<div className="rounded-2xl border p-5 max-w-md">
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Waiting for the build to finish...
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="w-fit px-8"
|
||||
disabled={!isDone && !isError}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
First deploy
|
||||
</span>
|
||||
<h1 className={titleClassName}>Ship something.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Pick a quickstart — we'll generate a domain and deploy it for you.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNginx}
|
||||
disabled={!!starting}
|
||||
className="flex flex-col items-start justify-between gap-8 bg-background p-7 text-left hover:bg-muted/40 transition-colors disabled:opacity-60"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium">Simple app</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
A "Hello World" demo app, live in seconds.
|
||||
</p>
|
||||
</div>
|
||||
<span className="font-mono text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-2">
|
||||
{starting === "nginx" ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Starting...
|
||||
</>
|
||||
) : (
|
||||
"Deploy now →"
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col justify-between gap-8 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Docker Compose template</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Popular open source stacks, one click away.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CURATED_TEMPLATES.map((template) => (
|
||||
<Button
|
||||
key={template.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!!starting}
|
||||
isLoading={starting === template.id}
|
||||
onClick={() => handleTemplate(template.id)}
|
||||
className="justify-start"
|
||||
>
|
||||
<img
|
||||
src={`https://templates.dokploy.com/blueprints/${template.id}/${template.logo}`}
|
||||
alt={template.name}
|
||||
className="size-4 object-contain shrink-0"
|
||||
/>
|
||||
{template.name}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
213
apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx
Normal file
213
apps/dokploy/components/dashboard/onboarding/steps/plan-step.tsx
Normal file
@ -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 (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Billing
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
Start free, upgrade when ready.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
No credit card for the trial — add one only if you decide to stay.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col rounded-2xl bg-zinc-950 dark:bg-white text-white dark:text-zinc-950 overflow-hidden">
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-6 p-7">
|
||||
<div>
|
||||
<span className="inline-flex items-center rounded-full bg-white text-zinc-950 dark:bg-zinc-950 dark:text-white font-mono text-[10px] font-semibold uppercase tracking-[0.15em] px-2.5 py-1">
|
||||
Recommended
|
||||
</span>
|
||||
<p className={`${displayFont.className} text-2xl mt-3`}>
|
||||
14-day free trial
|
||||
</p>
|
||||
<p className="text-sm text-zinc-400 dark:text-zinc-600 mt-1 max-w-xs">
|
||||
No card required — cancel anytime.
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"1 server included",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-zinc-300 dark:text-zinc-700"
|
||||
>
|
||||
<CheckIcon className="size-3.5 text-zinc-500 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
size="lg"
|
||||
className="bg-white text-zinc-950 hover:bg-zinc-200 dark:bg-zinc-950 dark:text-white dark:hover:bg-zinc-800 w-fit shrink-0 px-6"
|
||||
isLoading={loadingTier === "trial"}
|
||||
disabled={loadingTier !== null}
|
||||
onClick={handleTrial}
|
||||
>
|
||||
Start trial
|
||||
<ArrowRightIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-px bg-border rounded-2xl overflow-hidden border">
|
||||
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Hobby</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
For individual developers
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
${calculatePriceHobby(1, false).toFixed(2)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
"1 server included",
|
||||
"Unlimited apps & databases",
|
||||
"2 environments",
|
||||
"Community support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loadingTier === "hobby"}
|
||||
disabled={loadingTier !== null || !data?.hobbyProductId}
|
||||
onClick={() => handleCheckout("hobby")}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col justify-between gap-6 bg-background p-7">
|
||||
<div>
|
||||
<p className="font-medium">Startup</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
For small to mid-size teams
|
||||
</p>
|
||||
<p className="text-3xl font-semibold mt-4 tabular-nums">
|
||||
$
|
||||
{calculatePriceStartup(STARTUP_SERVERS_INCLUDED, false).toFixed(
|
||||
2,
|
||||
)}
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
{" "}
|
||||
/mo
|
||||
</span>
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 mt-4">
|
||||
{[
|
||||
`${STARTUP_SERVERS_INCLUDED} servers included`,
|
||||
"Unlimited users & environments",
|
||||
"Basic RBAC + 2FA",
|
||||
"Email & chat support",
|
||||
].map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{f}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
isLoading={loadingTier === "startup"}
|
||||
disabled={loadingTier !== null || !data?.startupProductId}
|
||||
onClick={() => handleCheckout("startup")}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -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<typeof schema>;
|
||||
|
||||
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<Schema>({
|
||||
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 (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Workspace
|
||||
</span>
|
||||
<h1 className={titleClassName}>Create your first project.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Projects group your apps, databases and environments together.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-5 max-w-sm"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My First Project" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description (optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="What is this project for?"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isPending}
|
||||
className="w-fit px-8 mt-2"
|
||||
>
|
||||
Create project
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,346 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CheckCircle2, CopyIcon, Loader2, XCircle } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { api } from "@/utils/api";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, "Name is required"),
|
||||
ipAddress: z
|
||||
.string()
|
||||
.min(1, "IP address is required")
|
||||
.refine((value) => !/\s/.test(value), "IP address cannot contain spaces"),
|
||||
});
|
||||
type Schema = z.infer<typeof schema>;
|
||||
|
||||
interface Props {
|
||||
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 StatusRow = ({
|
||||
label,
|
||||
ok,
|
||||
pending,
|
||||
}: {
|
||||
label: string;
|
||||
ok?: boolean;
|
||||
pending?: boolean;
|
||||
}) => (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{ok ? (
|
||||
<CheckCircle2 className="size-4 text-green-600 dark:text-green-500 shrink-0" />
|
||||
) : pending ? (
|
||||
<Loader2 className="size-4 text-muted-foreground shrink-0 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
<span className={ok ? "" : "text-muted-foreground"}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ServerStep = ({ 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: sshKeys, refetch: refetchSSHKeys } = api.sshKey.all.useQuery();
|
||||
const generateSSHKey = api.sshKey.generate.useMutation();
|
||||
const createSSHKey = api.sshKey.create.useMutation();
|
||||
const hasCreatedKey = useRef(false);
|
||||
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-onboarding-ssh-key",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ensureKey = async () => {
|
||||
if (!sshKeys || cloudSSHKey || hasCreatedKey.current) return;
|
||||
hasCreatedKey.current = true;
|
||||
try {
|
||||
const keys = await generateSSHKey.mutateAsync({ type: "rsa" });
|
||||
await createSSHKey.mutateAsync({
|
||||
name: "dokploy-onboarding-ssh-key",
|
||||
description: "Used during onboarding",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
organizationId: "",
|
||||
});
|
||||
await refetchSSHKeys();
|
||||
} catch {
|
||||
hasCreatedKey.current = false;
|
||||
}
|
||||
};
|
||||
ensureKey();
|
||||
}, [sshKeys]);
|
||||
|
||||
const { data: existingServers } = api.server.all.useQuery();
|
||||
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createdServerId && existingServers && existingServers.length > 0) {
|
||||
setCreatedServerId(existingServers[0]!.serverId);
|
||||
}
|
||||
}, [existingServers, createdServerId]);
|
||||
|
||||
const { data: canCreateMoreServers } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync: createServer, isPending: isCreating } =
|
||||
api.server.create.useMutation();
|
||||
const checkIsReady = (validation?: {
|
||||
docker?: { enabled?: boolean };
|
||||
isDokployNetworkInstalled?: boolean;
|
||||
}) =>
|
||||
!!validation?.docker?.enabled && !!validation?.isDokployNetworkInstalled;
|
||||
|
||||
const {
|
||||
data: validation,
|
||||
refetch: refetchValidation,
|
||||
isFetching: isValidating,
|
||||
} = api.server.validate.useQuery(
|
||||
{ serverId: createdServerId ?? "" },
|
||||
{
|
||||
enabled: !!createdServerId,
|
||||
refetchInterval: (query) =>
|
||||
checkIsReady(query.state.data) ? false : 5000,
|
||||
},
|
||||
);
|
||||
const isReady = checkIsReady(validation);
|
||||
|
||||
const [isSettingUp, setIsSettingUp] = useState(false);
|
||||
const hasStartedSetup = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (createdServerId && !hasStartedSetup.current && !isReady) {
|
||||
hasStartedSetup.current = true;
|
||||
setIsSettingUp(true);
|
||||
}
|
||||
}, [createdServerId, isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSettingUp) return;
|
||||
const timeout = setTimeout(() => {
|
||||
setIsSettingUp(false);
|
||||
toast.error(
|
||||
"Setup is taking too long — check the server is reachable, then try again.",
|
||||
);
|
||||
}, 120_000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [isSettingUp]);
|
||||
|
||||
api.server.setupWithLogs.useSubscription(
|
||||
{ serverId: createdServerId ?? "" },
|
||||
{
|
||||
enabled: !!createdServerId && isSettingUp,
|
||||
onData(log) {
|
||||
if (typeof log === "string" && log.includes("Setup Server:")) {
|
||||
setIsSettingUp(false);
|
||||
refetchValidation();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
setIsSettingUp(false);
|
||||
toast.error("Error setting up the server");
|
||||
console.error(error);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: { name: "My First Server", ipAddress: "" },
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
if (!cloudSSHKey) {
|
||||
toast.error("Still generating your SSH key, try again in a moment");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const server = await createServer({
|
||||
name: data.name,
|
||||
description: "",
|
||||
ipAddress: data.ipAddress.trim(),
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey.sshKeyId,
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: false,
|
||||
});
|
||||
setCreatedServerId(server.serverId);
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error creating the server",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-10">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Infrastructure
|
||||
</span>
|
||||
<h1 className={titleClassName}>Connect a server.</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
Dokploy deploys to servers you own — buy one from any VPS provider
|
||||
(Hetzner, DigitalOcean, Hostinger...) and paste its IP below.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!createdServerId ? (
|
||||
<div className="flex flex-col gap-4 max-w-sm">
|
||||
{canCreateMoreServers === false && (
|
||||
<AlertBlock type="warning">
|
||||
You'll need a plan or trial before connecting a server — go back
|
||||
to the "Pick a plan" step.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 rounded-lg border p-3">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
1. Run this on your server to authorize Dokploy
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 min-w-0 truncate rounded bg-muted px-2 py-1.5 text-xs">
|
||||
{cloudSSHKey
|
||||
? `echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`
|
||||
: "Generating..."}
|
||||
</code>
|
||||
{cloudSSHKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`echo "${cloudSSHKey.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Server name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="My First Server" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>2. IP address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={isCreating}
|
||||
disabled={!cloudSSHKey || canCreateMoreServers === false}
|
||||
className="mt-2"
|
||||
>
|
||||
Connect server
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-sm">
|
||||
<div className="flex flex-col gap-3 rounded-lg border p-4">
|
||||
{isSettingUp ? (
|
||||
<div className="flex items-center justify-center gap-3 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin shrink-0" />
|
||||
Setting up your server — installing Docker and dependencies...
|
||||
</div>
|
||||
) : isValidating && !validation ? (
|
||||
<div className="flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Checking your server...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<StatusRow
|
||||
label="Docker installed"
|
||||
ok={validation?.docker?.enabled}
|
||||
pending={!validation?.docker?.enabled && !isReady}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy network created"
|
||||
ok={validation?.isDokployNetworkInstalled}
|
||||
pending={!validation?.isDokployNetworkInstalled && !isReady}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isReady && !isSettingUp && (
|
||||
<AlertBlock type="info">
|
||||
Setup usually takes a minute after the server boots — this checks
|
||||
automatically. You can retry setup, or use "Skip for now" above to
|
||||
continue and come back to this later.
|
||||
</AlertBlock>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
isLoading={isValidating || isSettingUp}
|
||||
onClick={() => {
|
||||
if (!isReady && !isSettingUp) {
|
||||
setIsSettingUp(true);
|
||||
} else {
|
||||
refetchValidation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!isReady && !isSettingUp ? "Retry setup" : "Check now"}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={!isReady || isSettingUp}
|
||||
onClick={onNext}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,71 @@
|
||||
import { GitBranchIcon, PuzzleIcon, ServerIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { displayFont } from "../font";
|
||||
|
||||
interface Props {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const points = [
|
||||
{
|
||||
icon: GitBranchIcon,
|
||||
title: "Deploy from Git or Docker",
|
||||
description:
|
||||
"Push a repo, a compose file, or a container image and go live.",
|
||||
},
|
||||
{
|
||||
icon: PuzzleIcon,
|
||||
title: "One-click templates",
|
||||
description:
|
||||
"WordPress, databases, and dozens of open source apps, pre-wired.",
|
||||
},
|
||||
{
|
||||
icon: ServerIcon,
|
||||
title: "Your own servers",
|
||||
description: "Runs on infrastructure you control — no vendor lock-in.",
|
||||
},
|
||||
];
|
||||
|
||||
export const WelcomeStep = ({ onNext }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-12">
|
||||
<div className="flex flex-col gap-4">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Welcome
|
||||
</span>
|
||||
<h1
|
||||
className={`${displayFont.className} text-4xl sm:text-5xl leading-[1.05] tracking-tight`}
|
||||
>
|
||||
Let's get your first app <em className="not-italic">live</em>.
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg max-w-md leading-relaxed">
|
||||
A few steps — pick a plan, connect a server, ship something. You'll
|
||||
have a working URL by the end.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{points.map((point, index) => (
|
||||
<div key={point.title} className="flex gap-6 py-5 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<point.icon className="size-4 text-muted-foreground" />
|
||||
{point.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{point.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -4,6 +4,7 @@ import {
|
||||
AlertTriangle,
|
||||
Bell,
|
||||
CheckIcon,
|
||||
Clock,
|
||||
CreditCard,
|
||||
FileText,
|
||||
Loader2,
|
||||
@ -93,6 +94,7 @@ export const ShowBilling = () => {
|
||||
const router = useRouter();
|
||||
const { data: servers } = api.server.count.useQuery();
|
||||
const { data: admin } = api.user.get.useQuery();
|
||||
const { data: billingStatus } = api.stripe.getBillingStatus.useQuery();
|
||||
const { data, isPending } = api.stripe.getProducts.useQuery();
|
||||
const { mutateAsync: createCheckoutSession } =
|
||||
api.stripe.createCheckoutSession.useMutation();
|
||||
@ -103,8 +105,26 @@ export const ShowBilling = () => {
|
||||
api.stripe.upgradeSubscription.useMutation();
|
||||
const { mutateAsync: updateInvoiceNotifications } =
|
||||
api.stripe.updateInvoiceNotifications.useMutation();
|
||||
const { mutateAsync: startFreeTrial, isPending: isStartingTrial } =
|
||||
api.stripe.startFreeTrial.useMutation();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const handleStartTrial = async () => {
|
||||
try {
|
||||
await startFreeTrial();
|
||||
await Promise.all([
|
||||
utils.stripe.getBillingStatus.invalidate(),
|
||||
utils.stripe.getProducts.invalidate(),
|
||||
utils.user.get.invalidate(),
|
||||
]);
|
||||
toast.success("Your 14-day trial has started");
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Error starting trial",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
|
||||
const [startupServerQuantity, setStartupServerQuantity] = useState(
|
||||
STARTUP_SERVERS_INCLUDED,
|
||||
@ -249,6 +269,94 @@ export const ShowBilling = () => {
|
||||
</nav>
|
||||
|
||||
<div className="flex flex-col gap-4 w-full mt-6">
|
||||
{!isEnterpriseCloud &&
|
||||
billingStatus &&
|
||||
(billingStatus.plan || billingStatus.isOnTrial) && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border p-4 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
{billingStatus.isOnTrial ? (
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
) : (
|
||||
<CreditCard className="h-5 w-5 text-primary shrink-0" />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">
|
||||
{billingStatus.isOnTrial
|
||||
? "Free trial"
|
||||
: "Current plan"}
|
||||
</span>
|
||||
<Badge className="capitalize" variant="secondary">
|
||||
{billingStatus.plan ?? "Trial"}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{billingStatus.isOnTrial
|
||||
? `${billingStatus.trialDaysRemaining} day${billingStatus.trialDaysRemaining === 1 ? "" : "s"} left${
|
||||
billingStatus.trialEndsAt
|
||||
? ` · ends ${new Date(billingStatus.trialEndsAt).toLocaleDateString()}`
|
||||
: ""
|
||||
}`
|
||||
: "You're subscribed and billed automatically."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{billingStatus.isOnTrial &&
|
||||
admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
const session = await createCustomerPortalSession();
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
Add payment method
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isEnterpriseCloud &&
|
||||
billingStatus &&
|
||||
!billingStatus.plan &&
|
||||
!billingStatus.isOnTrial &&
|
||||
!billingStatus.hasUsedTrial && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-primary/30 bg-primary/5 p-4 max-w-2xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Clock className="h-5 w-5 text-primary shrink-0" />
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-sm font-medium">
|
||||
14-day free trial
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
No credit card required — cancel anytime.
|
||||
</span>
|
||||
<ul className="flex flex-col gap-1 mt-1">
|
||||
{[
|
||||
"1 server included",
|
||||
"Unlimited apps & databases",
|
||||
"Community support",
|
||||
].map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<CheckIcon className="size-3.5 shrink-0" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
isLoading={isStartingTrial}
|
||||
onClick={handleStartTrial}
|
||||
>
|
||||
Start trial
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
|
||||
<div className="space-y-2 flex flex-col">
|
||||
<h3 className="text-lg font-medium">Servers Plan</h3>
|
||||
|
||||
@ -1,61 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ShowBilling } from "@/components/dashboard/settings/billing/show-billing";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const ShowWelcomeDokploy = () => {
|
||||
const { data } = api.user.get.useQuery();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data: isCloud, isPending } = api.settings.isCloud.useQuery();
|
||||
|
||||
if (!isCloud || data?.role !== "admin") {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isPending &&
|
||||
isCloud &&
|
||||
!localStorage.getItem("hasSeenCloudWelcomeModal") &&
|
||||
data?.role === "owner"
|
||||
) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [isCloud, isPending]);
|
||||
|
||||
const handleClose = (isOpen: boolean) => {
|
||||
if (data?.role === "owner") {
|
||||
setOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
localStorage.setItem("hasSeenCloudWelcomeModal", "true"); // Establece el flag al cerrar el modal
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-center">
|
||||
Welcome to Dokploy Cloud 🎉
|
||||
</DialogTitle>
|
||||
<p className="text-center text-sm text-muted-foreground mt-2">
|
||||
Unlock powerful features to streamline your deployments and manage
|
||||
projects effortlessly.
|
||||
</p>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-3 text-sm text-primary ">
|
||||
<ShowBilling />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
import { Stethoscope } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ShowHealth } from "@/components/dashboard/docker/health/show-health";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
|
||||
interface Props {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export const ShowHealthModal = ({ serverId }: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
<Stethoscope className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[85vh] overflow-y-auto">
|
||||
<div className="flex gap-4 py-4 w-full">
|
||||
<ShowHealth serverId={serverId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@ -33,7 +33,6 @@ import { ShowServerActions } from "./actions/show-server-actions";
|
||||
import { DeleteServerModal } from "./delete-server-modal";
|
||||
import { HandleServers } from "./handle-servers";
|
||||
import { SetupServer } from "./setup-server";
|
||||
import { ShowHealthModal } from "./show-health-modal";
|
||||
import { ShowMonitoringModal } from "./show-monitoring-modal";
|
||||
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
|
||||
|
||||
@ -322,24 +321,6 @@ export const ShowServers = () => {
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{permissions?.docker.read &&
|
||||
permissions?.server.read &&
|
||||
server.sshKeyId &&
|
||||
!isBuildServer && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<ShowHealthModal
|
||||
serverId={server.serverId}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Health</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{permissions?.server.delete && (
|
||||
|
||||
@ -1,281 +0,0 @@
|
||||
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { DialogFooter } from "@/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Schema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
ipAddress: z.string().min(1, {
|
||||
message: "IP Address is required",
|
||||
}),
|
||||
port: z.number().optional(),
|
||||
username: z.string().optional(),
|
||||
sshKeyId: z.string().min(1, {
|
||||
message: "SSH Key is required",
|
||||
}),
|
||||
});
|
||||
|
||||
type Schema = z.infer<typeof Schema>;
|
||||
|
||||
interface Props {
|
||||
stepper: any;
|
||||
}
|
||||
|
||||
export const CreateServer = ({ stepper }: Props) => {
|
||||
const { data: sshKeys } = api.sshKey.all.useQuery();
|
||||
const [isOpen, _setIsOpen] = useState(false);
|
||||
const { data: canCreateMoreServers, refetch } =
|
||||
api.stripe.canCreateMoreServers.useQuery();
|
||||
const { mutateAsync } = api.server.create.useMutation();
|
||||
const cloudSSHKey = sshKeys?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
|
||||
const form = useForm<Schema>({
|
||||
defaultValues: {
|
||||
description: "Dokploy Cloud Server",
|
||||
name: "My First Server",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||
},
|
||||
resolver: zodResolver(Schema),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({
|
||||
description: "Dokploy Cloud Server",
|
||||
name: "My First Server",
|
||||
ipAddress: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
sshKeyId: cloudSSHKey?.sshKeyId || "",
|
||||
});
|
||||
}, [form, form.reset, form.formState.isSubmitSuccessful, sshKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [isOpen]);
|
||||
|
||||
const onSubmit = async (data: Schema) => {
|
||||
await mutateAsync({
|
||||
name: data.name,
|
||||
description: data.description || "",
|
||||
ipAddress: data.ipAddress?.trim() || "",
|
||||
port: data.port || 22,
|
||||
username: data.username || "root",
|
||||
sshKeyId: data.sshKeyId || "",
|
||||
serverType: "deploy",
|
||||
})
|
||||
.then(async (_data) => {
|
||||
toast.success("Server Created");
|
||||
stepper.next();
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error creating a server");
|
||||
});
|
||||
};
|
||||
return (
|
||||
<Card className="bg-background flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2 pt-5 px-4">
|
||||
{!canCreateMoreServers && (
|
||||
<AlertBlock type="warning" className="mt-2">
|
||||
You cannot create more servers,{" "}
|
||||
<Link href="/dashboard/settings/billing" className="text-primary">
|
||||
Please upgrade your plan
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CardContent className="flex flex-col">
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="hook-form-add-server"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="grid w-full gap-4"
|
||||
>
|
||||
<div className="flex flex-col gap-4 ">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Hostinger Server" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="This server is for databases..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sshKeyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Select a SSH Key</FormLabel>
|
||||
{!cloudSSHKey && (
|
||||
<AlertBlock>
|
||||
Looks like you didn't have the SSH Key yet, you can create
|
||||
one{" "}
|
||||
<Link
|
||||
href="/dashboard/settings/ssh-keys"
|
||||
className="text-primary"
|
||||
>
|
||||
here
|
||||
</Link>
|
||||
</AlertBlock>
|
||||
)}
|
||||
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a SSH Key" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{sshKeys?.map((sshKey) => (
|
||||
<SelectItem
|
||||
key={sshKey.sshKeyId}
|
||||
value={sshKey.sshKeyId}
|
||||
>
|
||||
{sshKey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>SSH Keys ({sshKeys?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ipAddress"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>IP Address</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="192.168.1.100" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="22"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
if (value === "") {
|
||||
field.onChange(0);
|
||||
} else {
|
||||
const number = Number.parseInt(value, 10);
|
||||
if (!Number.isNaN(number)) {
|
||||
field.onChange(number);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="root" {...field} />
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
isLoading={form.formState.isSubmitting}
|
||||
disabled={!canCreateMoreServers}
|
||||
form="hook-form-add-server"
|
||||
type="submit"
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -1,190 +0,0 @@
|
||||
import copy from "copy-to-clipboard";
|
||||
import { CopyIcon, ExternalLinkIcon, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { CodeEditor } from "@/components/shared/code-editor";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const CreateSSHKey = () => {
|
||||
const { data, refetch } = api.sshKey.all.useQuery();
|
||||
const generateMutation = api.sshKey.generate.useMutation();
|
||||
const { mutateAsync, isPending } = api.sshKey.create.useMutation();
|
||||
const hasCreatedKey = useRef(false);
|
||||
const [selectedOption, setSelectedOption] = useState<"manual" | "provider">(
|
||||
"manual",
|
||||
);
|
||||
|
||||
const cloudSSHKey = data?.find(
|
||||
(sshKey) => sshKey.name === "dokploy-cloud-ssh-key",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const createKey = async () => {
|
||||
if (!data || cloudSSHKey || hasCreatedKey.current || isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasCreatedKey.current = true;
|
||||
|
||||
try {
|
||||
const keys = await generateMutation.mutateAsync({
|
||||
type: "rsa",
|
||||
});
|
||||
await mutateAsync({
|
||||
name: "dokploy-cloud-ssh-key",
|
||||
description: "Used on Dokploy Cloud",
|
||||
privateKey: keys.privateKey,
|
||||
publicKey: keys.publicKey,
|
||||
organizationId: "",
|
||||
});
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error("Error creating SSH key:", error);
|
||||
hasCreatedKey.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
createKey();
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<Card className="h-full bg-transparent">
|
||||
<CardContent>
|
||||
<div className="grid w-full gap-4 pt-4">
|
||||
{isPending || !cloudSSHKey ? (
|
||||
<div className="min-h-[25vh] justify-center flex items-center gap-4">
|
||||
<Loader2
|
||||
className="animate-spin text-muted-foreground"
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-4 text-sm text-muted-foreground">
|
||||
<p className="text-primary text-base font-semibold">
|
||||
Choose how to add SSH Keys to your server:
|
||||
</p>
|
||||
|
||||
{/* Radio button options */}
|
||||
<div className="grid gap-2">
|
||||
<RadioGroup
|
||||
value={selectedOption}
|
||||
onValueChange={(value) => {
|
||||
setSelectedOption(value as "manual" | "provider");
|
||||
}}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="manual" id="manual" />
|
||||
<Label
|
||||
htmlFor="manual"
|
||||
className="text-primary font-medium cursor-pointer"
|
||||
>
|
||||
Add SSH Key to Server Manually
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="provider" id="provider" />
|
||||
<Label
|
||||
htmlFor="provider"
|
||||
className="text-primary font-medium cursor-pointer"
|
||||
>
|
||||
Add SSH Key when creating server in your provider
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* Content based on selected option */}
|
||||
{selectedOption === "manual" && (
|
||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Manual Setup Instructions
|
||||
</span>
|
||||
<ul className="space-y-2">
|
||||
<li className="items-center flex gap-1">
|
||||
1. Login to your server
|
||||
</li>
|
||||
<li>
|
||||
2. When you are logged in run the following command
|
||||
<div className="flex relative flex-col gap-4 w-full mt-2">
|
||||
<CodeEditor
|
||||
lineWrapping
|
||||
language="properties"
|
||||
value={`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`}
|
||||
readOnly
|
||||
className="font-mono opacity-60"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={() => {
|
||||
copy(
|
||||
`echo "${cloudSSHKey?.publicKey}" >> ~/.ssh/authorized_keys`,
|
||||
);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
<li className="mt-1">
|
||||
3. You're done, follow the next step to insert the
|
||||
details of your server.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedOption === "provider" && (
|
||||
<div className="flex flex-col gap-2 w-full border rounded-lg p-4">
|
||||
<span className="text-base font-semibold text-primary">
|
||||
Provider Setup Instructions
|
||||
</span>
|
||||
<div className="flex flex-col gap-4 w-full overflow-auto">
|
||||
<div className="flex relative flex-col gap-2 overflow-y-auto">
|
||||
<div className="text-sm text-primary flex flex-row gap-2 items-center">
|
||||
Copy Public Key
|
||||
<button
|
||||
type="button"
|
||||
className="right-2 top-8"
|
||||
onClick={() => {
|
||||
copy(
|
||||
cloudSSHKey?.publicKey || "Generate a SSH Key",
|
||||
);
|
||||
toast.success("SSH Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm mt-2">
|
||||
Use this public key when creating a server in your
|
||||
preferred provider (Hostinger, Digital Ocean, Hetzner,
|
||||
etc.)
|
||||
</p>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
|
||||
target="_blank"
|
||||
className="text-primary flex flex-row gap-2 mt-2"
|
||||
>
|
||||
View Tutorial <ExternalLinkIcon className="size-4" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@ -1,136 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
type LogLine,
|
||||
parseLogs,
|
||||
} from "@/components/dashboard/docker/logs/utils";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { DrawerLogs } from "@/components/shared/drawer-logs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { EditScript } from "../edit-script";
|
||||
|
||||
export const Setup = () => {
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const [serverId, setServerId] = useState<string>(
|
||||
servers?.[0]?.serverId || "",
|
||||
);
|
||||
const { data: server } = api.server.one.useQuery(
|
||||
{
|
||||
serverId,
|
||||
},
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
|
||||
const [isDeploying, setIsDeploying] = useState(false);
|
||||
api.server.setupWithLogs.useSubscription(
|
||||
{
|
||||
serverId: serverId,
|
||||
},
|
||||
{
|
||||
enabled: isDeploying,
|
||||
onData(log) {
|
||||
if (!isDrawerOpen) {
|
||||
setIsDrawerOpen(true);
|
||||
}
|
||||
|
||||
if (log === "Deployment completed successfully!") {
|
||||
setIsDeploying(false);
|
||||
}
|
||||
const parsedLogs = parseLogs(log);
|
||||
setFilteredLogs((prev) => [...prev, ...parsedLogs]);
|
||||
},
|
||||
onError(error) {
|
||||
console.error("Deployment logs error:", error);
|
||||
setIsDeploying(false);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Select the server and click on setup server</Label>
|
||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<CardTitle className="text-xl">Setup Server</CardTitle>
|
||||
<CardDescription>
|
||||
To setup a server, please click on the button below.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4 min-h-[25vh] items-center">
|
||||
<div className="flex flex-col gap-4 items-center h-full max-w-xl mx-auto min-h-[25vh] justify-center">
|
||||
<span className="text-sm text-muted-foreground text-center">
|
||||
When your server is ready, you can click on the button below, to
|
||||
directly run the script we use for setup the server or directly
|
||||
modify the script
|
||||
</span>
|
||||
<div className="flex flex-row gap-2">
|
||||
<EditScript serverId={server?.serverId || ""} />
|
||||
<DialogAction
|
||||
title={"Setup Server?"}
|
||||
type="default"
|
||||
description="This will setup the server and all associated data"
|
||||
onClick={async () => {
|
||||
setIsDeploying(true);
|
||||
}}
|
||||
>
|
||||
<Button>Setup Server</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DrawerLogs
|
||||
isOpen={isDrawerOpen}
|
||||
onClose={() => {
|
||||
setIsDrawerOpen(false);
|
||||
setFilteredLogs([]);
|
||||
setIsDeploying(false);
|
||||
}}
|
||||
filteredLogs={filteredLogs}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -1,179 +0,0 @@
|
||||
import { Loader2, PcCase, RefreshCw } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { api } from "@/utils/api";
|
||||
import { StatusRow } from "../gpu-support";
|
||||
|
||||
export const Verify = () => {
|
||||
const { data: servers } = api.server.all.useQuery();
|
||||
const [serverId, setServerId] = useState<string>(
|
||||
servers?.[0]?.serverId || "",
|
||||
);
|
||||
const { data, refetch, error, isPending, isError } =
|
||||
api.server.validate.useQuery(
|
||||
{ serverId },
|
||||
{
|
||||
enabled: !!serverId,
|
||||
},
|
||||
);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
return (
|
||||
<CardContent className="p-0">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card className="bg-background">
|
||||
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Select a server</Label>
|
||||
<Select onValueChange={setServerId} defaultValue={serverId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a server" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{servers?.map((server) => (
|
||||
<SelectItem key={server.serverId} value={server.serverId}>
|
||||
{server.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectLabel>Servers ({servers?.length})</SelectLabel>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2 justify-between w-full max-sm:flex-col">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<PcCase className="size-5" />
|
||||
<CardTitle className="text-xl">Setup Validation</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Check if your server is ready for deployment
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={isRefreshing}
|
||||
onClick={async () => {
|
||||
setIsRefreshing(true);
|
||||
await refetch();
|
||||
setIsRefreshing(false);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="size-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
{isError && (
|
||||
<AlertBlock type="error" className="w-full">
|
||||
{error.message}
|
||||
</AlertBlock>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex flex-col gap-4 min-h-[25vh]">
|
||||
{isPending ? (
|
||||
<div className="flex items-center justify-center text-muted-foreground py-4">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
<span>Checking Server configuration</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full gap-4">
|
||||
<div className="border rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-1">Status</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Shows the server configuration status
|
||||
</p>
|
||||
<div className="grid gap-2.5">
|
||||
<StatusRow
|
||||
label="Docker Installed"
|
||||
isEnabled={data?.docker?.enabled}
|
||||
description={
|
||||
data?.docker?.enabled
|
||||
? `Installed: ${data?.docker?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="RClone Installed"
|
||||
isEnabled={data?.rclone?.enabled}
|
||||
description={
|
||||
data?.rclone?.enabled
|
||||
? `Installed: ${data?.rclone?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Nixpacks Installed"
|
||||
isEnabled={data?.nixpacks?.enabled}
|
||||
description={
|
||||
data?.nixpacks?.enabled
|
||||
? `Installed: ${data?.nixpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Buildpacks Installed"
|
||||
isEnabled={data?.buildpacks?.enabled}
|
||||
description={
|
||||
data?.buildpacks?.enabled
|
||||
? `Installed: ${data?.buildpacks?.version}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Docker Swarm Initialized"
|
||||
isEnabled={data?.isSwarmInstalled}
|
||||
description={
|
||||
data?.isSwarmInstalled
|
||||
? "Initialized"
|
||||
: "Not Initialized"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Dokploy Network Created"
|
||||
isEnabled={data?.isDokployNetworkInstalled}
|
||||
description={
|
||||
data?.isDokployNetworkInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
<StatusRow
|
||||
label="Main Directory Created"
|
||||
isEnabled={data?.isMainDirectoryInstalled}
|
||||
description={
|
||||
data?.isMainDirectoryInstalled
|
||||
? "Created"
|
||||
: "Not Created"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
@ -1,441 +1,261 @@
|
||||
import { defineStepper } from "@stepperize/react";
|
||||
import {
|
||||
BookIcon,
|
||||
Code2,
|
||||
Database,
|
||||
GitMerge,
|
||||
Globe,
|
||||
Plug,
|
||||
Puzzle,
|
||||
Users,
|
||||
CheckIcon,
|
||||
DatabaseIcon,
|
||||
GitMergeIcon,
|
||||
GlobeIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import ConfettiExplosion from "react-confetti-explosion";
|
||||
import { GithubIcon } from "@/components/icons/data-tools-icons";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
import { DeployStep } from "@/components/dashboard/onboarding/steps/deploy-step";
|
||||
import { ProjectStep } from "@/components/dashboard/onboarding/steps/project-step";
|
||||
import { ServerStep } from "@/components/dashboard/onboarding/steps/server-step";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CreateServer } from "./create-server";
|
||||
import { CreateSSHKey } from "./create-ssh-key";
|
||||
import { Setup } from "./setup";
|
||||
import { Verify } from "./verify";
|
||||
import { Dialog, DialogContent, DialogFooter } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export const { useStepper, steps, Scoped } = defineStepper(
|
||||
{
|
||||
id: "requisites",
|
||||
title: "Requisites",
|
||||
description: "Check your requisites",
|
||||
},
|
||||
{
|
||||
id: "create-ssh-key",
|
||||
title: "SSH Key",
|
||||
description: "Create your ssh key",
|
||||
},
|
||||
{
|
||||
id: "connect-server",
|
||||
title: "Connect",
|
||||
description: "Connect",
|
||||
},
|
||||
{ id: "setup", title: "Setup", description: "Setup your server" },
|
||||
{ id: "verify", title: "Verify", description: "Verify your server" },
|
||||
{ id: "complete", title: "Complete", description: "Checkout complete" },
|
||||
const { useStepper, steps, Scoped } = defineStepper(
|
||||
{ id: "welcome", title: "Welcome" },
|
||||
{ id: "project", title: "New project" },
|
||||
{ id: "server", title: "Connect server" },
|
||||
{ id: "deploy", title: "Ship something" },
|
||||
{ id: "complete", title: "You're live" },
|
||||
);
|
||||
|
||||
export const WelcomeSubscription = () => {
|
||||
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.",
|
||||
},
|
||||
];
|
||||
|
||||
const WelcomeIntro = ({ onNext }: { onNext: () => void }) => (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Welcome
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Welcome to Dokploy Cloud
|
||||
</h1>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Thanks for subscribing — you're all set up. Next, connect a server so
|
||||
you can start deploying.
|
||||
</p>
|
||||
</div>
|
||||
<Button size="lg" onClick={onNext} className="w-fit px-8">
|
||||
Get started
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CompleteIntro = ({ onFinish }: { onFinish: () => void }) => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
useEffect(() => {
|
||||
setShowConfetti(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="fixed inset-x-0 top-0 flex justify-center pointer-events-none">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.35}
|
||||
particleCount={200}
|
||||
width={1200}
|
||||
zIndex={60}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<span className="font-mono text-xs uppercase tracking-[0.2em] text-primary">
|
||||
Done
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
You're all set.
|
||||
</h1>
|
||||
<p className="text-muted-foreground leading-relaxed">
|
||||
Your server is connected — here's some of what you can do next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="flex flex-col divide-y">
|
||||
{features.map((feature, index) => (
|
||||
<div key={feature.title} className="flex gap-6 py-4 first:pt-0">
|
||||
<dt className="font-mono text-xs text-muted-foreground pt-1 shrink-0 w-6">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</dt>
|
||||
<dd className="flex flex-col gap-1">
|
||||
<span className="font-medium flex items-center gap-2">
|
||||
<feature.icon className="size-4 text-muted-foreground" />
|
||||
{feature.title}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button size="lg" className="w-fit px-8" onClick={onFinish}>
|
||||
Go to dashboard
|
||||
</Button>
|
||||
<Link
|
||||
href="https://discord.com/invite/2tBnJ3jDJc"
|
||||
target="_blank"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Need help? Join our Discord →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const WelcomeSubscription = () => {
|
||||
const router = useRouter();
|
||||
const stepper = useStepper();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const router = useRouter();
|
||||
const { push } = router;
|
||||
|
||||
useEffect(() => {
|
||||
const confettiShown = localStorage.getItem("hasShownConfetti");
|
||||
if (!confettiShown) {
|
||||
setShowConfetti(true);
|
||||
localStorage.setItem("hasShownConfetti", "true");
|
||||
}
|
||||
}, [showConfetti]);
|
||||
// This flow can also fire for an existing customer upgrading their plan
|
||||
// (any checkout success redirects here), not just a first-time
|
||||
// subscriber — so "New project" only belongs in the flow when they don't
|
||||
// already have one. Mirrors the isCloud-based step filtering in the main
|
||||
// onboarding wizard.
|
||||
const { data: projects } = api.project.all.useQuery();
|
||||
const hasExistingProject = (projects?.length ?? 0) > 0;
|
||||
const visibleStepIds = hasExistingProject
|
||||
? steps.filter((step) => step.id !== "project").map((step) => step.id)
|
||||
: steps.map((step) => step.id);
|
||||
const visibleIndex = visibleStepIds.indexOf(stepper.current.id);
|
||||
const isLast = visibleIndex === visibleStepIds.length - 1;
|
||||
const goToNextVisible = () => {
|
||||
const nextId = visibleStepIds[visibleIndex + 1];
|
||||
if (nextId) stepper.goTo(nextId);
|
||||
};
|
||||
|
||||
const [projectId, setProjectId] = useState<string | undefined>();
|
||||
const [environmentId, setEnvironmentId] = useState<string | undefined>();
|
||||
|
||||
const firstExistingProjectId = projects?.[0]?.projectId;
|
||||
const { data: existingEnvironments } = api.environment.byProjectId.useQuery(
|
||||
{ projectId: firstExistingProjectId ?? "" },
|
||||
{ enabled: !!firstExistingProjectId && !environmentId },
|
||||
);
|
||||
const resolvedEnvironmentId =
|
||||
environmentId ?? existingEnvironments?.[0]?.environmentId;
|
||||
|
||||
const close = (destination: string) => {
|
||||
setIsOpen(false);
|
||||
router.push(destination);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsOpen(open);
|
||||
if (!open) {
|
||||
const { success, ...rest } = router.query;
|
||||
router.replace(
|
||||
{ pathname: router.pathname, query: rest },
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (!open) close("/dashboard/settings/servers");
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
|
||||
{showConfetti ?? "Flaso"}
|
||||
<div className="flex justify-center items-center w-full">
|
||||
{showConfetti && (
|
||||
<ConfettiExplosion
|
||||
duration={3000}
|
||||
force={0.3}
|
||||
particleSize={12}
|
||||
particleCount={300}
|
||||
className="z-9999"
|
||||
zIndex={9999}
|
||||
width={1500}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-center">
|
||||
Welcome To Dokploy Cloud 🎉
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-center max-w-xl mx-auto">
|
||||
Thank you for choosing Dokploy Cloud! 🚀 We're excited to have you
|
||||
onboard. Before you dive in, you'll need to configure your remote
|
||||
server to unlock all the features we offer.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4">
|
||||
<div className="flex justify-between">
|
||||
<h2 className="text-lg font-semibold">Steps</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Step {stepper.current.index + 1} of {steps.length}
|
||||
</span>
|
||||
<div />
|
||||
</div>
|
||||
</div>
|
||||
<Scoped>
|
||||
<nav aria-label="Checkout Steps" className="group my-4">
|
||||
<ol
|
||||
className="flex items-center justify-between gap-2"
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{stepper.all.map((step, index, array) => (
|
||||
<React.Fragment key={step.id}>
|
||||
<li className="flex items-center gap-4 shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
role="tab"
|
||||
variant={
|
||||
index <= stepper.current.index ? "secondary" : "ghost"
|
||||
}
|
||||
aria-current={
|
||||
stepper.current.id === step.id ? "step" : undefined
|
||||
}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={steps.length}
|
||||
aria-selected={stepper.current.id === step.id}
|
||||
className="flex size-10 items-center justify-center rounded-full border-2 border-border"
|
||||
onClick={() => stepper.goTo(step.id)}
|
||||
>
|
||||
{index + 1}
|
||||
</Button>
|
||||
<span className="text-sm font-medium">{step.title}</span>
|
||||
</li>
|
||||
{index < array.length - 1 && (
|
||||
<Separator
|
||||
className={`flex-1 ${
|
||||
index < stepper.current.index
|
||||
? "bg-primary"
|
||||
: "bg-muted"
|
||||
}`}
|
||||
/>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<Scoped>
|
||||
<nav aria-label="Steps" className="flex items-center gap-2 pt-2">
|
||||
{visibleStepIds.map((id, index) => {
|
||||
const isDone = index < visibleIndex;
|
||||
const isCurrent = stepper.current.id === id;
|
||||
return (
|
||||
<Fragment key={id}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!isDone}
|
||||
onClick={() => stepper.goTo(id)}
|
||||
aria-current={isCurrent ? "step" : undefined}
|
||||
className={cn(
|
||||
"flex size-7 shrink-0 items-center justify-center rounded-full font-mono text-[11px] transition-colors",
|
||||
isCurrent
|
||||
? "border-2 border-primary text-primary font-semibold"
|
||||
: isDone
|
||||
? "bg-primary text-primary-foreground cursor-pointer"
|
||||
: "border border-border text-muted-foreground",
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
{stepper.switch({
|
||||
requisites: () => (
|
||||
<div className="flex flex-col gap-2 border p-4 rounded-lg">
|
||||
<span className="text-primary text-base font-bold">
|
||||
Before getting started, please follow the steps below to
|
||||
ensure the best experience:
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
Supported Distributions:
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||
<li>Ubuntu 24.04 LTS</li>
|
||||
<li>Ubuntu 23.10</li>
|
||||
<li>Ubuntu 22.04 LTS</li>
|
||||
<li>Ubuntu 20.04 LTS</li>
|
||||
<li>Ubuntu 18.04 LTS</li>
|
||||
<li>Debian 12</li>
|
||||
<li>Debian 11</li>
|
||||
<li>Debian 10</li>
|
||||
<li>Fedora 40</li>
|
||||
<li>CentOS 9</li>
|
||||
<li>CentOS 8</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-primary text-sm font-medium">
|
||||
You will need to purchase or rent a Virtual Private Server
|
||||
(VPS) to proceed, we recommend to use one of these
|
||||
providers since has been heavily tested.
|
||||
</p>
|
||||
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
|
||||
className="text-link underline"
|
||||
>
|
||||
Hostinger - Get 20% Discount
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href=" https://app.americancloud.com/register?ref=dokploy"
|
||||
className="text-link underline"
|
||||
>
|
||||
American Cloud - Get $20 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://m.do.co/c/db24efd43f35"
|
||||
className="text-link underline"
|
||||
>
|
||||
DigitalOcean - Get $200 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
|
||||
className="text-link underline"
|
||||
>
|
||||
Hetzner - Get €20 Credits
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.vultr.com/?ref=9679828"
|
||||
className="text-link underline"
|
||||
>
|
||||
Vultr
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.linode.com/es/pricing/#compute-shared"
|
||||
className="text-link underline"
|
||||
>
|
||||
Linode
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<AlertBlock className="mt-4 px-4">
|
||||
You are free to use whatever provider, but we recommend to
|
||||
use one of the above, to avoid issues.
|
||||
</AlertBlock>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
"create-ssh-key": () => <CreateSSHKey />,
|
||||
"connect-server": () => <CreateServer stepper={stepper} />,
|
||||
setup: () => <Setup />,
|
||||
verify: () => <Verify />,
|
||||
complete: () => {
|
||||
const features = [
|
||||
{
|
||||
title: "Scalable Deployments",
|
||||
description:
|
||||
"Deploy and scale your applications effortlessly to handle any workload.",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Automated Backups",
|
||||
description: "Protect your data with automatic backups",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Open Source Templates",
|
||||
description:
|
||||
"Big list of common open source templates in one-click",
|
||||
icon: <Puzzle className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Custom Domains",
|
||||
description:
|
||||
"Link your own domains to your applications for a professional presence.",
|
||||
icon: <Globe className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "CI/CD Integration",
|
||||
description:
|
||||
"Implement continuous integration and deployment workflows to streamline development.",
|
||||
icon: <GitMerge className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Database Management",
|
||||
description:
|
||||
"Efficiently manage your databases with intuitive tools.",
|
||||
icon: <Database className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Team Collaboration",
|
||||
description:
|
||||
"Collaborate with your team on shared projects with customizable permissions.",
|
||||
icon: <Users className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "Multi-language Support",
|
||||
description:
|
||||
"Deploy applications in multiple programming languages to suit your needs.",
|
||||
icon: <Code2 className="text-primary" />,
|
||||
},
|
||||
{
|
||||
title: "API Access",
|
||||
description:
|
||||
"Integrate and manage your applications via robust and well-documented APIs.",
|
||||
icon: <Plug className="text-primary" />,
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-lg font-semibold">You're All Set!</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Did you know you can deploy any number of applications
|
||||
that your server can handle?
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Here are some of the things you can do with Dokploy
|
||||
Cloud:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
|
||||
{features.map((feature) => (
|
||||
<div
|
||||
key={feature.title}
|
||||
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="text-3xl mb-2">{feature.icon}</div>
|
||||
<h3 className="text-lg font-medium mb-1">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<span className="text-base text-primary">
|
||||
Need Help? We are here to help you.
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Join to our Discord server and we will help you.
|
||||
</span>
|
||||
<div className="flex flex-row gap-4">
|
||||
<Button className="rounded-full bg-[#5965F2] hover:bg-[#4A55E0] w-fit">
|
||||
<Link
|
||||
href="https://discord.gg/2tBnJ3jDJc"
|
||||
aria-label="Dokploy on GitHub"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 text-white"
|
||||
>
|
||||
<svg
|
||||
role="img"
|
||||
className="h-6 w-6 fill-white"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
Join Discord
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="rounded-full w-fit">
|
||||
<Link
|
||||
href="https://github.com/Dokploy/dokploy"
|
||||
aria-label="Dokploy on GitHub"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 "
|
||||
>
|
||||
<GithubIcon />
|
||||
Github
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className="rounded-full w-fit"
|
||||
variant="outline"
|
||||
>
|
||||
<Link
|
||||
href="https://docs.dokploy.com/docs/core"
|
||||
aria-label="Dokploy Docs"
|
||||
target="_blank"
|
||||
className="flex flex-row items-center gap-2 "
|
||||
>
|
||||
<BookIcon size={16} />
|
||||
Docs
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
>
|
||||
{isDone ? <CheckIcon className="size-3.5" /> : index + 1}
|
||||
</button>
|
||||
{index < visibleStepIds.length - 1 && (
|
||||
<span
|
||||
className={cn(
|
||||
"h-px flex-1",
|
||||
isDone ? "bg-primary" : "bg-border",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</Scoped>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{!stepper.isLast && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/settings/servers");
|
||||
}}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
)}
|
||||
</nav>
|
||||
</Scoped>
|
||||
|
||||
<div className="flex items-center gap-2 w-full justify-end">
|
||||
<Button
|
||||
onClick={stepper.prev}
|
||||
disabled={stepper.isFirst}
|
||||
variant="secondary"
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (stepper.isLast) {
|
||||
setIsOpen(false);
|
||||
push("/dashboard/home");
|
||||
} else {
|
||||
stepper.next();
|
||||
}
|
||||
<div className="py-2">
|
||||
{stepper.switch({
|
||||
welcome: () => <WelcomeIntro onNext={goToNextVisible} />,
|
||||
project: () => (
|
||||
<ProjectStep
|
||||
plainTitle
|
||||
onNext={(project) => {
|
||||
setProjectId(project.projectId);
|
||||
setEnvironmentId(project.environmentId);
|
||||
goToNextVisible();
|
||||
}}
|
||||
>
|
||||
{stepper.isLast ? "Complete" : "Next"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
/>
|
||||
),
|
||||
server: () => <ServerStep plainTitle onNext={goToNextVisible} />,
|
||||
deploy: () => (
|
||||
<DeployStep
|
||||
plainTitle
|
||||
environmentId={resolvedEnvironmentId}
|
||||
onNext={goToNextVisible}
|
||||
/>
|
||||
),
|
||||
complete: () => (
|
||||
<CompleteIntro onFinish={() => close("/dashboard/home")} />
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!isLast && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => close("/dashboard/settings/servers")}
|
||||
>
|
||||
Skip for now
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import Head from "next/head";
|
||||
import { useRouter } from "next/router";
|
||||
import { useEffect } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
import { useWhitelabeling } from "@/utils/hooks/use-whitelabeling";
|
||||
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
|
||||
@ -11,6 +13,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
const router = useRouter();
|
||||
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { config: whitelabeling } = useWhitelabeling();
|
||||
@ -24,6 +27,21 @@ export const DashboardLayout = ({ children, metaName }: Props) => {
|
||||
|
||||
const isChatEnabled = isCloud === true && currentPlan === "startup";
|
||||
|
||||
const { data: onboardingStatus } = api.project.onboardingStatus.useQuery();
|
||||
const shouldRedirectToOnboarding =
|
||||
router.pathname !== "/dashboard/home" &&
|
||||
onboardingStatus?.shouldShowOnboarding === true;
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldRedirectToOnboarding) {
|
||||
router.replace("/dashboard/home");
|
||||
}
|
||||
}, [shouldRedirectToOnboarding, router]);
|
||||
|
||||
if (shouldRedirectToOnboarding) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{metaName && (
|
||||
|
||||
7
apps/dokploy/drizzle/0190_nappy_anita_blake.sql
Normal file
7
apps/dokploy/drizzle/0190_nappy_anita_blake.sql
Normal file
@ -0,0 +1,7 @@
|
||||
ALTER TABLE "user" ADD COLUMN "onboardingCompletedAt" timestamp;--> statement-breakpoint
|
||||
-- Backfill only: existing users shouldn't see the onboarding wizard just
|
||||
-- because this column is new. Not a column-level DEFAULT — that would also
|
||||
-- apply to rows inserted after this migration, and newly created users
|
||||
-- (self-hosted setup, cloud signups) need onboardingCompletedAt to stay NULL
|
||||
-- so the wizard still shows for them.
|
||||
UPDATE "user" SET "onboardingCompletedAt" = now() WHERE "onboardingCompletedAt" IS NULL;
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "1874ab10-ad57-40f0-9619-5464fbf218da",
|
||||
"id": "d3186290-04e7-4f38-9216-d55a766b1dae",
|
||||
"prevId": "64935c8d-d487-4e34-9c2b-9d98ff0b0896",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@ -8351,6 +8351,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"default": "ARRAY[]::text[]"
|
||||
},
|
||||
"onboardingCompletedAt": {
|
||||
"name": "onboardingCompletedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
@ -8947,8 +8953,7 @@
|
||||
"values": [
|
||||
"cloudflare",
|
||||
"route53",
|
||||
"porkbun",
|
||||
"infomaniak"
|
||||
"porkbun"
|
||||
]
|
||||
},
|
||||
"public.domainType": {
|
||||
|
||||
9154
apps/dokploy/drizzle/meta/0191_snapshot.json
Normal file
9154
apps/dokploy/drizzle/meta/0191_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1335,8 +1335,15 @@
|
||||
{
|
||||
"idx": 190,
|
||||
"version": "7",
|
||||
"when": 1788269053738,
|
||||
"tag": "0190_perpetual_red_skull",
|
||||
"when": 1788300390648,
|
||||
"tag": "0190_nappy_anita_blake",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 191,
|
||||
"version": "7",
|
||||
"when": 1788332224024,
|
||||
"tag": "0191_cool_christian_walker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.30.4",
|
||||
"version": "v0.30.5",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@ -37,7 +37,8 @@
|
||||
"docker:push:canary": "./docker/push.sh canary",
|
||||
"version": "echo $(node -p \"require('./package.json').version\")",
|
||||
"test": "vitest --config __test__/vitest.config.ts",
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
|
||||
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts",
|
||||
"reset-onboarding": "tsx -r dotenv/config scripts/reset-onboarding.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.44",
|
||||
|
||||
@ -2,12 +2,47 @@ import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowHome } from "@/components/dashboard/home/show-home";
|
||||
import {
|
||||
clearOnboardingActive,
|
||||
isOnboardingActive,
|
||||
markOnboardingActive,
|
||||
} from "@/components/dashboard/onboarding/onboarding-lock";
|
||||
import { OnboardingWizard } from "@/components/dashboard/onboarding/onboarding-wizard";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const Home = () => {
|
||||
const { data } = api.project.onboardingStatus.useQuery();
|
||||
const utils = api.useUtils();
|
||||
const { mutateAsync: completeOnboarding } =
|
||||
api.project.completeOnboarding.useMutation();
|
||||
const [showWizard, setShowWizard] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (showWizard === null && data) {
|
||||
const show = isOnboardingActive() || data.shouldShowOnboarding;
|
||||
setShowWizard(show);
|
||||
if (show) markOnboardingActive();
|
||||
}
|
||||
}, [data, showWizard]);
|
||||
|
||||
if (showWizard) {
|
||||
return (
|
||||
<OnboardingWizard
|
||||
onClose={async () => {
|
||||
await completeOnboarding();
|
||||
await utils.project.onboardingStatus.invalidate();
|
||||
clearOnboardingActive();
|
||||
setShowWizard(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ShowHome />;
|
||||
};
|
||||
|
||||
@ -44,6 +79,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
|
||||
|
||||
await helpers.settings.isCloud.prefetch();
|
||||
await helpers.user.get.prefetch();
|
||||
await helpers.project.onboardingStatus.prefetch();
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@ -1,31 +1,14 @@
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import type { ReactElement } from "react";
|
||||
import superjson from "superjson";
|
||||
import { ShowProjects } from "@/components/dashboard/projects/show";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
import { appRouter } from "@/server/api/root";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const ShowWelcomeDokploy = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/settings/billing/show-welcome-dokploy").then(
|
||||
(mod) => mod.ShowWelcomeDokploy,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
return (
|
||||
<>
|
||||
{isCloud && <ShowWelcomeDokploy />}
|
||||
|
||||
<ShowProjects />
|
||||
</>
|
||||
);
|
||||
return <ShowProjects />;
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
119
apps/dokploy/scripts/reset-onboarding.ts
Normal file
119
apps/dokploy/scripts/reset-onboarding.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { findUserById, updateUser } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { member, projects, server, user } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Dev-only utility: resets a test account back to "never onboarded" so the
|
||||
* onboarding wizard shows up again (see server/api/routers/project.ts
|
||||
* `onboardingStatus` for the exact gate). Not part of the build — never
|
||||
* ships in dist, this is strictly a local testing tool.
|
||||
*
|
||||
* Usage: pnpm reset-onboarding <email>
|
||||
*/
|
||||
|
||||
const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
type StripeSubscription = { id: string; status: string };
|
||||
|
||||
const cancelActiveSubscriptions = async (stripeCustomerId: string) => {
|
||||
if (!STRIPE_SECRET_KEY) {
|
||||
console.log(" (STRIPE_SECRET_KEY not set, skipping subscription cleanup)");
|
||||
return;
|
||||
}
|
||||
|
||||
const listRes = await fetch(
|
||||
`https://api.stripe.com/v1/subscriptions?customer=${stripeCustomerId}&status=all`,
|
||||
{ headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` } },
|
||||
);
|
||||
const listBody = (await listRes.json()) as { data?: StripeSubscription[] };
|
||||
const cancelable = (listBody.data ?? []).filter(
|
||||
(sub) => sub.status !== "canceled",
|
||||
);
|
||||
|
||||
if (cancelable.length === 0) {
|
||||
console.log(" (no active/trialing subscriptions found)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const sub of cancelable) {
|
||||
const cancelRes = await fetch(
|
||||
`https://api.stripe.com/v1/subscriptions/${sub.id}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${STRIPE_SECRET_KEY}` },
|
||||
},
|
||||
);
|
||||
const cancelled = (await cancelRes.json()) as StripeSubscription;
|
||||
console.log(` - ${cancelled.id} -> ${cancelled.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const email = process.argv[2]?.trim().toLowerCase();
|
||||
if (!email) {
|
||||
console.log("Usage: pnpm reset-onboarding <email>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const foundUser = await db.query.user.findFirst({
|
||||
where: eq(user.email, email),
|
||||
});
|
||||
if (!foundUser) {
|
||||
console.log(`User not found for email: ${email}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Resetting onboarding for ${email} (${foundUser.id})`);
|
||||
|
||||
if (foundUser.stripeCustomerId) {
|
||||
console.log(
|
||||
`Cancelling Stripe subscriptions for ${foundUser.stripeCustomerId}...`,
|
||||
);
|
||||
await cancelActiveSubscriptions(foundUser.stripeCustomerId);
|
||||
}
|
||||
|
||||
const memberships = await db.query.member.findMany({
|
||||
where: eq(member.userId, foundUser.id),
|
||||
});
|
||||
|
||||
for (const membership of memberships) {
|
||||
const deletedProjects = await db
|
||||
.delete(projects)
|
||||
.where(eq(projects.organizationId, membership.organizationId))
|
||||
.returning({ id: projects.projectId, name: projects.name });
|
||||
for (const project of deletedProjects) {
|
||||
console.log(` - deleted project "${project.name}" (${project.id})`);
|
||||
}
|
||||
|
||||
const deletedServers = await db
|
||||
.delete(server)
|
||||
.where(eq(server.organizationId, membership.organizationId))
|
||||
.returning({ id: server.serverId, name: server.name });
|
||||
for (const deletedServer of deletedServers) {
|
||||
console.log(
|
||||
` - deleted server "${deletedServer.name}" (${deletedServer.id})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await updateUser(foundUser.id, {
|
||||
onboardingCompletedAt: null,
|
||||
stripeCustomerId: null,
|
||||
// startFreeTrial sets these directly on the user row (not via webhook),
|
||||
// so cancelling the subscription above doesn't clear them on its own.
|
||||
stripeSubscriptionId: null,
|
||||
serversQuantity: 0,
|
||||
});
|
||||
|
||||
// Re-fetch to confirm and print the final state.
|
||||
const finalUser = await findUserById(foundUser.id);
|
||||
console.log("\nDone. Final state:");
|
||||
console.log(` onboardingCompletedAt: ${finalUser.onboardingCompletedAt}`);
|
||||
console.log(` stripeCustomerId: ${finalUser.stripeCustomerId}`);
|
||||
|
||||
process.exit(0);
|
||||
})().catch((error) => {
|
||||
console.error("Error resetting onboarding", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -1,11 +1,13 @@
|
||||
import {
|
||||
clearOldDeployments,
|
||||
createApplication,
|
||||
createDomain,
|
||||
deleteAllMiddlewares,
|
||||
findApplicationById,
|
||||
findEnvironmentById,
|
||||
findPreviewDeploymentsByApplicationId,
|
||||
findProjectById,
|
||||
generateTraefikMeDomain,
|
||||
getAccessibleServerIds,
|
||||
getApplicationStats,
|
||||
getContainerLogs,
|
||||
@ -138,6 +140,118 @@ export const applicationRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
deployNginxQuickstart: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
environmentId: z.string().min(1),
|
||||
serverId: z.string().min(1).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const environment = await findEnvironmentById(input.environmentId);
|
||||
const project = await findProjectById(environment.projectId);
|
||||
|
||||
await checkServiceAccess(ctx, project.projectId, "create");
|
||||
|
||||
if (project.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this project",
|
||||
});
|
||||
}
|
||||
|
||||
if (IS_CLOUD && !input.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You need to use a server to create an application",
|
||||
});
|
||||
}
|
||||
|
||||
if (input.serverId) {
|
||||
const accessibleIds = await getAccessibleServerIds(ctx.session);
|
||||
if (!accessibleIds.has(input.serverId)) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to access this server",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = nanoid(6).toLowerCase();
|
||||
const newApplication = await createApplication({
|
||||
name: "Hello World",
|
||||
appName: `hello-world-${suffix}`,
|
||||
description: "Nginx demo app created by the onboarding wizard",
|
||||
environmentId: input.environmentId,
|
||||
serverId: input.serverId,
|
||||
sourceType: "docker",
|
||||
});
|
||||
|
||||
await addNewService(ctx, newApplication.applicationId);
|
||||
|
||||
await updateApplication(newApplication.applicationId, {
|
||||
dockerImage: "nginxdemos/hello",
|
||||
sourceType: "docker",
|
||||
applicationStatus: "idle",
|
||||
});
|
||||
|
||||
const host = await generateTraefikMeDomain(
|
||||
newApplication.appName,
|
||||
ctx.user.ownerId,
|
||||
input.serverId,
|
||||
);
|
||||
|
||||
const domain = await createDomain({
|
||||
host,
|
||||
port: 80,
|
||||
https: false,
|
||||
applicationId: newApplication.applicationId,
|
||||
domainType: "application",
|
||||
});
|
||||
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "service",
|
||||
resourceId: newApplication.applicationId,
|
||||
resourceName: newApplication.appName,
|
||||
});
|
||||
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: newApplication.applicationId,
|
||||
titleLog: "Onboarding quickstart deployment",
|
||||
descriptionLog: "",
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
server: !!input.serverId,
|
||||
serverId: input.serverId,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && input.serverId) {
|
||||
deploy(jobData).catch((error) => {
|
||||
console.error("Background deployment failed:", error);
|
||||
});
|
||||
} else {
|
||||
await myQueue.add(
|
||||
"deployments",
|
||||
{ ...jobData },
|
||||
{ removeOnComplete: true, removeOnFail: true },
|
||||
);
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "deploy",
|
||||
resourceType: "application",
|
||||
resourceId: newApplication.applicationId,
|
||||
resourceName: newApplication.appName,
|
||||
});
|
||||
|
||||
return {
|
||||
applicationId: newApplication.applicationId,
|
||||
domainUrl: `http://${domain.host}`,
|
||||
};
|
||||
}),
|
||||
|
||||
one: protectedProcedure
|
||||
.input(apiFindOneApplication)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@ -428,6 +428,7 @@ export const composeRouter = createTRPCRouter({
|
||||
descriptionLog: input.description || "",
|
||||
server: !!compose.serverId,
|
||||
serverId: compose.serverId ?? undefined,
|
||||
freshVolumes: input.freshVolumes,
|
||||
};
|
||||
|
||||
if (IS_CLOUD && compose.serverId) {
|
||||
@ -477,6 +478,7 @@ export const composeRouter = createTRPCRouter({
|
||||
descriptionLog: input.description || "",
|
||||
server: !!compose.serverId,
|
||||
serverId: compose.serverId ?? undefined,
|
||||
freshVolumes: input.freshVolumes,
|
||||
};
|
||||
if (IS_CLOUD && compose.serverId) {
|
||||
deploy(jobData).catch((error) => {
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
findUserById,
|
||||
IS_CLOUD,
|
||||
updateProjectById,
|
||||
updateUser,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
@ -65,6 +66,7 @@ import {
|
||||
projects,
|
||||
redis,
|
||||
} from "@/server/db/schema";
|
||||
import { getBillingStatus } from "@/server/utils/billing";
|
||||
|
||||
export const projectRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
@ -654,6 +656,35 @@ export const projectRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
|
||||
onboardingStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
const projectCountRows = await db
|
||||
.select({ projectCount: sql<number>`count(*)::int` })
|
||||
.from(projects)
|
||||
.where(eq(projects.organizationId, ctx.session.activeOrganizationId));
|
||||
const projectCount = projectCountRows[0]?.projectCount ?? 0;
|
||||
|
||||
const billingStatus = await getBillingStatus(ctx.user.ownerId);
|
||||
const currentUser = await findUserById(ctx.user.id);
|
||||
|
||||
const isOwner = ctx.user.role === "owner";
|
||||
const billingGatePassed = IS_CLOUD ? !billingStatus.hasActiveAccess : true;
|
||||
|
||||
return {
|
||||
shouldShowOnboarding:
|
||||
isOwner &&
|
||||
!currentUser.onboardingCompletedAt &&
|
||||
projectCount === 0 &&
|
||||
billingGatePassed,
|
||||
projectCount,
|
||||
...billingStatus,
|
||||
};
|
||||
}),
|
||||
|
||||
completeOnboarding: protectedProcedure.mutation(async ({ ctx }) => {
|
||||
await updateUser(ctx.user.id, { onboardingCompletedAt: new Date() });
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
search: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@ -68,11 +68,15 @@ export const serverRouter = createTRPCRouter({
|
||||
input,
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
await applyDockerCleanupSchedule(
|
||||
project.serverId,
|
||||
ctx.session.activeOrganizationId,
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
try {
|
||||
await applyDockerCleanupSchedule(
|
||||
project.serverId,
|
||||
ctx.session.activeOrganizationId,
|
||||
input.enableDockerCleanup,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to schedule docker cleanup:", error);
|
||||
}
|
||||
await audit(ctx, {
|
||||
action: "create",
|
||||
resourceType: "server",
|
||||
@ -81,6 +85,9 @@ export const serverRouter = createTRPCRouter({
|
||||
});
|
||||
return project;
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) {
|
||||
throw error;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Error creating the server",
|
||||
|
||||
@ -7,7 +7,13 @@ import {
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
||||
import {
|
||||
getBillingStatus,
|
||||
getCurrentPlan as getCurrentPlanForOrganization,
|
||||
getStripeClient,
|
||||
TRIAL_DURATION_DAYS,
|
||||
TRIAL_SERVER_LIMIT,
|
||||
} from "@/server/utils/billing";
|
||||
import {
|
||||
type BillingTier,
|
||||
getStripeItems,
|
||||
@ -35,6 +41,77 @@ export const stripeRouter = createTRPCRouter({
|
||||
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
getBillingStatus: protectedProcedure.query(async ({ ctx }) => {
|
||||
return getBillingStatus(ctx.user.ownerId);
|
||||
}),
|
||||
|
||||
startFreeTrial: adminProcedure.mutation(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This feature is only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
if (!HOBBY_PRICE_MONTHLY_ID) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Trials are not configured",
|
||||
});
|
||||
}
|
||||
|
||||
const owner = await findUserById(ctx.user.ownerId);
|
||||
const billingStatus = await getBillingStatus(owner.id);
|
||||
|
||||
if (billingStatus.hasActiveAccess) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You already have an active plan or trial",
|
||||
});
|
||||
}
|
||||
|
||||
if (billingStatus.hasUsedTrial) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "You have already used your free trial",
|
||||
});
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
|
||||
let stripeCustomerId = owner.stripeCustomerId;
|
||||
if (stripeCustomerId) {
|
||||
const customer = await stripe.customers.retrieve(stripeCustomerId);
|
||||
if (customer.deleted) {
|
||||
stripeCustomerId = null;
|
||||
}
|
||||
}
|
||||
if (!stripeCustomerId) {
|
||||
const customer = await stripe.customers.create({ email: owner.email });
|
||||
stripeCustomerId = customer.id;
|
||||
}
|
||||
|
||||
const subscription = await stripe.subscriptions.create({
|
||||
customer: stripeCustomerId,
|
||||
items: [{ price: HOBBY_PRICE_MONTHLY_ID, quantity: 1 }],
|
||||
trial_period_days: TRIAL_DURATION_DAYS,
|
||||
trial_settings: { end_behavior: { missing_payment_method: "cancel" } },
|
||||
metadata: { source: "onboarding_trial", adminId: owner.id },
|
||||
});
|
||||
|
||||
await updateUser(owner.id, {
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
serversQuantity: TRIAL_SERVER_LIMIT,
|
||||
});
|
||||
|
||||
return {
|
||||
trialEndsAt: subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
|
||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||
const user = await findUserById(ctx.user.ownerId);
|
||||
const stripeCustomerId = user.stripeCustomerId;
|
||||
@ -256,7 +333,10 @@ export const stripeRouter = createTRPCRouter({
|
||||
{ expand: ["items.data.price"] },
|
||||
);
|
||||
|
||||
if (subscription.status !== "active") {
|
||||
if (
|
||||
subscription.status !== "active" &&
|
||||
subscription.status !== "trialing"
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Subscription is not active",
|
||||
|
||||
@ -42,12 +42,14 @@ export const processDeploymentJob = async (job: InMemoryJob) => {
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
freshVolumes: job.data.freshVolumes,
|
||||
});
|
||||
} else if (job.data.type === "redeploy") {
|
||||
await rebuildCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
freshVolumes: job.data.freshVolumes,
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "application-preview") {
|
||||
|
||||
@ -16,6 +16,7 @@ type DeployJob =
|
||||
type: "deploy" | "redeploy";
|
||||
applicationType: "compose";
|
||||
serverId?: string;
|
||||
freshVolumes?: boolean;
|
||||
}
|
||||
| {
|
||||
applicationId: string;
|
||||
|
||||
@ -11,28 +11,12 @@ import {
|
||||
|
||||
export type BillingPlan = "legacy" | "hobby" | "startup";
|
||||
|
||||
export const getCurrentPlanForUser = async (
|
||||
userId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const owner = await findUserById(userId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
export const getStripeClient = () =>
|
||||
new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||
apiVersion: "2024-09-30.acacia",
|
||||
});
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "active",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
if (subscriptions.data.length === 0) return null;
|
||||
|
||||
const priceIds = subscriptions.data.flatMap((sub) =>
|
||||
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||
);
|
||||
|
||||
const planFromPriceIds = (priceIds: string[]): BillingPlan | null => {
|
||||
if (
|
||||
priceIds.some(
|
||||
(id) =>
|
||||
@ -52,10 +36,36 @@ export const getCurrentPlanForUser = async (
|
||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||
return "legacy";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCurrentPlanForUser = async (
|
||||
userId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
if (!IS_CLOUD) return null;
|
||||
|
||||
const owner = await findUserById(userId);
|
||||
if (!owner?.stripeCustomerId) return null;
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "all",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
|
||||
const relevantSubs = subscriptions.data.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
if (relevantSubs.length === 0) return null;
|
||||
|
||||
const priceIds = relevantSubs.flatMap((sub) =>
|
||||
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||
);
|
||||
|
||||
return planFromPriceIds(priceIds);
|
||||
};
|
||||
|
||||
export const getCurrentPlan = async (
|
||||
organizationId: string,
|
||||
): Promise<BillingPlan | null> => {
|
||||
@ -66,3 +76,79 @@ export const getCurrentPlan = async (
|
||||
|
||||
return getCurrentPlanForUser(ownerId);
|
||||
};
|
||||
|
||||
export const TRIAL_DURATION_DAYS = 14;
|
||||
export const TRIAL_SERVER_LIMIT = 1;
|
||||
|
||||
export interface BillingStatus {
|
||||
plan: BillingPlan | null;
|
||||
isOnTrial: boolean;
|
||||
trialEndsAt: Date | null;
|
||||
trialDaysRemaining: number | null;
|
||||
hasUsedTrial: boolean;
|
||||
hasActiveAccess: boolean;
|
||||
}
|
||||
|
||||
export const getBillingStatus = async (
|
||||
userId: string,
|
||||
): Promise<BillingStatus> => {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
plan: null,
|
||||
isOnTrial: false,
|
||||
trialEndsAt: null,
|
||||
trialDaysRemaining: null,
|
||||
hasUsedTrial: false,
|
||||
hasActiveAccess: true,
|
||||
};
|
||||
}
|
||||
|
||||
const owner = await findUserById(userId);
|
||||
if (!owner?.stripeCustomerId) {
|
||||
return {
|
||||
plan: null,
|
||||
isOnTrial: false,
|
||||
trialEndsAt: null,
|
||||
trialDaysRemaining: null,
|
||||
hasUsedTrial: false,
|
||||
hasActiveAccess: false,
|
||||
};
|
||||
}
|
||||
|
||||
const stripe = getStripeClient();
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: owner.stripeCustomerId,
|
||||
status: "all",
|
||||
expand: ["data.items.data.price"],
|
||||
});
|
||||
|
||||
const relevantSubs = subscriptions.data.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
const priceIds = relevantSubs.flatMap((sub) =>
|
||||
sub.items.data.map((item) => (item.price as Stripe.Price).id),
|
||||
);
|
||||
const plan = planFromPriceIds(priceIds);
|
||||
|
||||
const trialingSub = subscriptions.data.find(
|
||||
(sub) => sub.status === "trialing",
|
||||
);
|
||||
const trialEndsAt = trialingSub?.trial_end
|
||||
? new Date(trialingSub.trial_end * 1000)
|
||||
: null;
|
||||
const trialDaysRemaining = trialEndsAt
|
||||
? Math.max(
|
||||
0,
|
||||
Math.ceil((trialEndsAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
|
||||
)
|
||||
: null;
|
||||
|
||||
return {
|
||||
plan,
|
||||
isOnTrial: !!trialingSub,
|
||||
trialEndsAt,
|
||||
trialDaysRemaining,
|
||||
hasUsedTrial: subscriptions.data.length > 0,
|
||||
hasActiveAccess: plan !== null || !!trialingSub,
|
||||
};
|
||||
};
|
||||
|
||||
@ -237,12 +237,14 @@ export const apiDeployCompose = z.object({
|
||||
composeId: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
freshVolumes: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiRedeployCompose = z.object({
|
||||
composeId: z.string().min(1),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
freshVolumes: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const apiDeleteCompose = z.object({
|
||||
|
||||
@ -73,6 +73,7 @@ export const user = pgTable("user", {
|
||||
bookmarkedTemplates: text("bookmarkedTemplates")
|
||||
.array()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
onboardingCompletedAt: timestamp("onboardingCompletedAt"),
|
||||
});
|
||||
|
||||
export const usersRelations = relations(user, ({ one, many }) => ({
|
||||
@ -97,6 +98,7 @@ const createSchema = createInsertSchema(user, {
|
||||
bookmarkedTemplates: true,
|
||||
isValidEnterpriseLicense: true,
|
||||
isEnterpriseCloud: true,
|
||||
onboardingCompletedAt: true,
|
||||
});
|
||||
|
||||
export const apiCreateUserInvitation = createSchema.pick({}).extend({
|
||||
|
||||
@ -229,10 +229,12 @@ export const deployCompose = async ({
|
||||
composeId,
|
||||
titleLog = "Manual deployment",
|
||||
descriptionLog = "",
|
||||
freshVolumes = false,
|
||||
}: {
|
||||
composeId: string;
|
||||
titleLog: string;
|
||||
descriptionLog: string;
|
||||
freshVolumes?: boolean;
|
||||
}) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
|
||||
@ -286,6 +288,16 @@ export const deployCompose = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (freshVolumes && compose.composeType === "docker-compose") {
|
||||
const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`;
|
||||
const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, downWithLog);
|
||||
} else {
|
||||
await execAsync(downWithLog);
|
||||
}
|
||||
}
|
||||
|
||||
command = "set -e;";
|
||||
command += await getBuildComposeCommand(entity);
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
@ -359,10 +371,12 @@ export const rebuildCompose = async ({
|
||||
composeId,
|
||||
titleLog = "Rebuild deployment",
|
||||
descriptionLog = "",
|
||||
freshVolumes = false,
|
||||
}: {
|
||||
composeId: string;
|
||||
titleLog: string;
|
||||
descriptionLog: string;
|
||||
freshVolumes?: boolean;
|
||||
}) => {
|
||||
const compose = await findComposeById(composeId);
|
||||
|
||||
@ -400,6 +414,16 @@ export const rebuildCompose = async ({
|
||||
}
|
||||
}
|
||||
|
||||
if (freshVolumes && compose.composeType === "docker-compose") {
|
||||
const downCommand = `set -e; env -i PATH="$PATH" docker compose -p ${compose.appName} down --volumes 2>&1 || true;`;
|
||||
const downWithLog = `(${downCommand}) >> ${deployment.logPath} 2>&1`;
|
||||
if (compose.serverId) {
|
||||
await execAsyncRemote(compose.serverId, downWithLog);
|
||||
} else {
|
||||
await execAsync(downWithLog);
|
||||
}
|
||||
}
|
||||
|
||||
command = "set -e;";
|
||||
command += await getBuildComposeCommand(compose);
|
||||
commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
|
||||
|
||||
@ -26,11 +26,7 @@ export const getDockerCommand = (application: ApplicationNested) => {
|
||||
try {
|
||||
const image = `${appName}`;
|
||||
|
||||
const defaultContextPath =
|
||||
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
|
||||
|
||||
const dockerContextPath =
|
||||
getDockerContextPath(application) || defaultContextPath;
|
||||
const dockerContextPath = getDockerContextPath(application);
|
||||
|
||||
const commandArgs = ["build", "-t", image, "-f", dockerFilePath, "."];
|
||||
|
||||
|
||||
@ -138,8 +138,10 @@ export const getDockerContextPath = (application: Application) => {
|
||||
const { APPLICATIONS_PATH } = paths(!!application.serverId);
|
||||
const { appName, dockerContextPath } = application;
|
||||
|
||||
if (!dockerContextPath) {
|
||||
return null;
|
||||
}
|
||||
return path.join(APPLICATIONS_PATH, appName, "code", dockerContextPath);
|
||||
return path.join(
|
||||
APPLICATIONS_PATH,
|
||||
appName,
|
||||
"code",
|
||||
dockerContextPath || ".",
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user