mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 21:31:11 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 12784ec0ff | |||
| 5bc53bfbff | |||
| 549b124fcd | |||
| 0c5da0b36f | |||
| ee18724dd7 | |||
| 1ae9b4025c | |||
| 5bc870dc2d | |||
| 299950a323 |
+8403
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Managed Servers (Dokploy Cloud only) — API token from https://hpanel.hostinger.com/profile/api
|
||||
HOSTINGER_API_KEY=
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CreditCard, FileText } from "lucide-react";
|
||||
import { CreditCard, FileText, Server } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
@@ -83,6 +84,11 @@ const navigationItems = [
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Server,
|
||||
Trash2,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
const navigationItems = [
|
||||
{
|
||||
name: "Subscription",
|
||||
href: "/dashboard/settings/billing",
|
||||
icon: CreditCard,
|
||||
},
|
||||
{
|
||||
name: "Managed Servers",
|
||||
href: "/dashboard/settings/managed-servers",
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
name: "Invoices",
|
||||
href: "/dashboard/settings/invoices",
|
||||
icon: FileText,
|
||||
},
|
||||
];
|
||||
|
||||
const STATUS_MAP: Record<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
variant: "default" | "secondary" | "destructive" | "outline";
|
||||
}
|
||||
> = {
|
||||
pending: {
|
||||
label: "Pending",
|
||||
icon: <Clock className="size-3" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
provisioning: {
|
||||
label: "Provisioning",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
configuring: {
|
||||
label: "Installing Dokploy",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
icon: <CheckCircle2 className="size-3" />,
|
||||
variant: "default",
|
||||
},
|
||||
error: {
|
||||
label: "Error",
|
||||
icon: <XCircle className="size-3" />,
|
||||
variant: "destructive",
|
||||
},
|
||||
terminating: {
|
||||
label: "Terminating",
|
||||
icon: <Loader2 className="size-3 animate-spin" />,
|
||||
variant: "secondary",
|
||||
},
|
||||
terminated: {
|
||||
label: "Terminated",
|
||||
icon: <AlertCircle className="size-3" />,
|
||||
variant: "outline",
|
||||
},
|
||||
};
|
||||
|
||||
function formatSpecs(cpus: number, memoryGb: number, storageGb: number) {
|
||||
return `${cpus} vCPU · ${memoryGb} GB RAM · ${storageGb} GB NVMe`;
|
||||
}
|
||||
|
||||
function formatPrice(priceCents: number) {
|
||||
return `$${(priceCents / 100).toFixed(2)}/mo`;
|
||||
}
|
||||
|
||||
const CONTINENT_ORDER = ["Europe", "Americas", "Asia-Pacific"];
|
||||
|
||||
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||
const [selectedZone, setSelectedZone] = useState<string>("");
|
||||
|
||||
const { data: plans, isLoading: loadingPlans } =
|
||||
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||
const { data: zones, isLoading: loadingZones } =
|
||||
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||
|
||||
const isLoadingOptions = loadingPlans || loadingZones;
|
||||
|
||||
const purchase = api.managedServer.purchase.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server order placed! Provisioning will take ~5 minutes.");
|
||||
setOpen(false);
|
||||
onSuccess();
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
const plan = plans?.find((p) => p.id === selectedPlan);
|
||||
|
||||
const groupedZones = zones?.reduce<Record<string, typeof zones>>((acc, z) => {
|
||||
if (!acc[z.continent]) acc[z.continent] = [];
|
||||
acc[z.continent]!.push(z);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="size-4 mr-2" />
|
||||
Order Server
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Order a Managed Server</DialogTitle>
|
||||
<DialogDescription>
|
||||
We'll provision and configure a server for you automatically. Ready
|
||||
in ~5 minutes.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 pt-2">
|
||||
{isLoadingOptions ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 gap-3 text-muted-foreground">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
<p className="text-sm">Loading available plans...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Plan cards */}
|
||||
<div className="space-y-2">
|
||||
<Label>Plan</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{plans?.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedPlan(p.id)}
|
||||
className={cn(
|
||||
"flex flex-col gap-1 rounded-lg border p-3 text-left transition-colors",
|
||||
selectedPlan === p.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-semibold text-sm">{p.name}</p>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
{formatPrice(p.priceCents)}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatSpecs(p.cpus, p.memoryGb, p.storageGb)}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone selector grouped by continent */}
|
||||
<div className="space-y-2">
|
||||
<Label>Location</Label>
|
||||
<Select value={selectedZone} onValueChange={setSelectedZone}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a location..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
className="max-h-64 overflow-y-auto"
|
||||
>
|
||||
{CONTINENT_ORDER.filter((c) => groupedZones?.[c]).map((continent) => (
|
||||
<div key={continent}>
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{continent}
|
||||
</div>
|
||||
{groupedZones?.[continent]?.map((z) => (
|
||||
<SelectItem key={z.id} value={z.id}>
|
||||
{z.description}
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{plan && selectedZone && (
|
||||
<div className="rounded-lg bg-muted p-3 text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Plan</span>
|
||||
<span className="font-medium">{plan.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Specs</span>
|
||||
<span className="font-medium">
|
||||
{formatSpecs(plan.cpus, plan.memoryGb, plan.storageGb)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Price</span>
|
||||
<span className="font-medium">{formatPrice(plan.priceCents)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Zone</span>
|
||||
<span className="font-medium">
|
||||
{zones?.find((z) => z.id === selectedZone)?.description ?? selectedZone}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!selectedPlan || !selectedZone || purchase.isPending}
|
||||
onClick={() => {
|
||||
if (!selectedPlan || !selectedZone) return;
|
||||
purchase.mutate({
|
||||
plan: selectedPlan,
|
||||
zone: selectedZone,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{purchase.isPending ? (
|
||||
<>
|
||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||
Placing order...
|
||||
</>
|
||||
) : (
|
||||
"Order Server"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
hobby: "Hobby",
|
||||
starter: "Starter",
|
||||
pro: "Pro",
|
||||
business: "Business",
|
||||
};
|
||||
|
||||
export const ShowManagedServers = () => {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: servers, isLoading } = api.managedServer.list.useQuery();
|
||||
|
||||
const syncStatus = api.managedServer.syncStatus.useMutation({
|
||||
onSuccess: () => utils.managedServer.list.invalidate(),
|
||||
});
|
||||
|
||||
const reconnect = api.managedServer.reconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server reconnected successfully.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deleteServer = api.managedServer.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Server terminated.");
|
||||
utils.managedServer.list.invalidate();
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
|
||||
<div className="rounded-xl bg-background shadow-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl flex flex-row gap-2">
|
||||
<Server className="size-6 text-muted-foreground self-center" />
|
||||
Billing
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your subscription and servers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 py-4 border-t">
|
||||
<nav className="flex space-x-2 border-b">
|
||||
{navigationItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = router.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-4 py-2 text-sm font-medium border-b-2 transition-colors",
|
||||
isActive
|
||||
? "border-primary text-primary"
|
||||
: "border-transparent text-muted-foreground hover:text-primary hover:border-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold text-base">Managed Servers</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Servers provisioned and managed by Dokploy Cloud
|
||||
</p>
|
||||
</div>
|
||||
<OrderServerDialog
|
||||
onSuccess={() => utils.managedServer.list.invalidate()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : servers?.length === 0 ? (
|
||||
<div className="text-center py-12 border rounded-lg border-dashed">
|
||||
<Server className="size-10 mx-auto text-muted-foreground mb-3" />
|
||||
<p className="text-sm font-medium">No managed servers yet</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Order a server and we'll provision and configure it for you
|
||||
automatically.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers?.map((s) => {
|
||||
const status =
|
||||
STATUS_MAP[s.status] ?? STATUS_MAP.error!;
|
||||
const isProvisioning = [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
].includes(s.status);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={s.managedServerId}
|
||||
className="flex items-center justify-between rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="size-5 text-muted-foreground shrink-0" />
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">
|
||||
{PLAN_LABELS[s.plan] ?? s.plan}
|
||||
</span>
|
||||
<Badge
|
||||
variant={status?.variant}
|
||||
className="flex items-center gap-1 text-xs h-5"
|
||||
>
|
||||
{status?.icon}
|
||||
{status?.label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{s.hostname ?? ""}
|
||||
{s.ipAddress ? ` · ${s.ipAddress}` : ""}
|
||||
{s.zone ? ` · ${s.zone}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isProvisioning && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
syncStatus.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={syncStatus.isPending}
|
||||
>
|
||||
<Loader2
|
||||
className={cn(
|
||||
"size-4",
|
||||
syncStatus.isPending && "animate-spin",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && !s.serverId && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
reconnect.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
disabled={reconnect.isPending}
|
||||
>
|
||||
{reconnect.isPending ? (
|
||||
<Loader2 className="size-3.5 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="size-3.5 mr-1.5" />
|
||||
)}
|
||||
Reconnect
|
||||
</Button>
|
||||
)}
|
||||
{s.status === "ready" && s.server && (
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link
|
||||
href={`/dashboard/settings/server?serverId=${s.serverId}`}
|
||||
>
|
||||
<ExternalLink className="size-3.5 mr-1.5" />
|
||||
Open
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<DialogAction
|
||||
title="Terminate Server"
|
||||
description="This will permanently destroy the server and all data on it. This action cannot be undone."
|
||||
type="destructive"
|
||||
onClick={() =>
|
||||
deleteServer.mutate({
|
||||
managedServerId: s.managedServerId,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</DialogAction>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TYPE "public"."managedServerStatus" AS ENUM('pending', 'provisioning', 'configuring', 'ready', 'error', 'terminating', 'terminated');--> statement-breakpoint
|
||||
CREATE TABLE "managed_server" (
|
||||
"managedServerId" text PRIMARY KEY NOT NULL,
|
||||
"organizationId" text NOT NULL,
|
||||
"serverId" text,
|
||||
"plan" text NOT NULL,
|
||||
"status" "managedServerStatus" DEFAULT 'pending' NOT NULL,
|
||||
"providerVmId" text,
|
||||
"zone" text NOT NULL,
|
||||
"sshKeyId" text,
|
||||
"ipAddress" text,
|
||||
"hostname" text,
|
||||
"stripeSubscriptionId" text,
|
||||
"stripePriceId" text,
|
||||
"errorMessage" text,
|
||||
"createdAt" text NOT NULL,
|
||||
"updatedAt" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "managed_server" ADD CONSTRAINT "managed_server_sshKeyId_ssh-key_sshKeyId_fk" FOREIGN KEY ("sshKeyId") REFERENCES "public"."ssh-key"("sshKeyId") ON DELETE set null ON UPDATE no action;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1380,6 +1380,13 @@
|
||||
"when": 1788995453103,
|
||||
"tag": "0196_worthless_ravenous",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 197,
|
||||
"version": "7",
|
||||
"when": 1790609715152,
|
||||
"tag": "0197_aberrant_psynapse",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { IS_CLOUD } from "@dokploy/server/constants";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import type { ReactElement } from "react";
|
||||
import { ShowManagedServers } from "@/components/dashboard/settings/billing/show-managed-servers";
|
||||
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
|
||||
|
||||
const Page = () => {
|
||||
return <ShowManagedServers />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
|
||||
Page.getLayout = (page: ReactElement) => {
|
||||
return <DashboardLayout metaName="Managed Servers">{page}</DashboardLayout>;
|
||||
};
|
||||
|
||||
export async function getServerSideProps(
|
||||
ctx: GetServerSidePropsContext,
|
||||
) {
|
||||
if (!IS_CLOUD) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/dashboard/home",
|
||||
},
|
||||
};
|
||||
}
|
||||
const { user } = await validateRequest(ctx.req);
|
||||
if (!user || user.role !== "owner") {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { props: {} };
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||
import { scimRouter } from "./routers/proprietary/scim";
|
||||
import { ssoRouter } from "./routers/proprietary/sso";
|
||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||
@@ -122,6 +123,7 @@ export const appRouter = createTRPCRouter({
|
||||
patch: patchRouter,
|
||||
overview: overviewRouter,
|
||||
transfer: transferRouter,
|
||||
managedServer: managedServerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import {
|
||||
createServer,
|
||||
IS_CLOUD,
|
||||
serverSetup,
|
||||
} from "@dokploy/server";
|
||||
import {
|
||||
apiCreateManagedServer,
|
||||
apiDeleteManagedServer,
|
||||
apiFindOneManagedServer,
|
||||
} from "@dokploy/server/db/schema/managed-server";
|
||||
import {
|
||||
createManagedServer,
|
||||
deleteManagedServer,
|
||||
findManagedServerById,
|
||||
findManagedServersByOrg,
|
||||
updateManagedServer,
|
||||
} from "@dokploy/server/services/managed-server";
|
||||
import { createSshKey } from "@dokploy/server/services/ssh-key";
|
||||
import { generateSSHKey } from "@dokploy/server/utils/filesystem/ssh";
|
||||
import {
|
||||
DOKPLOY_PLANS,
|
||||
createUpCloudServer,
|
||||
deleteUpCloudServer,
|
||||
getPublicIPv4,
|
||||
getUpCloudServer,
|
||||
getUpCloudZones,
|
||||
stopUpCloudServer,
|
||||
} from "@dokploy/server/utils/upcloud";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { nanoid } from "nanoid";
|
||||
import { adminProcedure, createTRPCRouter } from "../../trpc";
|
||||
|
||||
|
||||
export const managedServerRouter = createTRPCRouter({
|
||||
getPlans: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return DOKPLOY_PLANS;
|
||||
}),
|
||||
|
||||
getDataCenters: adminProcedure.query(async () => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
return getUpCloudZones();
|
||||
}),
|
||||
|
||||
list: adminProcedure.query(async ({ ctx }) => {
|
||||
if (!IS_CLOUD) return [];
|
||||
return findManagedServersByOrg(ctx.session.activeOrganizationId);
|
||||
}),
|
||||
|
||||
one: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.query(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
return record;
|
||||
}),
|
||||
|
||||
purchase: adminProcedure
|
||||
.input(apiCreateManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Managed servers are only available in Dokploy Cloud",
|
||||
});
|
||||
}
|
||||
|
||||
const plan = DOKPLOY_PLANS.find((p) => p.id === input.plan);
|
||||
if (!plan) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid plan" });
|
||||
}
|
||||
|
||||
const hostname =
|
||||
`dokploy-${ctx.session.activeOrganizationId.slice(0, 8)}-${nanoid(6)}`.toLowerCase();
|
||||
|
||||
const managedRecord = await createManagedServer({
|
||||
organizationId: ctx.session.activeOrganizationId,
|
||||
plan: input.plan,
|
||||
zone: input.zone,
|
||||
status: "provisioning",
|
||||
});
|
||||
|
||||
provisionManagedServer(
|
||||
managedRecord.managedServerId,
|
||||
plan.upcloudPlan,
|
||||
input.zone,
|
||||
hostname,
|
||||
ctx.session.activeOrganizationId,
|
||||
).catch(async (err) => {
|
||||
const responseBody = err?.response?.data;
|
||||
const detail =
|
||||
responseBody?.message ??
|
||||
responseBody?.error ??
|
||||
(typeof responseBody === "string" ? responseBody : null);
|
||||
const errorMessage = detail
|
||||
? `${err?.message}: ${typeof detail === "object" ? JSON.stringify(detail) : detail}`
|
||||
: (err?.message ?? "Unknown error during provisioning");
|
||||
console.error("[managed-server] provisioning failed:", errorMessage, responseBody);
|
||||
await updateManagedServer(managedRecord.managedServerId, {
|
||||
status: "error",
|
||||
errorMessage,
|
||||
});
|
||||
});
|
||||
|
||||
return managedRecord;
|
||||
}),
|
||||
|
||||
delete: adminProcedure
|
||||
.input(apiDeleteManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
status: "terminating",
|
||||
});
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
terminateManagedServer(input.managedServerId, upcloudUuid ?? null).catch(
|
||||
(err) => console.error("[managed-server] termination failed:", err),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
reconnect: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
if (record.serverId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Server is already linked",
|
||||
});
|
||||
}
|
||||
if (!record.ipAddress) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No IP address available to reconnect",
|
||||
});
|
||||
}
|
||||
|
||||
if (!record.sshKeyId) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "No SSH key found for this managed server",
|
||||
});
|
||||
}
|
||||
|
||||
const name = record.hostname ?? record.managedServerId;
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${name}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: record.ipAddress,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: record.sshKeyId,
|
||||
},
|
||||
ctx.session.activeOrganizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
status: "ready",
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
|
||||
syncStatus: adminProcedure
|
||||
.input(apiFindOneManagedServer)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!IS_CLOUD) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "Cloud only" });
|
||||
}
|
||||
const record = await findManagedServerById(input.managedServerId);
|
||||
if (record.organizationId !== ctx.session.activeOrganizationId) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||
}
|
||||
|
||||
const upcloudUuid = record.providerVmId;
|
||||
if (!upcloudUuid) return record;
|
||||
|
||||
const vm = await getUpCloudServer(upcloudUuid);
|
||||
const ipAddress = getPublicIPv4(vm) ?? record.ipAddress ?? undefined;
|
||||
|
||||
await updateManagedServer(input.managedServerId, {
|
||||
ipAddress,
|
||||
hostname: vm.hostname ?? undefined,
|
||||
status:
|
||||
vm.state === "started"
|
||||
? record.serverId
|
||||
? "ready"
|
||||
: "configuring"
|
||||
: record.status,
|
||||
});
|
||||
|
||||
return findManagedServerById(input.managedServerId);
|
||||
}),
|
||||
});
|
||||
|
||||
async function provisionManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudPlan: string,
|
||||
zone: string,
|
||||
hostname: string,
|
||||
organizationId: string,
|
||||
) {
|
||||
const { publicKey, privateKey } = await generateSSHKey("rsa");
|
||||
|
||||
const vm = await createUpCloudServer({
|
||||
hostname,
|
||||
upcloudPlan,
|
||||
zone,
|
||||
sshKey: publicKey,
|
||||
});
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
providerVmId: vm.uuid,
|
||||
hostname: vm.hostname,
|
||||
status: "configuring",
|
||||
});
|
||||
|
||||
await waitForServerStarted(vm.uuid, managedServerId);
|
||||
|
||||
const finalVm = await getUpCloudServer(vm.uuid);
|
||||
const finalIp = getPublicIPv4(finalVm);
|
||||
|
||||
if (!finalIp) {
|
||||
throw new Error("VM is started but has no public IPv4 address");
|
||||
}
|
||||
|
||||
const sshKey = await createSshKey({
|
||||
name: hostname,
|
||||
publicKey,
|
||||
privateKey,
|
||||
organizationId,
|
||||
});
|
||||
|
||||
if (!sshKey) throw new Error("Failed to create SSH key");
|
||||
|
||||
const serverRecord = await createServer(
|
||||
{
|
||||
name: `Managed • ${hostname}`,
|
||||
description: "Managed server provisioned by Dokploy Cloud",
|
||||
ipAddress: finalIp,
|
||||
port: 22,
|
||||
username: "root",
|
||||
serverType: "deploy",
|
||||
enableDockerCleanup: true,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
},
|
||||
organizationId,
|
||||
);
|
||||
|
||||
await updateManagedServer(managedServerId, {
|
||||
serverId: serverRecord.serverId,
|
||||
sshKeyId: sshKey.sshKeyId,
|
||||
ipAddress: finalIp,
|
||||
});
|
||||
|
||||
await serverSetup(serverRecord.serverId);
|
||||
await updateManagedServer(managedServerId, { status: "ready" });
|
||||
}
|
||||
|
||||
async function waitForServerStarted(
|
||||
uuid: string,
|
||||
_managedServerId: string,
|
||||
maxAttempts = 30,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "started") return;
|
||||
if (vm.state === "error") throw new Error("VM entered error state");
|
||||
}
|
||||
throw new Error("Timed out waiting for VM to start");
|
||||
}
|
||||
|
||||
async function waitForServerStopped(
|
||||
uuid: string,
|
||||
maxAttempts = 18,
|
||||
intervalMs = 10_000,
|
||||
) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
const vm = await getUpCloudServer(uuid);
|
||||
if (vm.state === "stopped") return;
|
||||
}
|
||||
}
|
||||
|
||||
async function terminateManagedServer(
|
||||
managedServerId: string,
|
||||
upcloudUuid: string | null,
|
||||
) {
|
||||
if (upcloudUuid) {
|
||||
await stopUpCloudServer(upcloudUuid);
|
||||
await waitForServerStopped(upcloudUuid);
|
||||
await deleteUpCloudServer(upcloudUuid);
|
||||
}
|
||||
await deleteManagedServer(managedServerId);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
updateSSHKeyById,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -57,6 +58,16 @@ export const sshRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const linkedManagedServer = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.sshKeyId, input.sshKeyId),
|
||||
});
|
||||
if (linkedManagedServer) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "This SSH key is used by a managed server and cannot be deleted.",
|
||||
});
|
||||
}
|
||||
|
||||
await audit(ctx, {
|
||||
action: "delete",
|
||||
resourceType: "sshKey",
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"drizzle-orm": "0.45.2",
|
||||
"drizzle-zod": "0.5.1",
|
||||
"hostinger-api-sdk": "^0.0.17",
|
||||
"lodash": "4.17.21",
|
||||
"micromatch": "4.0.8",
|
||||
"nanoid": "3.3.18",
|
||||
|
||||
@@ -17,6 +17,7 @@ export * from "./gitea";
|
||||
export * from "./github";
|
||||
export * from "./gitlab";
|
||||
export * from "./libsql";
|
||||
export * from "./managed-server";
|
||||
export * from "./mariadb";
|
||||
export * from "./mongo";
|
||||
export * from "./mount";
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import { pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { organization } from "./account";
|
||||
import { server } from "./server";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
|
||||
export const managedServerStatus = pgEnum("managedServerStatus", [
|
||||
"pending",
|
||||
"provisioning",
|
||||
"configuring",
|
||||
"ready",
|
||||
"error",
|
||||
"terminating",
|
||||
"terminated",
|
||||
]);
|
||||
|
||||
export const managedServer = pgTable("managed_server", {
|
||||
managedServerId: text("managedServerId")
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => nanoid()),
|
||||
organizationId: text("organizationId")
|
||||
.notNull()
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
serverId: text("serverId").references(() => server.serverId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
/** UpCloud plan name, e.g. "2xCPU-4GB" */
|
||||
plan: text("plan").notNull(),
|
||||
status: managedServerStatus("status").notNull().default("pending"),
|
||||
/** UpCloud server UUID */
|
||||
providerVmId: text("providerVmId"),
|
||||
/** UpCloud zone ID, e.g. "fi-hel1" */
|
||||
zone: text("zone").notNull(),
|
||||
/** SSH key used to access this server */
|
||||
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
ipAddress: text("ipAddress"),
|
||||
hostname: text("hostname"),
|
||||
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||
stripePriceId: text("stripePriceId"),
|
||||
errorMessage: text("errorMessage"),
|
||||
createdAt: text("createdAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
updatedAt: text("updatedAt")
|
||||
.notNull()
|
||||
.$defaultFn(() => new Date().toISOString()),
|
||||
});
|
||||
|
||||
export const managedServerRelations = relations(managedServer, ({ one }) => ({
|
||||
organization: one(organization, {
|
||||
fields: [managedServer.organizationId],
|
||||
references: [organization.id],
|
||||
}),
|
||||
server: one(server, {
|
||||
fields: [managedServer.serverId],
|
||||
references: [server.serverId],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const apiCreateManagedServer = z.object({
|
||||
plan: z.string().min(1),
|
||||
zone: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiFindOneManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
|
||||
export const apiDeleteManagedServer = z.object({
|
||||
managedServerId: z.string().min(1),
|
||||
});
|
||||
@@ -50,6 +50,7 @@ export * from "./services/registry";
|
||||
export * from "./services/rollbacks";
|
||||
export * from "./services/schedule";
|
||||
export * from "./services/security";
|
||||
export * from "./services/managed-server";
|
||||
export * from "./services/server";
|
||||
export * from "./services/server-health";
|
||||
export * from "./services/settings";
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export type ManagedServer = typeof managedServer.$inferSelect;
|
||||
|
||||
export const createManagedServer = async (
|
||||
input: typeof managedServer.$inferInsert,
|
||||
) => {
|
||||
const record = await db
|
||||
.insert(managedServer)
|
||||
.values(input)
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
if (!record) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServerById = async (managedServerId: string) => {
|
||||
const record = await db.query.managedServer.findFirst({
|
||||
where: eq(managedServer.managedServerId, managedServerId),
|
||||
with: { server: true },
|
||||
});
|
||||
if (!record)
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "Managed server not found" });
|
||||
return record;
|
||||
};
|
||||
|
||||
export const findManagedServersByOrg = async (organizationId: string) => {
|
||||
return db.query.managedServer.findMany({
|
||||
where: eq(managedServer.organizationId, organizationId),
|
||||
with: { server: true },
|
||||
orderBy: (t, { desc }) => [desc(t.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const updateManagedServer = async (
|
||||
managedServerId: string,
|
||||
data: Partial<typeof managedServer.$inferInsert>,
|
||||
) => {
|
||||
return db
|
||||
.update(managedServer)
|
||||
.set({ ...data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(managedServer.managedServerId, managedServerId))
|
||||
.returning()
|
||||
.then((r) => r[0]);
|
||||
};
|
||||
|
||||
export const deleteManagedServer = async (managedServerId: string) => {
|
||||
return db
|
||||
.delete(managedServer)
|
||||
.where(eq(managedServer.managedServerId, managedServerId));
|
||||
};
|
||||
@@ -11,7 +11,7 @@ import { eq } from "drizzle-orm";
|
||||
import type { z } from "zod";
|
||||
|
||||
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
|
||||
await db.transaction(async (tx) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const sshKey = await tx
|
||||
.insert(sshKeys)
|
||||
.values(input)
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
BillingCatalogApi,
|
||||
BillingSubscriptionsApi,
|
||||
Configuration,
|
||||
VPSDataCentersApi,
|
||||
VPSVirtualMachineApi,
|
||||
} from "hostinger-api-sdk";
|
||||
|
||||
export type {
|
||||
BillingV1CatalogCatalogItemResource as HostingerCatalogItem,
|
||||
VPSV1DataCenterDataCenterResource as HostingerDataCenter,
|
||||
VPSV1VirtualMachinePurchaseRequest as HostingerPurchaseRequest,
|
||||
VPSV1VirtualMachineVirtualMachineResource as HostingerVM,
|
||||
} from "hostinger-api-sdk";
|
||||
|
||||
// Correct base URL — api.hostinger.com returns 530, developers.hostinger.com is the real gateway
|
||||
const HOSTINGER_BASE_PATH = "https://developers.hostinger.com";
|
||||
|
||||
function getConfig() {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) throw new Error("HOSTINGER_API_KEY is not set");
|
||||
return new Configuration({
|
||||
basePath: HOSTINGER_BASE_PATH,
|
||||
accessToken: apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
function getVmApi() {
|
||||
return new VPSVirtualMachineApi(getConfig());
|
||||
}
|
||||
|
||||
export async function getHostingerDataCenters() {
|
||||
try {
|
||||
const api = new VPSDataCentersApi(getConfig());
|
||||
const res = await api.getDataCenterListV1();
|
||||
return res.data;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getHostingerVpsCatalog() {
|
||||
const api = new BillingCatalogApi(getConfig());
|
||||
const res = await api.getCatalogItemListV1("VPS");
|
||||
return res.data;
|
||||
}
|
||||
|
||||
const FAKE_VM_ID = 999999;
|
||||
|
||||
export async function purchaseHostingerVps(
|
||||
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
|
||||
): Promise<import("hostinger-api-sdk").BillingV1OrderVirtualMachineOrderResource> {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log("[hostinger dry-run] purchaseHostingerVps called, skipping real purchase");
|
||||
return {
|
||||
virtual_machine: {
|
||||
id: FAKE_VM_ID,
|
||||
hostname: body.setup.hostname,
|
||||
state: "initial",
|
||||
ipv4: [],
|
||||
subscription_id: "dry-run-sub-123",
|
||||
},
|
||||
} as never;
|
||||
}
|
||||
const api = getVmApi();
|
||||
const res = await api.purchaseNewVirtualMachineV1(body);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getHostingerVm(vmId: number) {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log(`[hostinger dry-run] getHostingerVm(${vmId})`);
|
||||
return {
|
||||
id: vmId,
|
||||
hostname: "dry-run-vm",
|
||||
state: "running",
|
||||
ipv4: [{ address: "1.2.3.4" }],
|
||||
subscription_id: "dry-run-sub-123",
|
||||
} as never;
|
||||
}
|
||||
const api = getVmApi();
|
||||
const res = await api.getVirtualMachineDetailsV1(vmId);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function stopHostingerVm(vmId: number) {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log(`[hostinger dry-run] stopHostingerVm(${vmId})`);
|
||||
return;
|
||||
}
|
||||
const api = getVmApi();
|
||||
await api.stopVirtualMachineV1(vmId);
|
||||
}
|
||||
|
||||
export async function cancelHostingerSubscription(subscriptionId: string) {
|
||||
if (process.env.HOSTINGER_DRY_RUN === "true") {
|
||||
console.log(`[hostinger dry-run] cancelHostingerSubscription(${subscriptionId})`);
|
||||
return;
|
||||
}
|
||||
const api = new BillingSubscriptionsApi(getConfig());
|
||||
await api.cancelSubscriptionV1(subscriptionId, {
|
||||
reason_code: "other",
|
||||
cancel_option: "immediately",
|
||||
});
|
||||
}
|
||||
|
||||
/** Ubuntu 22.04 LTS template ID on Hostinger */
|
||||
export const UBUNTU_22_TEMPLATE_ID = 1009;
|
||||
|
||||
/**
|
||||
* Markup multiplier applied to Hostinger's catalog price to get Dokploy's user price.
|
||||
* Hostinger KVM2 = ~$24.49/mo → Dokploy charges $45/mo (~84% markup).
|
||||
*/
|
||||
const MARKUP = 1.84;
|
||||
|
||||
export interface ManagedServerPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
hostingerItemIdMonthly: string;
|
||||
hostingerItemIdAnnual: string;
|
||||
cpus: number;
|
||||
memoryMb: number;
|
||||
diskMb: number;
|
||||
bandwidthMb: number;
|
||||
/** Price in cents Hostinger charges us monthly */
|
||||
hostingerPriceCentsMonthly: number;
|
||||
/** Price in cents we charge the user monthly */
|
||||
dokployPriceCentsMonthly: number;
|
||||
/** Price in cents we charge the user annually */
|
||||
dokployPriceCentsAnnual: number;
|
||||
}
|
||||
|
||||
/** KVM plan IDs offered through Dokploy (excludes Game Panel plans) */
|
||||
const OFFERED_PLAN_IDS = [
|
||||
"hostingercom-vps-kvm1",
|
||||
"hostingercom-vps-kvm2",
|
||||
"hostingercom-vps-kvm4",
|
||||
"hostingercom-vps-kvm8",
|
||||
];
|
||||
|
||||
/**
|
||||
* Fetches live VPS plans from Hostinger catalog and applies Dokploy markup.
|
||||
* Only returns standard KVM plans (not Game Panel variants).
|
||||
*/
|
||||
export async function getManagedServerPlans(): Promise<ManagedServerPlan[]> {
|
||||
const catalog = await getHostingerVpsCatalog();
|
||||
|
||||
const plans: ManagedServerPlan[] = [];
|
||||
|
||||
for (const item of catalog) {
|
||||
if (!OFFERED_PLAN_IDS.includes(item.id ?? "")) continue;
|
||||
|
||||
const meta = item.metadata as Record<string, string> | null;
|
||||
const cpus = Number(meta?.cpus ?? 0);
|
||||
const memoryMb = Number(meta?.memory ?? 0);
|
||||
const diskMb = Number(meta?.disk_space ?? 0);
|
||||
const bandwidthMb = Number(meta?.bandwidth ?? 0);
|
||||
|
||||
const monthlyPrice = item.prices?.find(
|
||||
(p) => p.period === 1 && p.period_unit === "month",
|
||||
);
|
||||
const annualPrice = item.prices?.find(
|
||||
(p) => p.period === 1 && p.period_unit === "year",
|
||||
);
|
||||
|
||||
if (!monthlyPrice) continue;
|
||||
|
||||
const hostingerMonthly = monthlyPrice.price ?? 0;
|
||||
const hostingerAnnual = annualPrice?.price ?? hostingerMonthly * 12;
|
||||
|
||||
// Apply markup and round to nearest $0.50 (50 cents)
|
||||
const dokployMonthly = Math.ceil((hostingerMonthly * MARKUP) / 50) * 50;
|
||||
const dokployAnnual = Math.ceil((hostingerAnnual * MARKUP) / 50) * 50;
|
||||
|
||||
// Derive hostinger item IDs for monthly and annual billing
|
||||
const hostingerItemIdMonthly = monthlyPrice.id ?? `${item.id}-usd-1m`;
|
||||
const hostingerItemIdAnnual = annualPrice?.id ?? `${item.id}-usd-1y`;
|
||||
|
||||
// Map hostinger plan names to friendly names
|
||||
const friendlyNames: Record<string, string> = {
|
||||
"hostingercom-vps-kvm1": "Starter",
|
||||
"hostingercom-vps-kvm2": "Basic",
|
||||
"hostingercom-vps-kvm4": "Growth",
|
||||
"hostingercom-vps-kvm8": "Scale",
|
||||
};
|
||||
|
||||
plans.push({
|
||||
id: item.id ?? "",
|
||||
name: friendlyNames[item.id ?? ""] ?? item.name ?? item.id ?? "",
|
||||
hostingerItemIdMonthly,
|
||||
hostingerItemIdAnnual,
|
||||
cpus,
|
||||
memoryMb,
|
||||
diskMb,
|
||||
bandwidthMb,
|
||||
hostingerPriceCentsMonthly: hostingerMonthly,
|
||||
dokployPriceCentsMonthly: dokployMonthly,
|
||||
dokployPriceCentsAnnual: dokployAnnual,
|
||||
});
|
||||
}
|
||||
|
||||
return plans;
|
||||
}
|
||||
|
||||
export type ManagedServerPlanId = string;
|
||||
@@ -0,0 +1,220 @@
|
||||
import { defaultCommand } from "../setup/server-setup";
|
||||
|
||||
const UPCLOUD_BASE = "https://api.upcloud.com/1.3";
|
||||
const UBUNTU_22_TEMPLATE_UUID = "01000000-0000-4000-8000-000030220200";
|
||||
|
||||
export { UBUNTU_22_TEMPLATE_UUID };
|
||||
|
||||
export interface DokployPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
priceCents: number;
|
||||
upcloudPlan: string;
|
||||
cpus: number;
|
||||
memoryGb: number;
|
||||
storageGb: number;
|
||||
}
|
||||
|
||||
export const DOKPLOY_PLANS: DokployPlan[] = [
|
||||
{
|
||||
id: "hobby",
|
||||
name: "Hobby",
|
||||
priceCents: 2499,
|
||||
upcloudPlan: "1xCPU-2GB",
|
||||
cpus: 1,
|
||||
memoryGb: 2,
|
||||
storageGb: 50,
|
||||
},
|
||||
{
|
||||
id: "starter",
|
||||
name: "Starter",
|
||||
priceCents: 4900,
|
||||
upcloudPlan: "2xCPU-4GB",
|
||||
cpus: 2,
|
||||
memoryGb: 4,
|
||||
storageGb: 80,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
priceCents: 9900,
|
||||
upcloudPlan: "4xCPU-8GB",
|
||||
cpus: 4,
|
||||
memoryGb: 8,
|
||||
storageGb: 160,
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
priceCents: 19900,
|
||||
upcloudPlan: "HIMEM-4xCPU-16GB",
|
||||
cpus: 4,
|
||||
memoryGb: 16,
|
||||
storageGb: 320,
|
||||
},
|
||||
];
|
||||
|
||||
function getHeaders() {
|
||||
const token = process.env.UPCLOUD_TOKEN;
|
||||
if (!token) throw new Error("UPCLOUD_TOKEN is not set");
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
}
|
||||
|
||||
async function upcloudFetch<T>(
|
||||
path: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${UPCLOUD_BASE}${path}`, {
|
||||
...options,
|
||||
headers: { ...getHeaders(), ...(options.headers ?? {}) },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await res.json();
|
||||
} catch {
|
||||
body = await res.text();
|
||||
}
|
||||
const err = new Error(
|
||||
`UpCloud API error ${res.status}: ${JSON.stringify(body)}`,
|
||||
) as Error & { response: { status: number; data: unknown } };
|
||||
err.response = { status: res.status, data: body };
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export interface UpCloudZone {
|
||||
id: string;
|
||||
description: string;
|
||||
public: "yes" | "no";
|
||||
}
|
||||
|
||||
export type UpCloudZoneContinent = "Europe" | "Americas" | "Asia-Pacific";
|
||||
|
||||
export interface UpCloudZoneWithContinent extends UpCloudZone {
|
||||
continent: UpCloudZoneContinent;
|
||||
}
|
||||
|
||||
const ZONE_CONTINENT: Record<string, UpCloudZoneContinent> = {
|
||||
"fi-hel1": "Europe",
|
||||
"fi-hel2": "Europe",
|
||||
"de-fra1": "Europe",
|
||||
"nl-ams1": "Europe",
|
||||
"uk-lon1": "Europe",
|
||||
"se-sto1": "Europe",
|
||||
"dk-cph1": "Europe",
|
||||
"no-svg1": "Europe",
|
||||
"pl-waw1": "Europe",
|
||||
"es-mad1": "Europe",
|
||||
"us-nyc1": "Americas",
|
||||
"us-chi1": "Americas",
|
||||
"us-sjo1": "Americas",
|
||||
"sg-sin1": "Asia-Pacific",
|
||||
"au-syd1": "Asia-Pacific",
|
||||
};
|
||||
|
||||
export async function getUpCloudZones(): Promise<UpCloudZoneWithContinent[]> {
|
||||
const data = await upcloudFetch<{ zones: { zone: UpCloudZone[] } }>("/zone");
|
||||
return data.zones.zone
|
||||
.filter((z) => z.public === "yes" && z.id in ZONE_CONTINENT)
|
||||
.map((z) => ({ ...z, continent: ZONE_CONTINENT[z.id]! }))
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.continent.localeCompare(b.continent) ||
|
||||
a.description.localeCompare(b.description),
|
||||
);
|
||||
}
|
||||
|
||||
export interface UpCloudServer {
|
||||
uuid: string;
|
||||
hostname: string;
|
||||
title: string;
|
||||
state: "started" | "stopped" | "maintenance" | "error";
|
||||
zone: string;
|
||||
plan: string;
|
||||
core_number: string;
|
||||
memory_amount: string;
|
||||
ip_addresses: {
|
||||
ip_address: Array<{
|
||||
access: "public" | "utility" | "private";
|
||||
address: string;
|
||||
family: "IPv4" | "IPv6";
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export async function createUpCloudServer(params: {
|
||||
hostname: string;
|
||||
upcloudPlan: string;
|
||||
zone: string;
|
||||
sshKey: string;
|
||||
}): Promise<UpCloudServer> {
|
||||
const userDataScript = `#!/bin/bash\n${defaultCommand(false)}`;
|
||||
|
||||
const body = {
|
||||
server: {
|
||||
zone: params.zone,
|
||||
title: params.hostname,
|
||||
hostname: params.hostname,
|
||||
plan: params.upcloudPlan,
|
||||
firewall: "on",
|
||||
metadata: "yes",
|
||||
user_data: userDataScript,
|
||||
login_user: {
|
||||
username: "root",
|
||||
ssh_keys: {
|
||||
ssh_key: [params.sshKey],
|
||||
},
|
||||
create_password: "no",
|
||||
},
|
||||
storage_devices: {
|
||||
storage_device: [
|
||||
{
|
||||
action: "clone",
|
||||
storage: UBUNTU_22_TEMPLATE_UUID,
|
||||
title: `${params.hostname}-disk`,
|
||||
tier: "maxiops",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = await upcloudFetch<{ server: UpCloudServer }>("/server", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return data.server;
|
||||
}
|
||||
|
||||
export async function getUpCloudServer(uuid: string): Promise<UpCloudServer> {
|
||||
const data = await upcloudFetch<{ server: UpCloudServer }>(`/server/${uuid}`);
|
||||
return data.server;
|
||||
}
|
||||
|
||||
export async function stopUpCloudServer(uuid: string): Promise<void> {
|
||||
await upcloudFetch(`/server/${uuid}/stop`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ stop_server: { stop_type: "hard", timeout: "60" } }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteUpCloudServer(uuid: string): Promise<void> {
|
||||
await upcloudFetch(`/server/${uuid}?storages=1&backups=delete`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicIPv4(server: UpCloudServer): string | undefined {
|
||||
return server.ip_addresses.ip_address.find(
|
||||
(ip) => ip.access === "public" && ip.family === "IPv4",
|
||||
)?.address;
|
||||
}
|
||||
Generated
+13
@@ -680,6 +680,9 @@ importers:
|
||||
drizzle-zod:
|
||||
specifier: 0.5.1
|
||||
version: 0.5.1(drizzle-orm@0.45.2(@electric-sql/pglite@0.3.15)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.29.3)(mysql2@3.15.3)(pg@8.18.0)(postgres@3.4.4)(prisma@7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)(typescript@7.0.2)))(zod@4.3.6)
|
||||
hostinger-api-sdk:
|
||||
specifier: ^0.0.17
|
||||
version: 0.0.17
|
||||
lodash:
|
||||
specifier: 4.17.21
|
||||
version: 4.17.21
|
||||
@@ -6307,6 +6310,9 @@ packages:
|
||||
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
|
||||
engines: {node: '>=16.9.0'}
|
||||
|
||||
hostinger-api-sdk@0.0.17:
|
||||
resolution: {integrity: sha512-PGIS2P4bwwvztlUHTdXYia7sAJsmDd9qsSE2tr8wDMAAjYow0J979w4dHcuHfC4ovo8nZoj3btqTDJtIIeSPYw==}
|
||||
|
||||
html-to-text@9.0.5:
|
||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -15391,6 +15397,13 @@ snapshots:
|
||||
|
||||
hono@4.12.2: {}
|
||||
|
||||
hostinger-api-sdk@0.0.17:
|
||||
dependencies:
|
||||
axios: 1.18.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
- supports-color
|
||||
|
||||
html-to-text@9.0.5:
|
||||
dependencies:
|
||||
'@selderee/plugin-htmlparser2': 0.11.0
|
||||
|
||||
Reference in New Issue
Block a user