mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-04 05:25:22 +02:00
feat(billing): add support for Hetzner and Hostinger server providers with updated pricing and specifications
This commit is contained in:
@@ -24,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { ShowProviders } from "./show-providers";
|
||||
|
||||
const stripePromise = loadStripe(
|
||||
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
|
||||
@@ -151,44 +152,25 @@ export const ShowBilling = () => {
|
||||
<Loader2 className="animate-spin" />
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
{products?.map((product) => {
|
||||
const featured = true;
|
||||
return (
|
||||
<div key={product.id}>
|
||||
<section
|
||||
className={clsx(
|
||||
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
|
||||
featured
|
||||
? "order-first border py-8 lg:order-none"
|
||||
: "lg:py-8",
|
||||
)}
|
||||
>
|
||||
{isAnnual && (
|
||||
<div className="mb-4 flex flex-row items-center gap-2">
|
||||
<Badge>Recommended 🚀</Badge>
|
||||
</div>
|
||||
)}
|
||||
{isAnnual ? (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||
${" "}
|
||||
{calculatePrice(
|
||||
serverQuantity,
|
||||
isAnnual,
|
||||
).toFixed(2)}{" "}
|
||||
USD
|
||||
</p>
|
||||
|
|
||||
<p className="text-base font-semibold tracking-tight text-muted-foreground">
|
||||
${" "}
|
||||
{(
|
||||
calculatePrice(serverQuantity, isAnnual) / 12
|
||||
).toFixed(2)}{" "}
|
||||
/ Month USD
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
products?.map((product) => {
|
||||
const featured = true;
|
||||
return (
|
||||
<div key={product.id}>
|
||||
<section
|
||||
className={clsx(
|
||||
"flex flex-col rounded-3xl border-dashed border-2 px-4 max-w-sm",
|
||||
featured
|
||||
? "order-first border py-8 lg:order-none"
|
||||
: "lg:py-8",
|
||||
)}
|
||||
>
|
||||
{isAnnual && (
|
||||
<div className="mb-4 flex flex-row items-center gap-2">
|
||||
<Badge>Recommended 🚀</Badge>
|
||||
</div>
|
||||
)}
|
||||
{isAnnual ? (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||
${" "}
|
||||
{calculatePrice(serverQuantity, isAnnual).toFixed(
|
||||
@@ -196,127 +178,146 @@ export const ShowBilling = () => {
|
||||
)}{" "}
|
||||
USD
|
||||
</p>
|
||||
)}
|
||||
<h3 className="mt-5 font-medium text-lg text-primary">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-sm",
|
||||
featured ? "text-white" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{product.description}
|
||||
|
|
||||
<p className="text-base font-semibold tracking-tight text-muted-foreground">
|
||||
${" "}
|
||||
{(
|
||||
calculatePrice(serverQuantity, isAnnual) / 12
|
||||
).toFixed(2)}{" "}
|
||||
/ Month USD
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xl font-semibold tracking-tight text-primary ">
|
||||
${" "}
|
||||
{calculatePrice(serverQuantity, isAnnual).toFixed(
|
||||
2,
|
||||
)}{" "}
|
||||
USD
|
||||
</p>
|
||||
)}
|
||||
<h3 className="mt-5 font-medium text-lg text-primary">
|
||||
{product.name}
|
||||
</h3>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-sm",
|
||||
featured ? "text-white" : "text-slate-400",
|
||||
)}
|
||||
>
|
||||
{product.description}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
className={clsx(
|
||||
" mt-4 flex flex-col gap-y-2 text-sm",
|
||||
featured ? "text-white" : "text-slate-200",
|
||||
<ul
|
||||
className={clsx(
|
||||
" mt-4 flex flex-col gap-y-2 text-sm",
|
||||
featured ? "text-white" : "text-slate-200",
|
||||
)}
|
||||
>
|
||||
{[
|
||||
"All the features of Dokploy",
|
||||
"Unlimited deployments",
|
||||
"Self-hosted on your own infrastructure",
|
||||
"Full access to all deployment features",
|
||||
"Dokploy integration",
|
||||
"Backups",
|
||||
"All Incoming features",
|
||||
].map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex text-muted-foreground"
|
||||
>
|
||||
<CheckIcon />
|
||||
<span className="ml-4">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{serverQuantity} Servers
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
disabled={serverQuantity <= 1}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (serverQuantity <= 1) return;
|
||||
|
||||
setServerQuantity(serverQuantity - 1);
|
||||
}}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={serverQuantity}
|
||||
onChange={(e) => {
|
||||
setServerQuantity(
|
||||
e.target.value as unknown as number,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setServerQuantity(serverQuantity + 1);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
data?.subscriptions &&
|
||||
data?.subscriptions?.length > 0
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
"flex flex-row items-center gap-2 mt-4",
|
||||
)}
|
||||
>
|
||||
{[
|
||||
"All the features of Dokploy",
|
||||
"Unlimited deployments",
|
||||
"Self-hosted on your own infrastructure",
|
||||
"Full access to all deployment features",
|
||||
"Dokploy integration",
|
||||
"Backups",
|
||||
"All Incoming features",
|
||||
].map((feature) => (
|
||||
<li
|
||||
key={feature}
|
||||
className="flex text-muted-foreground"
|
||||
>
|
||||
<CheckIcon />
|
||||
<span className="ml-4">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex flex-col gap-2 mt-4">
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{serverQuantity} Servers
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
{admin?.user.stripeCustomerId && (
|
||||
<Button
|
||||
disabled={serverQuantity <= 1}
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (serverQuantity <= 1) return;
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
|
||||
setServerQuantity(serverQuantity - 1);
|
||||
window.open(session.url);
|
||||
}}
|
||||
>
|
||||
<MinusIcon className="h-4 w-4" />
|
||||
Manage Subscription
|
||||
</Button>
|
||||
<NumberInput
|
||||
value={serverQuantity}
|
||||
onChange={(e) => {
|
||||
setServerQuantity(
|
||||
e.target.value as unknown as number,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setServerQuantity(serverQuantity + 1);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
data?.subscriptions &&
|
||||
data?.subscriptions?.length > 0
|
||||
? "justify-between"
|
||||
: "justify-end",
|
||||
"flex flex-row items-center gap-2 mt-4",
|
||||
)}
|
||||
>
|
||||
{admin?.user.stripeCustomerId && (
|
||||
{data?.subscriptions?.length === 0 && (
|
||||
<div className="justify-end w-full">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
const session =
|
||||
await createCustomerPortalSession();
|
||||
|
||||
window.open(session.url);
|
||||
handleCheckout(product.id);
|
||||
}}
|
||||
disabled={serverQuantity < 1}
|
||||
>
|
||||
Manage Subscription
|
||||
Subscribe
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{data?.subscriptions?.length === 0 && (
|
||||
<div className="justify-end w-full">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={async () => {
|
||||
handleCheckout(product.id);
|
||||
}}
|
||||
disabled={serverQuantity < 1}
|
||||
>
|
||||
Subscribe
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="flex flex-col gap-4 px-4">
|
||||
<ShowProviders />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
Loader2,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
MapPin,
|
||||
Globe,
|
||||
Zap,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useState } from "react";
|
||||
|
||||
// Function to format prices correctly
|
||||
function formatPrice(price: string): string {
|
||||
return Number.parseFloat(price).toFixed(2);
|
||||
}
|
||||
|
||||
// Function to classify servers by type
|
||||
function getServerCategory(cpuType: string) {
|
||||
if (cpuType === "shared") {
|
||||
return {
|
||||
category: "Shared CPU",
|
||||
icon: Cpu,
|
||||
badge: "shared",
|
||||
color: "bg-blue-500",
|
||||
description: "Perfect for small and medium projects",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
category: "Dedicated CPU",
|
||||
icon: Zap,
|
||||
badge: "dedicated",
|
||||
color: "bg-purple-500",
|
||||
description: "Maximum performance for demanding applications",
|
||||
};
|
||||
}
|
||||
|
||||
export const ShowHetznerProviders = () => {
|
||||
const { data: serverTypes, isLoading: isLoadingTypes } =
|
||||
api.hetzner.serverTypes.useQuery();
|
||||
|
||||
const { data: locations, isLoading: isLoadingLocations } =
|
||||
api.hetzner.locations.useQuery();
|
||||
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>("");
|
||||
const [selectedArchitecture, setSelectedArchitecture] =
|
||||
useState<string>("x86");
|
||||
|
||||
if (isLoadingTypes || isLoadingLocations) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Get selected location info
|
||||
const currentLocation = selectedLocation
|
||||
? locations?.find((loc) => loc.name === selectedLocation)
|
||||
: locations?.[0]; // Default to first location
|
||||
|
||||
// Get available locations from server types
|
||||
const availableLocations = Array.from(
|
||||
new Set(
|
||||
serverTypes?.flatMap((type) => type.prices.map((p) => p.location)) || [],
|
||||
),
|
||||
);
|
||||
|
||||
// Filter locations that exist in both endpoints
|
||||
const validLocations =
|
||||
locations?.filter((loc) => availableLocations.includes(loc.name)) || [];
|
||||
|
||||
const activeLocationName = selectedLocation || validLocations[0]?.name || "";
|
||||
|
||||
// Filter by architecture first, then classify and sort by price
|
||||
const filteredServerTypes =
|
||||
serverTypes?.filter((type) => {
|
||||
if (selectedArchitecture === "all") return true;
|
||||
return type.architecture === selectedArchitecture;
|
||||
}) || [];
|
||||
|
||||
// Classify servers by type and sort by monthly price
|
||||
const sharedServers =
|
||||
filteredServerTypes
|
||||
?.filter((type) => type.cpu_type === "shared")
|
||||
.sort((a, b) => {
|
||||
const priceA =
|
||||
a.prices.find((p) => p.location === activeLocationName)?.price_monthly
|
||||
.gross || "0";
|
||||
const priceB =
|
||||
b.prices.find((p) => p.location === activeLocationName)?.price_monthly
|
||||
.gross || "0";
|
||||
return Number.parseFloat(priceA) - Number.parseFloat(priceB);
|
||||
}) || [];
|
||||
|
||||
const dedicatedServers =
|
||||
filteredServerTypes
|
||||
?.filter((type) => type.cpu_type === "dedicated")
|
||||
.sort((a, b) => {
|
||||
const priceA =
|
||||
a.prices.find((p) => p.location === activeLocationName)?.price_monthly
|
||||
.gross || "0";
|
||||
const priceB =
|
||||
b.prices.find((p) => p.location === activeLocationName)?.price_monthly
|
||||
.gross || "0";
|
||||
return Number.parseFloat(priceA) - Number.parseFloat(priceB);
|
||||
}) || [];
|
||||
|
||||
const renderServerGrid = (
|
||||
servers: typeof serverTypes,
|
||||
category: ReturnType<typeof getServerCategory>,
|
||||
) => {
|
||||
if (!servers?.length) return null;
|
||||
|
||||
const IconComponent = category.icon;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<IconComponent className="h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">{category.category}</h3>
|
||||
<Badge variant="outline" className={`text-white ${category.color}`}>
|
||||
{category.badge}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Sorted by price
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{category.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{servers.map((serverType) => {
|
||||
// Find price for selected location
|
||||
const locationPrice = serverType.prices.find(
|
||||
(p) => p.location === activeLocationName,
|
||||
);
|
||||
|
||||
if (!locationPrice) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={serverType.id}
|
||||
className="border-2 hover:border-blue-300 transition-colors bg-transparent"
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{serverType.name}</CardTitle>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-white ${category.color}`}
|
||||
>
|
||||
{category.badge}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${serverType.architecture === "arm" ? "bg-green-100 text-green-700" : "bg-blue-100 text-blue-700"}`}
|
||||
>
|
||||
{serverType.architecture.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription className="text-sm">
|
||||
{serverType.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-3">
|
||||
{/* Specs */}
|
||||
<div className="grid grid-cols-3 gap-2 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span>{serverType.cores} CPU</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStick className="h-4 w-4 text-green-500" />
|
||||
<span>{serverType.memory}GB RAM</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-4 w-4 text-orange-500" />
|
||||
<span>{serverType.disk}GB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing for selected location */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center p-2 bg-green-900 rounded border border-green-600">
|
||||
<span className="text-sm text-green-400 font-medium">
|
||||
Monthly
|
||||
</span>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-green-400">
|
||||
€{formatPrice(locationPrice.price_monthly.gross)}
|
||||
/mo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Region and Architecture Selectors */}
|
||||
<Card className="bg-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<MapPin className="h-5 w-5" />
|
||||
Filters
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Choose a region and architecture to see location-specific pricing
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* Region Selector */}
|
||||
<div>
|
||||
<span className="text-sm font-medium mb-2 block">Region</span>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedLocation}
|
||||
onValueChange={setSelectedLocation}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a region" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{validLocations.map((location) => (
|
||||
<SelectItem key={location.name} value={location.name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{location.name}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{location.city}, {location.country}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{currentLocation && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 text-sm text-muted-foreground">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<Globe className="h-3 w-3" />
|
||||
{currentLocation.description}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{currentLocation.network_zone}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Architecture Selector */}
|
||||
<div>
|
||||
<span className="text-sm font-medium mb-2 block">
|
||||
Architecture
|
||||
</span>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={selectedArchitecture}
|
||||
onValueChange={setSelectedArchitecture}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select architecture" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="x86">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span className="font-medium">x86 (Intel/AMD)</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Most common
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="arm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-green-500" />
|
||||
<span className="font-medium">ARM</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Energy efficient
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 text-sm">
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
<span>
|
||||
Architecture:{" "}
|
||||
{selectedArchitecture === "all"
|
||||
? "All"
|
||||
: selectedArchitecture.toUpperCase()}
|
||||
</span>
|
||||
</Badge>
|
||||
{filteredServerTypes && (
|
||||
<Badge variant="outline">
|
||||
{filteredServerTypes.length} servers
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Header with selected region info */}
|
||||
{currentLocation && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-primary">
|
||||
Servers in {currentLocation.city}, {currentLocation.country}
|
||||
</h2>
|
||||
<p className="text-sm text-primary">
|
||||
{currentLocation.description} • Zone:{" "}
|
||||
{currentLocation.network_zone}
|
||||
</p>
|
||||
</div>
|
||||
<Shield className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Shared CPU Servers */}
|
||||
{renderServerGrid(sharedServers, getServerCategory("shared"))}
|
||||
|
||||
{/* Dedicated CPU Servers */}
|
||||
{renderServerGrid(dedicatedServers, getServerCategory("dedicated"))}
|
||||
|
||||
{(!serverTypes || serverTypes.length === 0) && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Could not load server types. Please verify your Hetzner API key.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Architecture Information */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-blue-900 rounded-lg border border-blue-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu className="h-5 w-5 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm text-blue-200">
|
||||
<strong>x86 Architecture:</strong> Traditional Intel/AMD
|
||||
processors. Most compatible with existing software and
|
||||
applications. Best choice for general-purpose workloads.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-green-900 rounded-lg border border-green-600">
|
||||
<div className="flex items-start gap-2">
|
||||
<Cpu className="h-5 w-5 text-green-600 mt-0.5" />
|
||||
<div className="text-sm text-green-200">
|
||||
<strong>ARM Architecture:</strong> Modern, energy-efficient
|
||||
processors. Excellent price-to-performance ratio. Perfect for
|
||||
cloud-native and containerized applications.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,428 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { api } from "@/utils/api";
|
||||
import {
|
||||
Loader2,
|
||||
Server,
|
||||
Cpu,
|
||||
HardDrive,
|
||||
MemoryStick,
|
||||
Globe,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
// Map specs based on plan name
|
||||
function getVPSSpecs(planName: string) {
|
||||
const kvmMatch = planName.match(/KVM\s*(\d+)/i);
|
||||
const kvmNumber = kvmMatch ? Number.parseInt(kvmMatch[1] || "1", 10) : 1;
|
||||
|
||||
const specs = {
|
||||
1: { cpu: 1, ram: 4, storage: 50, bandwidth: 4 },
|
||||
2: { cpu: 2, ram: 8, storage: 100, bandwidth: 8 },
|
||||
4: { cpu: 4, ram: 16, storage: 200, bandwidth: 16 },
|
||||
8: { cpu: 8, ram: 32, storage: 400, bandwidth: 32 },
|
||||
};
|
||||
|
||||
return specs[kvmNumber as keyof typeof specs] || specs[1];
|
||||
}
|
||||
|
||||
// Get description based on plan name
|
||||
function getPlanDescription(planName: string) {
|
||||
const kvmMatch = planName.match(/KVM\s*(\d+)/i);
|
||||
const kvmNumber = kvmMatch ? Number.parseInt(kvmMatch[1] || "1", 10) : 1;
|
||||
|
||||
const descriptions = {
|
||||
1: "Perfect for small projects and personal websites",
|
||||
2: "Most popular plan for growing businesses",
|
||||
4: "High performance for demanding applications",
|
||||
8: "Maximum power for resource-intensive workloads",
|
||||
};
|
||||
|
||||
return (
|
||||
descriptions[kvmNumber as keyof typeof descriptions] || descriptions[1]
|
||||
);
|
||||
}
|
||||
|
||||
// Format billing period
|
||||
function formatBillingPeriod(period: number, unit: string): string {
|
||||
if (unit === "month") {
|
||||
return period === 1 ? "Monthly" : `${period} months`;
|
||||
}
|
||||
if (unit === "year") {
|
||||
return period === 1 ? "Yearly" : `${period} years`;
|
||||
}
|
||||
return `${period} ${unit}`;
|
||||
}
|
||||
|
||||
// Calculate yearly savings
|
||||
function calculateSavings(monthlyPrice: number, yearlyPrice: number): number {
|
||||
return monthlyPrice * 12 - yearlyPrice;
|
||||
}
|
||||
|
||||
export const ShowHostingerServers = () => {
|
||||
const { data: vpsPlans, isLoading } = api.hostinger.vpsPlans.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5" />
|
||||
Hostinger VPS Plans
|
||||
<Badge variant="outline" className="text-xs text-muted-foreground">
|
||||
Sorted by price
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
VPS plans with real pricing from Hostinger API
|
||||
<br />
|
||||
<span className="text-xs text-orange-600">
|
||||
💡 Promotional pricing applies to first billing period only
|
||||
</span>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{vpsPlans
|
||||
?.sort((a, b) => {
|
||||
// Sort by monthly promotional price (first_period_price)
|
||||
const monthlyPriceA =
|
||||
a.prices.find(
|
||||
(p) => p.period === 1 && p.period_unit === "month",
|
||||
)?.first_period_price || 0;
|
||||
const monthlyPriceB =
|
||||
b.prices.find(
|
||||
(p) => p.period === 1 && p.period_unit === "month",
|
||||
)?.first_period_price || 0;
|
||||
return monthlyPriceA - monthlyPriceB;
|
||||
})
|
||||
?.map((plan) => {
|
||||
const specs = getVPSSpecs(plan.name);
|
||||
const description = getPlanDescription(plan.name);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="border-2 hover:border-purple-300 transition-colors relative"
|
||||
>
|
||||
{plan.name === "KVM 2" && (
|
||||
<div className="absolute -top-2 -right-2 bg-green-500 text-white px-2 py-1 rounded-full text-xs font-semibold">
|
||||
MOST POPULAR
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-lg">{plan.name}</CardTitle>
|
||||
<Badge variant="secondary">VPS</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-sm">
|
||||
{description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
<div className="space-y-4">
|
||||
{/* Specs */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<Cpu className="h-4 w-4 text-blue-500" />
|
||||
<span>{specs.cpu} vCPU</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<MemoryStick className="h-4 w-4 text-green-500" />
|
||||
<span>{specs.ram}GB RAM</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<HardDrive className="h-4 w-4 text-orange-500" />
|
||||
<span>{specs.storage}GB NVMe</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Globe className="h-4 w-4 text-indigo-500" />
|
||||
<span>{specs.bandwidth}TB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pricing Options */}
|
||||
<div className="border-t pt-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
<h4 className="font-medium text-sm">
|
||||
Billing options:
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="monthly" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="monthly" className="text-xs">
|
||||
Monthly
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="yearly" className="text-xs">
|
||||
Yearly
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="biennial" className="text-xs">
|
||||
2 Years
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Monthly prices */}
|
||||
<TabsContent value="monthly" className="mt-2">
|
||||
{plan.prices
|
||||
.filter(
|
||||
(p) =>
|
||||
p.period === 1 && p.period_unit === "month",
|
||||
)
|
||||
.map((price) => (
|
||||
<div
|
||||
key={price.id}
|
||||
className="p-2 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-gray-500" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{formatBillingPeriod(
|
||||
price.period,
|
||||
price.period_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-green-600">
|
||||
$
|
||||
{(
|
||||
price.first_period_price / 100
|
||||
).toFixed(2)}
|
||||
/mo
|
||||
</div>
|
||||
<div className="text-xs line-through text-gray-500">
|
||||
${(price.price / 100).toFixed(2)}/mo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
ID: {price.id}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</TabsContent>
|
||||
|
||||
{/* Yearly prices */}
|
||||
<TabsContent value="yearly" className="mt-2">
|
||||
{plan.prices
|
||||
.filter(
|
||||
(p) =>
|
||||
p.period === 1 && p.period_unit === "year",
|
||||
)
|
||||
.map((price) => {
|
||||
const monthlyEquivalent = plan.prices.find(
|
||||
(p) =>
|
||||
p.period === 1 &&
|
||||
p.period_unit === "month",
|
||||
);
|
||||
const savings = monthlyEquivalent
|
||||
? calculateSavings(
|
||||
monthlyEquivalent.price / 100,
|
||||
price.first_period_price / 100,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={price.id}
|
||||
className="p-2 bg-blue-50 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-blue-500" />
|
||||
<span className="text-sm text-blue-600">
|
||||
{formatBillingPeriod(
|
||||
price.period,
|
||||
price.period_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-blue-600">
|
||||
$
|
||||
{(
|
||||
price.first_period_price / 100
|
||||
).toFixed(2)}
|
||||
/year
|
||||
</div>
|
||||
<div className="text-xs line-through text-gray-500">
|
||||
${(price.price / 100).toFixed(2)}
|
||||
/year
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{savings > 0 && (
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
💰 Savings: ${savings.toFixed(2)} vs
|
||||
monthly
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
ID: {price.id}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
|
||||
{/* Biennial prices */}
|
||||
<TabsContent value="biennial" className="mt-2">
|
||||
{plan.prices
|
||||
.filter(
|
||||
(p) =>
|
||||
p.period === 2 && p.period_unit === "year",
|
||||
)
|
||||
.map((price) => {
|
||||
const monthlyEquivalent = plan.prices.find(
|
||||
(p) =>
|
||||
p.period === 1 &&
|
||||
p.period_unit === "month",
|
||||
);
|
||||
const savings = monthlyEquivalent
|
||||
? calculateSavings(
|
||||
(monthlyEquivalent.price / 100) * 24,
|
||||
price.first_period_price / 100,
|
||||
)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={price.id}
|
||||
className="p-2 bg-purple-50 rounded-lg"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3 text-purple-500" />
|
||||
<span className="text-sm text-purple-600">
|
||||
{formatBillingPeriod(
|
||||
price.period,
|
||||
price.period_unit,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-bold text-purple-600">
|
||||
$
|
||||
{(
|
||||
price.first_period_price / 100
|
||||
).toFixed(2)}
|
||||
/2 years
|
||||
</div>
|
||||
<div className="text-xs line-through text-gray-500">
|
||||
${(price.price / 100).toFixed(2)}/2
|
||||
years
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{savings > 0 && (
|
||||
<div className="text-xs text-green-600 mt-1">
|
||||
💰 Savings: ${savings.toFixed(2)} vs
|
||||
monthly
|
||||
</div>
|
||||
)}
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
ID: {price.id}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="border-t pt-3">
|
||||
<h4 className="font-medium text-sm mb-2">
|
||||
Included features:
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-1">
|
||||
{[
|
||||
"NVMe SSD storage",
|
||||
"AMD EPYC processors",
|
||||
"1000 Mb/s network",
|
||||
"Free weekly backups",
|
||||
"DDoS protection",
|
||||
"Kodee AI assistant",
|
||||
].map((feature, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 text-xs"
|
||||
>
|
||||
<div className="w-1 h-1 bg-green-500 rounded-full" />
|
||||
<span className="text-muted-foreground">
|
||||
{feature}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locations */}
|
||||
<div className="border-t pt-3">
|
||||
<h4 className="font-medium text-sm mb-2">
|
||||
Available locations:
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{["US", "UK", "NL", "LT", "SG", "BR", "IN"].map(
|
||||
(location, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{location}
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{(!vpsPlans || vpsPlans.length === 0) && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Could not load VPS plans. Please verify your Hostinger API key.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-blue-600 mt-0.5">ℹ️</div>
|
||||
<div className="text-sm text-blue-800">
|
||||
<strong>For resellers:</strong> These are real prices from
|
||||
Hostinger API. Promotional pricing applies to the first billing
|
||||
period only. You can use the shown IDs to create servers via
|
||||
API.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { ShowHetznerProviders } from "./show-hetzner-providers";
|
||||
import { ShowHostingerServers } from "./show-hostinger-servers";
|
||||
import { DollarSign } from "lucide-react";
|
||||
|
||||
export const ShowProviders = () => {
|
||||
return (
|
||||
<Card className="w-full bg-transparent ">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<DollarSign className="h-6 w-6 text-green-600" />
|
||||
Servers
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage and view available server types from Hetzner and Hostinger for
|
||||
your business. Here you can see updated pricing and specifications for
|
||||
each plan.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="hetzner" className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="hetzner" className="flex items-center gap-2">
|
||||
🇩🇪 Hetzner Cloud
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="hostinger" className="flex items-center gap-2">
|
||||
🌍 Hostinger VPS
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="hetzner" className="mt-4">
|
||||
<ShowHetznerProviders />
|
||||
</TabsContent>
|
||||
<TabsContent value="hostinger" className="mt-4">
|
||||
<ShowHostingerServers />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -38,6 +38,8 @@ import { stripeRouter } from "./routers/stripe";
|
||||
import { swarmRouter } from "./routers/swarm";
|
||||
import { userRouter } from "./routers/user";
|
||||
import { volumeBackupsRouter } from "./routers/volume-backups";
|
||||
import { hetznerRouter } from "./routers/hetzner";
|
||||
import { hostingerRouter } from "./routers/hostinger";
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
*
|
||||
@@ -84,6 +86,8 @@ export const appRouter = createTRPCRouter({
|
||||
schedule: scheduleRouter,
|
||||
rollback: rollbackRouter,
|
||||
volumeBackups: volumeBackupsRouter,
|
||||
hetzner: hetznerRouter,
|
||||
hostinger: hostingerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
144
apps/dokploy/server/api/routers/hetzner.ts
Normal file
144
apps/dokploy/server/api/routers/hetzner.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
const HETZNER_API_URL = "https://api.hetzner.cloud/v1";
|
||||
|
||||
interface HetznerLocation {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
country: string;
|
||||
city: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
network_zone: string;
|
||||
}
|
||||
|
||||
interface HetznerServerType {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
cores: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
prices: {
|
||||
location: string;
|
||||
price_hourly: {
|
||||
net: string;
|
||||
gross: string;
|
||||
};
|
||||
price_monthly: {
|
||||
net: string;
|
||||
gross: string;
|
||||
};
|
||||
}[];
|
||||
storage_type: string;
|
||||
cpu_type: string;
|
||||
architecture: string;
|
||||
}
|
||||
|
||||
interface HetznerServer {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
created: string;
|
||||
server_type: {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
cores: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
};
|
||||
public_net: {
|
||||
ipv4: {
|
||||
ip: string;
|
||||
};
|
||||
ipv6: {
|
||||
ip: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchHetznerLocations(
|
||||
apiKey: string,
|
||||
): Promise<HetznerLocation[]> {
|
||||
const response = await fetch(`${HETZNER_API_URL}/locations`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Hetzner locations: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { locations?: HetznerLocation[] };
|
||||
return data.locations || [];
|
||||
}
|
||||
|
||||
async function fetchHetznerServerTypes(
|
||||
apiKey: string,
|
||||
): Promise<HetznerServerType[]> {
|
||||
const response = await fetch(`${HETZNER_API_URL}/server_types`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Hetzner server types: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
server_types?: HetznerServerType[];
|
||||
};
|
||||
return data.server_types || [];
|
||||
}
|
||||
|
||||
async function fetchHetznerServers(apiKey: string): Promise<HetznerServer[]> {
|
||||
const response = await fetch(`${HETZNER_API_URL}/servers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch Hetzner servers: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { servers?: HetznerServer[] };
|
||||
return data.servers || [];
|
||||
}
|
||||
|
||||
export const hetznerRouter = createTRPCRouter({
|
||||
locations: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HETZNER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hetzner API key not configured");
|
||||
}
|
||||
return await fetchHetznerLocations(apiKey);
|
||||
}),
|
||||
|
||||
serverTypes: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HETZNER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hetzner API key not configured");
|
||||
}
|
||||
return await fetchHetznerServerTypes(apiKey);
|
||||
}),
|
||||
|
||||
servers: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HETZNER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hetzner API key not configured");
|
||||
}
|
||||
return await fetchHetznerServers(apiKey);
|
||||
}),
|
||||
});
|
||||
299
apps/dokploy/server/api/routers/hostinger.ts
Normal file
299
apps/dokploy/server/api/routers/hostinger.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { z } from "zod";
|
||||
|
||||
const HOSTINGER_API_URL = "https://api.hostinger.com";
|
||||
|
||||
interface HostingerCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
prices: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
price: number; // en centavos
|
||||
first_period_price: number; // precio promocional en centavos
|
||||
period: number;
|
||||
period_unit: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface HostingerServer {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
ip_address: string;
|
||||
plan: {
|
||||
name: string;
|
||||
cpu: number;
|
||||
ram: number;
|
||||
storage: number;
|
||||
};
|
||||
location: string;
|
||||
}
|
||||
|
||||
interface HostingerTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
documentation?: string;
|
||||
}
|
||||
|
||||
interface HostingerDataCenter {
|
||||
id: number;
|
||||
name: string;
|
||||
location: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface HostingerVMCreateResponse {
|
||||
order: {
|
||||
id: number;
|
||||
subscription_id: string;
|
||||
status: string;
|
||||
currency: string;
|
||||
subtotal: number;
|
||||
total: number;
|
||||
};
|
||||
virtual_machine: {
|
||||
id: number;
|
||||
subscription_id: string;
|
||||
plan: string;
|
||||
hostname: string;
|
||||
state: string;
|
||||
cpus: number;
|
||||
memory: number;
|
||||
disk: number;
|
||||
bandwidth: number;
|
||||
ipv4: Array<{
|
||||
id: number;
|
||||
address: string;
|
||||
ptr: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Obtener catalog items (productos VPS con precios reales)
|
||||
async function fetchHostingerCatalog(
|
||||
apiKey: string,
|
||||
): Promise<HostingerCatalogItem[]> {
|
||||
const response = await fetch(
|
||||
`${HOSTINGER_API_URL}/api/billing/v1/catalog?category=VPS`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Hostinger catalog: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as HostingerCatalogItem[];
|
||||
return data || [];
|
||||
}
|
||||
|
||||
// Obtener VPS existentes
|
||||
async function fetchHostingerServers(
|
||||
apiKey: string,
|
||||
): Promise<HostingerServer[]> {
|
||||
const response = await fetch(
|
||||
`${HOSTINGER_API_URL}/api/vps/v1/virtual-machines`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// Si no hay servidores o falla, retornamos array vacío
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data?: HostingerServer[] };
|
||||
return data.data || [];
|
||||
}
|
||||
|
||||
// Obtener templates (sistemas operativos) disponibles
|
||||
async function fetchHostingerTemplates(
|
||||
apiKey: string,
|
||||
): Promise<HostingerTemplate[]> {
|
||||
const response = await fetch(`${HOSTINGER_API_URL}/api/vps/v1/templates`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Hostinger templates: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data?: HostingerTemplate[] };
|
||||
return data.data || [];
|
||||
}
|
||||
|
||||
// Obtener data centers disponibles
|
||||
async function fetchHostingerDataCenters(
|
||||
apiKey: string,
|
||||
): Promise<HostingerDataCenter[]> {
|
||||
const response = await fetch(`${HOSTINGER_API_URL}/api/vps/v1/data-centers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Hostinger data centers: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { data?: HostingerDataCenter[] };
|
||||
return data.data || [];
|
||||
}
|
||||
|
||||
// Crear nuevo VPS
|
||||
async function createHostingerVPS(
|
||||
apiKey: string,
|
||||
params: {
|
||||
item_id: string;
|
||||
template_id: number;
|
||||
data_center_id: number;
|
||||
hostname?: string;
|
||||
password?: string;
|
||||
enable_backups?: boolean;
|
||||
public_key?: {
|
||||
name: string;
|
||||
key: string;
|
||||
};
|
||||
},
|
||||
): Promise<HostingerVMCreateResponse> {
|
||||
const response = await fetch(
|
||||
`${HOSTINGER_API_URL}/api/vps/v1/virtual-machines`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
item_id: params.item_id,
|
||||
setup: {
|
||||
template_id: params.template_id,
|
||||
data_center_id: params.data_center_id,
|
||||
hostname: params.hostname || `vps-${Date.now()}.hstgr.cloud`,
|
||||
password: params.password || generateRandomPassword(),
|
||||
enable_backups: params.enable_backups ?? true,
|
||||
install_monarx: false,
|
||||
...(params.public_key && { public_key: params.public_key }),
|
||||
},
|
||||
coupons: [],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(
|
||||
`Failed to create Hostinger VPS: ${response.statusText} - ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
return (await response.json()) as HostingerVMCreateResponse;
|
||||
}
|
||||
|
||||
// Generar contraseña aleatoria
|
||||
function generateRandomPassword(): string {
|
||||
const chars =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*";
|
||||
let password = "";
|
||||
for (let i = 0; i < 16; i++) {
|
||||
password += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return password;
|
||||
}
|
||||
|
||||
export const hostingerRouter = createTRPCRouter({
|
||||
vpsPlans: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hostinger API key not configured");
|
||||
}
|
||||
|
||||
const catalogItems = await fetchHostingerCatalog(apiKey);
|
||||
return catalogItems.filter((item) => item.name.startsWith("KVM"));
|
||||
}),
|
||||
|
||||
servers: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) {
|
||||
return [];
|
||||
}
|
||||
return await fetchHostingerServers(apiKey);
|
||||
}),
|
||||
|
||||
templates: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hostinger API key not configured");
|
||||
}
|
||||
return await fetchHostingerTemplates(apiKey);
|
||||
}),
|
||||
|
||||
dataCenters: protectedProcedure.query(async () => {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hostinger API key not configured");
|
||||
}
|
||||
return await fetchHostingerDataCenters(apiKey);
|
||||
}),
|
||||
|
||||
createVPS: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
item_id: z.string(),
|
||||
template_id: z.number(),
|
||||
data_center_id: z.number(),
|
||||
hostname: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
enable_backups: z.boolean().optional(),
|
||||
ssh_key_name: z.string().optional(),
|
||||
ssh_key_content: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input }) => {
|
||||
const apiKey = process.env.HOSTINGER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error("Hostinger API key not configured");
|
||||
}
|
||||
|
||||
const publicKey =
|
||||
input.ssh_key_name && input.ssh_key_content
|
||||
? {
|
||||
name: input.ssh_key_name,
|
||||
key: input.ssh_key_content,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return await createHostingerVPS(apiKey, {
|
||||
item_id: input.item_id,
|
||||
template_id: input.template_id,
|
||||
data_center_id: input.data_center_id,
|
||||
hostname: input.hostname,
|
||||
password: input.password,
|
||||
enable_backups: input.enable_backups,
|
||||
public_key: publicKey,
|
||||
});
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user