mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-20 15:35:23 +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"
|
DATABASE_URL="postgres://dokploy:amukds4wi9001583845717ad2@localhost:5432/dokploy"
|
||||||
PORT=3000
|
PORT=3000
|
||||||
NODE_ENV=development
|
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 Link from "next/link";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,11 @@ const navigationItems = [
|
|||||||
href: "/dashboard/settings/billing",
|
href: "/dashboard/settings/billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Managed Servers",
|
||||||
|
href: "/dashboard/settings/managed-servers",
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Invoices",
|
name: "Invoices",
|
||||||
href: "/dashboard/settings/invoices",
|
href: "/dashboard/settings/invoices",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
Server,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -83,6 +84,11 @@ const navigationItems = [
|
|||||||
href: "/dashboard/settings/billing",
|
href: "/dashboard/settings/billing",
|
||||||
icon: CreditCard,
|
icon: CreditCard,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Managed Servers",
|
||||||
|
href: "/dashboard/settings/managed-servers",
|
||||||
|
icon: Server,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Invoices",
|
name: "Invoices",
|
||||||
href: "/dashboard/settings/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,
|
"when": 1788995453103,
|
||||||
"tag": "0196_worthless_ravenous",
|
"tag": "0196_worthless_ravenous",
|
||||||
"breakpoints": true
|
"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 { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||||
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
import { forwardAuthRouter } from "./routers/proprietary/forward-auth";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||||
import { scimRouter } from "./routers/proprietary/scim";
|
import { scimRouter } from "./routers/proprietary/scim";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
@@ -122,6 +123,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
patch: patchRouter,
|
patch: patchRouter,
|
||||||
overview: overviewRouter,
|
overview: overviewRouter,
|
||||||
transfer: transferRouter,
|
transfer: transferRouter,
|
||||||
|
managedServer: managedServerRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// 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,
|
updateSSHKeyById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
|
import { managedServer } from "@dokploy/server/db/schema/managed-server";
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import {
|
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, {
|
await audit(ctx, {
|
||||||
action: "delete",
|
action: "delete",
|
||||||
resourceType: "sshKey",
|
resourceType: "sshKey",
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"drizzle-zod": "0.5.1",
|
"drizzle-zod": "0.5.1",
|
||||||
|
"hostinger-api-sdk": "^0.0.17",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"micromatch": "4.0.8",
|
"micromatch": "4.0.8",
|
||||||
"nanoid": "3.3.18",
|
"nanoid": "3.3.18",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from "./gitea";
|
|||||||
export * from "./github";
|
export * from "./github";
|
||||||
export * from "./gitlab";
|
export * from "./gitlab";
|
||||||
export * from "./libsql";
|
export * from "./libsql";
|
||||||
|
export * from "./managed-server";
|
||||||
export * from "./mariadb";
|
export * from "./mariadb";
|
||||||
export * from "./mongo";
|
export * from "./mongo";
|
||||||
export * from "./mount";
|
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/rollbacks";
|
||||||
export * from "./services/schedule";
|
export * from "./services/schedule";
|
||||||
export * from "./services/security";
|
export * from "./services/security";
|
||||||
|
export * from "./services/managed-server";
|
||||||
export * from "./services/server";
|
export * from "./services/server";
|
||||||
export * from "./services/server-health";
|
export * from "./services/server-health";
|
||||||
export * from "./services/settings";
|
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";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
|
export const createSshKey = async (input: z.infer<typeof apiCreateSshKey>) => {
|
||||||
await db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
const sshKey = await tx
|
const sshKey = await tx
|
||||||
.insert(sshKeys)
|
.insert(sshKeys)
|
||||||
.values(input)
|
.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:
|
drizzle-zod:
|
||||||
specifier: 0.5.1
|
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)
|
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:
|
lodash:
|
||||||
specifier: 4.17.21
|
specifier: 4.17.21
|
||||||
version: 4.17.21
|
version: 4.17.21
|
||||||
@@ -6307,6 +6310,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
|
resolution: {integrity: sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
hostinger-api-sdk@0.0.17:
|
||||||
|
resolution: {integrity: sha512-PGIS2P4bwwvztlUHTdXYia7sAJsmDd9qsSE2tr8wDMAAjYow0J979w4dHcuHfC4ovo8nZoj3btqTDJtIIeSPYw==}
|
||||||
|
|
||||||
html-to-text@9.0.5:
|
html-to-text@9.0.5:
|
||||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -15391,6 +15397,13 @@ snapshots:
|
|||||||
|
|
||||||
hono@4.12.2: {}
|
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:
|
html-to-text@9.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@selderee/plugin-htmlparser2': 0.11.0
|
'@selderee/plugin-htmlparser2': 0.11.0
|
||||||
|
|||||||
Reference in New Issue
Block a user