mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-06-28 02:25:23 +02:00
Compare commits
6 Commits
fix/env-ed
...
feature/ma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
549b124fcd | ||
|
|
0c5da0b36f | ||
|
|
ee18724dd7 | ||
|
|
1ae9b4025c | ||
|
|
5bc870dc2d | ||
|
|
299950a323 |
8403
api-1.json
Normal file
8403
api-1.json
Normal file
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,41 +0,0 @@
|
|||||||
import { shouldDeploy } from "@dokploy/server";
|
|
||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
describe("shouldDeploy", () => {
|
|
||||||
it("should deploy when no watch paths are configured", () => {
|
|
||||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should deploy when watch paths match modified files", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
|
||||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not deploy when watch paths do not match", () => {
|
|
||||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files contain non-string values", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(
|
|
||||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when modified files are undefined or null", () => {
|
|
||||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
|
||||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
|
||||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not throw when every modified file is non-string", () => {
|
|
||||||
expect(() =>
|
|
||||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
|
||||||
).not.toThrow();
|
|
||||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -78,20 +78,4 @@ describe("readValidDirectory (path traversal)", () => {
|
|||||||
it("returns false for empty string (resolves to cwd)", () => {
|
it("returns false for empty string (resolves to cwd)", () => {
|
||||||
expect(readValidDirectory("")).toBe(false);
|
expect(readValidDirectory("")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns true for Next.js dynamic route paths with square brackets", () => {
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
readValidDirectory(
|
|
||||||
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -9,6 +9,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";
|
||||||
@@ -82,6 +83,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,493 @@
|
|||||||
|
import {
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
|
CreditCard,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
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, memoryMb: number, diskMb: number, bandwidthMb: number) {
|
||||||
|
const bandwidthTb = bandwidthMb / 1024 / 1024;
|
||||||
|
const bandwidthLabel = bandwidthTb >= 1 ? `${bandwidthTb.toFixed(0)} TB` : `${Math.round(bandwidthMb / 1024)} GB`;
|
||||||
|
return `${cpus} vCPU · ${Math.round(memoryMb / 1024)} GB RAM · ${Math.round(diskMb / 1024)} GB NVMe · ${bandwidthLabel} bandwidth`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centsToDisplay(cents: number) {
|
||||||
|
return (cents / 100).toFixed(2).replace(/\.00$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrderServerDialog({ onSuccess }: { onSuccess: () => void }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<string>("");
|
||||||
|
const [selectedDc, setSelectedDc] = useState<string>("");
|
||||||
|
const [isAnnual, setIsAnnual] = useState(false);
|
||||||
|
|
||||||
|
const { data: plans, isLoading: loadingPlans } =
|
||||||
|
api.managedServer.getPlans.useQuery(undefined, { enabled: open });
|
||||||
|
const { data: dataCenters, isLoading: loadingDcs } =
|
||||||
|
api.managedServer.getDataCenters.useQuery(undefined, { enabled: open });
|
||||||
|
|
||||||
|
const isLoadingOptions = loadingPlans || loadingDcs;
|
||||||
|
|
||||||
|
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 displayPrice = (p: NonNullable<typeof plan>) =>
|
||||||
|
isAnnual
|
||||||
|
? `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr`
|
||||||
|
: `$${centsToDisplay(p.dokployPriceCentsMonthly)}/mo`;
|
||||||
|
|
||||||
|
const displayPriceSmall = (p: NonNullable<typeof plan>) =>
|
||||||
|
isAnnual
|
||||||
|
? `$${centsToDisplay(Math.round(p.dokployPriceCentsAnnual / 12))}/mo billed annually`
|
||||||
|
: `$${centsToDisplay(p.dokployPriceCentsAnnual)}/yr if annual`;
|
||||||
|
|
||||||
|
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-4">
|
||||||
|
{/* Billing period toggle */}
|
||||||
|
<div className="flex items-center gap-1 rounded-lg border p-1 bg-muted/40 w-fit">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAnnual(false)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors",
|
||||||
|
!isAnnual
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsAnnual(true)}
|
||||||
|
className={cn(
|
||||||
|
"px-4 py-1.5 rounded-md text-sm font-medium transition-colors flex items-center gap-1.5",
|
||||||
|
isAnnual
|
||||||
|
? "bg-background shadow-sm text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Annual
|
||||||
|
<span className="text-xs bg-green-500/15 text-green-600 dark:text-green-400 px-1.5 py-0.5 rounded font-semibold">
|
||||||
|
Save ~20%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Plan selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Plan</Label>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{plans?.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedPlan(p.id)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-between rounded-lg border p-3 text-left transition-colors",
|
||||||
|
selectedPlan === p.id
|
||||||
|
? "border-primary bg-primary/5"
|
||||||
|
: "border-border hover:border-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">{p.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatSpecs(p.cpus, p.memoryMb, p.diskMb, p.bandwidthMb)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="font-semibold text-sm">
|
||||||
|
{displayPrice(p)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{displayPriceSmall(p)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Data center selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Data Center</Label>
|
||||||
|
<Select value={selectedDc} onValueChange={setSelectedDc}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a location..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent position="popper" side="bottom" sideOffset={4} className="max-h-56 overflow-y-auto">
|
||||||
|
{dataCenters?.map((dc) => (
|
||||||
|
<SelectItem key={dc.id} value={String(dc.id)}>
|
||||||
|
{dc.city} — {dc.continent}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{plan && selectedDc && (
|
||||||
|
<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">Billing</span>
|
||||||
|
<span className="font-medium">{isAnnual ? "Annual" : "Monthly"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-muted-foreground">Total</span>
|
||||||
|
<span className="font-semibold">{displayPrice(plan)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
disabled={!selectedPlan || !selectedDc || purchase.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedPlan || !selectedDc) return;
|
||||||
|
purchase.mutate({
|
||||||
|
plan: selectedPlan,
|
||||||
|
dataCenterId: Number(selectedDc),
|
||||||
|
isAnnual,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{purchase.isPending ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||||
|
Placing order...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Order Server"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
const planLabel = s.plan
|
||||||
|
.split("-")
|
||||||
|
.slice(-2)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
|
||||||
|
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">
|
||||||
|
{planLabel}
|
||||||
|
</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}` : ""}
|
||||||
|
</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.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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -167,13 +167,7 @@ export const CodeEditor = ({
|
|||||||
? css()
|
? css()
|
||||||
: language === "shell"
|
: language === "shell"
|
||||||
? StreamLanguage.define(shell)
|
? StreamLanguage.define(shell)
|
||||||
: StreamLanguage.define({
|
: StreamLanguage.define(properties),
|
||||||
...properties,
|
|
||||||
// The legacy properties mode lacks comment metadata, so
|
|
||||||
// CodeMirror's toggle-comment shortcut (Mod-/) has no comment
|
|
||||||
// token to use. Declare `#` as the line comment for env editors.
|
|
||||||
languageData: { commentTokens: { line: "#" } },
|
|
||||||
}),
|
|
||||||
props.lineWrapping ? EditorView.lineWrapping : [],
|
props.lineWrapping ? EditorView.lineWrapping : [],
|
||||||
language === "yaml"
|
language === "yaml"
|
||||||
? autocompletion({
|
? autocompletion({
|
||||||
|
|||||||
22
apps/dokploy/drizzle/0167_dizzy_solo.sql
Normal file
22
apps/dokploy/drizzle/0167_dizzy_solo.sql
Normal file
@@ -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,
|
||||||
|
"hostingerVmId" integer,
|
||||||
|
"hostingerSubscriptionId" text,
|
||||||
|
"dataCenterId" integer NOT NULL,
|
||||||
|
"ipAddress" text,
|
||||||
|
"hostname" text,
|
||||||
|
"stripeSubscriptionId" text,
|
||||||
|
"stripePriceId" text,
|
||||||
|
"rootPassword" 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;
|
||||||
8469
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
8469
apps/dokploy/drizzle/meta/0167_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1170,6 +1170,13 @@
|
|||||||
"when": 1778303519111,
|
"when": 1778303519111,
|
||||||
"tag": "0166_nosy_slapstick",
|
"tag": "0166_nosy_slapstick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 167,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778657133470,
|
||||||
|
"tag": "0167_dizzy_solo",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
39
apps/dokploy/pages/dashboard/settings/managed-servers.tsx
Normal file
39
apps/dokploy/pages/dashboard/settings/managed-servers.tsx
Normal file
@@ -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: {} };
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import { projectRouter } from "./routers/project";
|
|||||||
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
import { auditLogRouter } from "./routers/proprietary/audit-log";
|
||||||
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
import { customRoleRouter } from "./routers/proprietary/custom-role";
|
||||||
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
import { licenseKeyRouter } from "./routers/proprietary/license-key";
|
||||||
|
import { managedServerRouter } from "./routers/proprietary/managed-server";
|
||||||
import { ssoRouter } from "./routers/proprietary/sso";
|
import { ssoRouter } from "./routers/proprietary/sso";
|
||||||
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
import { whitelabelingRouter } from "./routers/proprietary/whitelabeling";
|
||||||
import { redirectsRouter } from "./routers/redirects";
|
import { redirectsRouter } from "./routers/redirects";
|
||||||
@@ -102,6 +103,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
environment: environmentRouter,
|
environment: environmentRouter,
|
||||||
tag: tagRouter,
|
tag: tagRouter,
|
||||||
patch: patchRouter,
|
patch: patchRouter,
|
||||||
|
managedServer: managedServerRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
247
apps/dokploy/server/api/routers/proprietary/managed-server.ts
Normal file
247
apps/dokploy/server/api/routers/proprietary/managed-server.ts
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
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 {
|
||||||
|
getHostingerDataCenters,
|
||||||
|
getHostingerVm,
|
||||||
|
getManagedServerPlans,
|
||||||
|
purchaseHostingerVps,
|
||||||
|
stopHostingerVm,
|
||||||
|
UBUNTU_22_TEMPLATE_ID,
|
||||||
|
} from "@dokploy/server/utils/hostinger";
|
||||||
|
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 getManagedServerPlans();
|
||||||
|
}),
|
||||||
|
|
||||||
|
getDataCenters: adminProcedure.query(async () => {
|
||||||
|
if (!IS_CLOUD) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Managed servers are only available in Dokploy Cloud",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return getHostingerDataCenters();
|
||||||
|
}),
|
||||||
|
|
||||||
|
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 plans = await getManagedServerPlans();
|
||||||
|
const plan = 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,
|
||||||
|
dataCenterId: input.dataCenterId,
|
||||||
|
status: "provisioning",
|
||||||
|
});
|
||||||
|
|
||||||
|
const hostingerItemId = input.isAnnual
|
||||||
|
? plan.hostingerItemIdAnnual
|
||||||
|
: plan.hostingerItemIdMonthly;
|
||||||
|
|
||||||
|
provisionManagedServer(
|
||||||
|
managedRecord.managedServerId,
|
||||||
|
hostingerItemId,
|
||||||
|
input.dataCenterId,
|
||||||
|
hostname,
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
).catch(async (err) => {
|
||||||
|
await updateManagedServer(managedRecord.managedServerId, {
|
||||||
|
status: "error",
|
||||||
|
errorMessage: err?.message ?? "Unknown error during provisioning",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record.hostingerVmId) {
|
||||||
|
try {
|
||||||
|
await stopHostingerVm(record.hostingerVmId);
|
||||||
|
} catch (_) {
|
||||||
|
// Best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await deleteManagedServer(input.managedServerId);
|
||||||
|
return { ok: true };
|
||||||
|
}),
|
||||||
|
|
||||||
|
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" });
|
||||||
|
}
|
||||||
|
if (!record.hostingerVmId) return record;
|
||||||
|
|
||||||
|
const vm = await getHostingerVm(record.hostingerVmId);
|
||||||
|
const ipAddress = vm.ipv4?.[0]?.address ?? record.ipAddress;
|
||||||
|
|
||||||
|
await updateManagedServer(input.managedServerId, {
|
||||||
|
ipAddress: ipAddress ?? undefined,
|
||||||
|
hostname: vm.hostname ?? undefined,
|
||||||
|
status:
|
||||||
|
vm.state === "running"
|
||||||
|
? record.serverId
|
||||||
|
? "ready"
|
||||||
|
: "configuring"
|
||||||
|
: record.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
return findManagedServerById(input.managedServerId);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function provisionManagedServer(
|
||||||
|
managedServerId: string,
|
||||||
|
hostingerItemId: string,
|
||||||
|
dataCenterId: number,
|
||||||
|
hostname: string,
|
||||||
|
organizationId: string,
|
||||||
|
) {
|
||||||
|
const result = await purchaseHostingerVps({
|
||||||
|
item_id: hostingerItemId,
|
||||||
|
payment_method_id: 0,
|
||||||
|
setup: {
|
||||||
|
template_id: UBUNTU_22_TEMPLATE_ID,
|
||||||
|
data_center_id: dataCenterId,
|
||||||
|
hostname,
|
||||||
|
enable_backups: false,
|
||||||
|
},
|
||||||
|
coupons: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const vm = result.virtual_machine;
|
||||||
|
|
||||||
|
await updateManagedServer(managedServerId, {
|
||||||
|
hostingerVmId: vm.id,
|
||||||
|
hostingerSubscriptionId: vm.subscription_id ?? undefined,
|
||||||
|
ipAddress: vm.ipv4?.[0]?.address ?? undefined,
|
||||||
|
hostname: vm.hostname ?? undefined,
|
||||||
|
status: "configuring",
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitForVmRunning(vm.id!, managedServerId);
|
||||||
|
|
||||||
|
const finalVm = await getHostingerVm(vm.id!);
|
||||||
|
const finalIp = finalVm.ipv4?.[0]?.address;
|
||||||
|
|
||||||
|
if (!finalIp) {
|
||||||
|
throw new Error("VM is running but has no IPv4 address");
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverRecord = await createServer(
|
||||||
|
{
|
||||||
|
name: `Managed • ${hostname}`,
|
||||||
|
description: "Managed server provisioned by Dokploy Cloud",
|
||||||
|
ipAddress: finalIp,
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
serverType: "deploy",
|
||||||
|
},
|
||||||
|
organizationId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await updateManagedServer(managedServerId, {
|
||||||
|
serverId: serverRecord.serverId,
|
||||||
|
ipAddress: finalIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
await serverSetup(serverRecord.serverId);
|
||||||
|
|
||||||
|
await updateManagedServer(managedServerId, { status: "ready" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForVmRunning(
|
||||||
|
vmId: number,
|
||||||
|
_managedServerId: string,
|
||||||
|
maxAttempts = 30,
|
||||||
|
intervalMs = 10_000,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < maxAttempts; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, intervalMs));
|
||||||
|
const vm = await getHostingerVm(vmId);
|
||||||
|
if (vm.state === "running") return;
|
||||||
|
if (vm.state === "error") {
|
||||||
|
throw new Error("VM entered error state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Timed out waiting for VM to become running");
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
"drizzle-dbml-generator": "0.10.0",
|
"drizzle-dbml-generator": "0.10.0",
|
||||||
"drizzle-orm": "0.45.1",
|
"drizzle-orm": "0.45.1",
|
||||||
"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.11",
|
"nanoid": "3.3.11",
|
||||||
|
|||||||
@@ -15,6 +15,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";
|
||||||
|
|||||||
72
packages/server/src/db/schema/managed-server.ts
Normal file
72
packages/server/src/db/schema/managed-server.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { relations } from "drizzle-orm";
|
||||||
|
import { integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { organization } from "./account";
|
||||||
|
import { server } from "./server";
|
||||||
|
|
||||||
|
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",
|
||||||
|
}),
|
||||||
|
/** Hostinger catalog item id, e.g. "hostingercom-vps-kvm2" */
|
||||||
|
plan: text("plan").notNull(),
|
||||||
|
status: managedServerStatus("status").notNull().default("pending"),
|
||||||
|
hostingerVmId: integer("hostingerVmId"),
|
||||||
|
hostingerSubscriptionId: text("hostingerSubscriptionId"),
|
||||||
|
dataCenterId: integer("dataCenterId").notNull(),
|
||||||
|
ipAddress: text("ipAddress"),
|
||||||
|
hostname: text("hostname"),
|
||||||
|
stripeSubscriptionId: text("stripeSubscriptionId"),
|
||||||
|
stripePriceId: text("stripePriceId"),
|
||||||
|
rootPassword: text("rootPassword"),
|
||||||
|
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),
|
||||||
|
dataCenterId: z.number().int().positive(),
|
||||||
|
isAnnual: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiFindOneManagedServer = z.object({
|
||||||
|
managedServerId: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const apiDeleteManagedServer = z.object({
|
||||||
|
managedServerId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -43,6 +43,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/settings";
|
export * from "./services/settings";
|
||||||
export * from "./services/ssh-key";
|
export * from "./services/ssh-key";
|
||||||
|
|||||||
54
packages/server/src/services/managed-server.ts
Normal file
54
packages/server/src/services/managed-server.ts
Normal file
@@ -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));
|
||||||
|
};
|
||||||
164
packages/server/src/utils/hostinger.ts
Normal file
164
packages/server/src/utils/hostinger.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import {
|
||||||
|
BillingCatalogApi,
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function purchaseHostingerVps(
|
||||||
|
body: import("hostinger-api-sdk").VPSV1VirtualMachinePurchaseRequest,
|
||||||
|
) {
|
||||||
|
const api = getVmApi();
|
||||||
|
const res = await api.purchaseNewVirtualMachineV1(body);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHostingerVm(vmId: number) {
|
||||||
|
const api = getVmApi();
|
||||||
|
const res = await api.getVirtualMachineDetailsV1(vmId);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopHostingerVm(vmId: number) {
|
||||||
|
const api = getVmApi();
|
||||||
|
await api.stopVirtualMachineV1(vmId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
@@ -2,11 +2,8 @@ import micromatch from "micromatch";
|
|||||||
|
|
||||||
export const shouldDeploy = (
|
export const shouldDeploy = (
|
||||||
watchPaths: string[] | null,
|
watchPaths: string[] | null,
|
||||||
modifiedFiles: (string | null | undefined)[] | null | undefined,
|
modifiedFiles: string[],
|
||||||
): boolean => {
|
): boolean => {
|
||||||
if (!watchPaths || watchPaths?.length === 0) return true;
|
if (!watchPaths || watchPaths?.length === 0) return true;
|
||||||
const files = (modifiedFiles ?? []).filter(
|
return micromatch.some(modifiedFiles, watchPaths);
|
||||||
(file): file is string => typeof file === "string",
|
|
||||||
);
|
|
||||||
return micromatch.some(files, watchPaths);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export const readValidDirectory = (
|
|||||||
directory: string,
|
directory: string,
|
||||||
serverId?: string | null,
|
serverId?: string | null,
|
||||||
) => {
|
) => {
|
||||||
if (!/^[\w/. :[\]-]{1,500}$/.test(directory)) {
|
if (!/^[\w/. :-]{1,500}$/.test(directory)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
pnpm-lock.yaml
generated
28
pnpm-lock.yaml
generated
@@ -539,10 +539,10 @@ importers:
|
|||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
vite-tsconfig-paths:
|
vite-tsconfig-paths:
|
||||||
specifier: 4.3.2
|
specifier: 4.3.2
|
||||||
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1))
|
version: 4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^4.0.18
|
specifier: ^4.0.18
|
||||||
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
|
|
||||||
apps/schedules:
|
apps/schedules:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -700,6 +700,9 @@ importers:
|
|||||||
drizzle-zod:
|
drizzle-zod:
|
||||||
specifier: 0.5.1
|
specifier: 0.5.1
|
||||||
version: 0.5.1(drizzle-orm@0.45.1(@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@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(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@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(zod@4.3.6)
|
version: 0.5.1(drizzle-orm@0.45.1(@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@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.11)(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@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)))(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
|
||||||
@@ -5766,6 +5769,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'}
|
||||||
@@ -12321,6 +12327,7 @@ snapshots:
|
|||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
|
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1))':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12329,7 +12336,6 @@ snapshots:
|
|||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
optional: true
|
|
||||||
|
|
||||||
'@vitest/pretty-format@4.0.18':
|
'@vitest/pretty-format@4.0.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -12560,7 +12566,7 @@ snapshots:
|
|||||||
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
|
prisma: 7.4.1(@types/react@18.3.5)(better-sqlite3@12.6.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.9.3)
|
||||||
react: 18.2.0
|
react: 18.2.0
|
||||||
react-dom: 18.2.0(react@18.2.0)
|
react-dom: 18.2.0(react@18.2.0)
|
||||||
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
|
|
||||||
better-auth@1.5.4(48b68ecaf84f5e14652b8d87fbbd7ca9):
|
better-auth@1.5.4(48b68ecaf84f5e14652b8d87fbbd7ca9):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13784,6 +13790,12 @@ snapshots:
|
|||||||
|
|
||||||
hono@4.12.2: {}
|
hono@4.12.2: {}
|
||||||
|
|
||||||
|
hostinger-api-sdk@0.0.17:
|
||||||
|
dependencies:
|
||||||
|
axios: 1.13.5
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- debug
|
||||||
|
|
||||||
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
|
||||||
@@ -16384,13 +16396,13 @@ snapshots:
|
|||||||
d3-time: 3.1.0
|
d3-time: 3.1.0
|
||||||
d3-timer: 3.0.1
|
d3-timer: 3.0.1
|
||||||
|
|
||||||
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)):
|
vite-tsconfig-paths@4.3.2(typescript@5.9.3)(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
globrex: 0.1.2
|
globrex: 0.1.2
|
||||||
tsconfck: 3.1.6(typescript@5.9.3)
|
tsconfck: 3.1.6(typescript@5.9.3)
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
vite: 7.3.1(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1)
|
vite: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
- typescript
|
- typescript
|
||||||
@@ -16409,6 +16421,7 @@ snapshots:
|
|||||||
jiti: 1.21.7
|
jiti: 1.21.7
|
||||||
tsx: 4.16.2
|
tsx: 4.16.2
|
||||||
yaml: 2.8.1
|
yaml: 2.8.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16424,7 +16437,6 @@ snapshots:
|
|||||||
jiti: 2.6.1
|
jiti: 2.6.1
|
||||||
tsx: 4.16.2
|
tsx: 4.16.2
|
||||||
yaml: 2.8.1
|
yaml: 2.8.1
|
||||||
optional: true
|
|
||||||
|
|
||||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
|
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@1.21.7)(tsx@4.16.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16463,6 +16475,7 @@ snapshots:
|
|||||||
- terser
|
- terser
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
|
optional: true
|
||||||
|
|
||||||
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(jiti@2.6.1)(tsx@4.16.2)(yaml@2.8.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -16501,7 +16514,6 @@ snapshots:
|
|||||||
- terser
|
- terser
|
||||||
- tsx
|
- tsx
|
||||||
- yaml
|
- yaml
|
||||||
optional: true
|
|
||||||
|
|
||||||
w3c-keyname@2.2.8: {}
|
w3c-keyname@2.2.8: {}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user