Merge branch 'canary' into preview-deploy-rebuild

This commit is contained in:
Mauricio Siu
2026-01-12 08:46:19 -06:00
17 changed files with 531 additions and 147 deletions
-1
View File
@@ -13,7 +13,6 @@
"@dokploy/server": "workspace:*", "@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3", "@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0", "@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"hono": "^4.7.10", "hono": "^4.7.10",
"pino": "9.4.0", "pino": "9.4.0",
@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && ( {field.value.gitlabPathNamespace && (
<Link <Link
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`} href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository"); const repository = form.watch("repository");
const gitlabId = form.watch("gitlabId"); const gitlabId = form.watch("gitlabId");
const gitlabUrl = useMemo(() => {
const url = gitlabProviders?.find(
(provider) => provider.gitlabId === gitlabId,
)?.gitlabUrl;
const gitlabUrl = url?.replace(/\/$/, "");
return gitlabUrl || "https://gitlab.com";
}, [gitlabId, gitlabProviders]);
const { const {
data: repositories, data: repositories,
isLoading: isLoadingRepositories, isLoading: isLoadingRepositories,
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col"> <FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel> <FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && ( {field.value.gitlabPathNamespace && (
<Link <Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`} href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary" className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
@@ -0,0 +1,74 @@
import { CreditCard, FileText } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { ShowInvoices } from "./show-invoices";
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBillingInvoices = () => {
const router = useRouter();
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">
<CreditCard className="size-6 text-muted-foreground self-center" />
Billing
</CardTitle>
<CardDescription>
Manage your subscription and invoices
</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">
<ShowInvoices />
</div>
</CardContent>
</div>
</Card>
</div>
);
};
@@ -4,11 +4,13 @@ import {
AlertTriangle, AlertTriangle,
CheckIcon, CheckIcon,
CreditCard, CreditCard,
FileText,
Loader2, Loader2,
MinusIcon, MinusIcon,
PlusIcon, PlusIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
if (count <= 1) return 4.5; if (count <= 1) return 4.5;
return count * 3.5; return count * 3.5;
}; };
const navigationItems = [
{
name: "Subscription",
href: "/dashboard/settings/billing",
icon: CreditCard,
},
{
name: "Invoices",
href: "/dashboard/settings/invoices",
icon: FileText,
},
];
export const ShowBilling = () => { export const ShowBilling = () => {
const router = useRouter();
const { data: servers } = api.server.count.useQuery(); const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery(); const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery(); const { data, isLoading } = api.stripe.getProducts.useQuery();
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
return ( return (
<div className="w-full"> <div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto"> <Card className="bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md "> <div className="rounded-xl bg-background shadow-md">
<CardHeader className=""> <CardHeader>
<CardTitle className="text-xl flex flex-row gap-2"> <CardTitle className="text-xl flex flex-row gap-2">
<CreditCard className="size-6 text-muted-foreground self-center" /> <CreditCard className="size-6 text-muted-foreground self-center" />
Billing Billing
</CardTitle> </CardTitle>
<CardDescription>Manage your subscription</CardDescription> <CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-4 py-4 border-t">
<div className="flex flex-col gap-4 w-full"> <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="flex flex-col gap-4 w-full mt-6">
<Tabs <Tabs
defaultValue="monthly" defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"} value={isAnnual ? "annual" : "monthly"}
@@ -0,0 +1,137 @@
import { Download, ExternalLink, FileText, Loader2 } from "lucide-react";
import type Stripe from "stripe";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const formatDate = (timestamp: number | null) => {
if (!timestamp) return "-";
return new Date(timestamp * 1000).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
const formatAmount = (amount: number, currency: string) => {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
}).format(amount / 100);
};
const getStatusBadge = (status: Stripe.Invoice.Status | null) => {
const statusConfig: Record<
Stripe.Invoice.Status,
{ label: string; variant: "default" | "secondary" | "destructive" }
> = {
paid: { label: "Paid", variant: "default" },
open: { label: "Open", variant: "secondary" },
draft: { label: "Draft", variant: "secondary" },
void: { label: "Void", variant: "destructive" },
uncollectible: { label: "Uncollectible", variant: "destructive" },
};
if (!status) {
return <Badge variant="secondary">Unknown</Badge>;
}
const config = statusConfig[status] || {
label: status,
variant: "secondary" as const,
};
return <Badge variant={config.variant}>{config.label}</Badge>;
};
export const ShowInvoices = () => {
const { data: invoices, isLoading } = api.stripe.getInvoices.useQuery();
return (
<div className="space-y-4">
{isLoading ? (
<div className="flex items-center justify-center min-h-[20vh]">
<span className="text-base text-muted-foreground flex flex-row gap-3 items-center">
Loading invoices...
<Loader2 className="animate-spin" />
</span>
</div>
) : invoices && invoices.length > 0 ? (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Date</TableHead>
<TableHead>Due Date</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map((invoice) => (
<TableRow key={invoice.id}>
<TableCell className="font-medium">
{invoice.number || invoice.id.slice(0, 12)}
</TableCell>
<TableCell>{formatDate(invoice.created)}</TableCell>
<TableCell>{formatDate(invoice.dueDate)}</TableCell>
<TableCell>
{formatAmount(invoice.amountDue, invoice.currency)}
</TableCell>
<TableCell>{getStatusBadge(invoice.status)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
{invoice.hostedInvoiceUrl && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(
invoice.hostedInvoiceUrl || "",
"_blank",
)
}
>
<ExternalLink className="h-4 w-4" />
</Button>
)}
{invoice.invoicePdf && (
<Button
variant="outline"
size="sm"
onClick={() =>
window.open(invoice.invoicePdf || "", "_blank")
}
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="flex flex-col items-center justify-center min-h-[20vh] gap-2">
<FileText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No invoices found</p>
<p className="text-sm text-muted-foreground">
Your invoices will appear here once you have a subscription
</p>
</div>
)}
</div>
);
};
@@ -22,7 +22,6 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
</Button> </Button>
</DialogTrigger> </DialogTrigger>
) : ( ) : (
<DropdownMenuItem <Button
className="w-full cursor-pointer " className="w-full cursor-pointer "
onSelect={(e) => { size="sm"
e.preventDefault(); onClick={() => {
setIsOpen(true); setIsOpen(true);
}} }}
> >
Setup Server Setup Server <Settings className="size-4" />
</DropdownMenuItem> </Button>
)} )}
<DialogContent className="sm:max-w-4xl "> <DialogContent className="sm:max-w-4xl ">
<DialogHeader> <DialogHeader>
@@ -6,9 +6,7 @@ import {
Loader2, Loader2,
MoreHorizontal, MoreHorizontal,
Network, Network,
Pencil,
ServerIcon, ServerIcon,
Settings,
Terminal, Terminal,
Trash2, Trash2,
User, User,
@@ -31,9 +29,7 @@ import {
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
@@ -285,7 +281,32 @@ export const ShowServers = () => {
{/* Compact Actions */} {/* Compact Actions */}
{isActive && ( {isActive && (
<div className="flex items-center gap-2 pt-3 border-t mt-auto"> <div className="flex items-center gap-2 pt-3 border-t mt-auto flex-wrap">
<div className="flex items-center gap-2 w-full">
<Tooltip>
<TooltipTrigger asChild>
<SetupServer
serverId={server.serverId}
/>
</TooltipTrigger>
<TooltipContent
className="max-w-xs"
side="bottom"
>
<div className="space-y-1">
<p className="font-semibold">
Setup Server
</p>
<p className="text-xs text-muted-foreground">
Configure and initialize your
server with Docker, Traefik, and
other essential services
</p>
</div>
</TooltipContent>
</Tooltip>
</div>
<TooltipProvider> <TooltipProvider>
{server.sshKeyId && ( {server.sshKeyId && (
<Tooltip> <Tooltip>
@@ -311,20 +332,6 @@ export const ShowServers = () => {
</Tooltip> </Tooltip>
)} )}
<Tooltip>
<TooltipTrigger asChild>
<div>
<SetupServer
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Setup Server</p>
</TooltipContent>
</Tooltip>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div> <div>
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.26.3", "version": "v0.26.4",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -109,7 +109,6 @@
"drizzle-orm": "^0.39.3", "drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1", "drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3", "fancy-ansi": "^0.1.3",
"hi-base32": "^0.5.1",
"i18next": "^23.16.8", "i18next": "^23.16.8",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
@@ -126,7 +125,6 @@
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
"nodemailer": "6.9.14", "nodemailer": "6.9.14",
"octokit": "3.1.2", "octokit": "3.1.2",
"otpauth": "^9.4.0",
"pino": "9.4.0", "pino": "9.4.0",
"pino-pretty": "11.2.2", "pino-pretty": "11.2.2",
"postgres": "3.4.4", "postgres": "3.4.4",
@@ -155,9 +153,11 @@
"xterm-addon-fit": "^0.8.0", "xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1", "yaml": "2.8.1",
"zod": "^3.25.32", "zod": "^3.25.32",
"zod-form-data": "^2.0.7" "zod-form-data": "^2.0.7",
"semver": "7.7.3"
}, },
"devDependencies": { "devDependencies": {
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5", "@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
@@ -0,0 +1,63 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowBillingInvoices } from "@/components/dashboard/settings/billing/show-billing-invoices";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
const Page = () => {
return <ShowBillingInvoices />;
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Invoices">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (!IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { req, res } = ctx;
const { user, session } = await validateRequest(req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.settings.isCloud.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}
@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
const backup = await findBackupById(input.backupId); const backup = await findBackupById(input.backupId);
await runWebServerBackup(backup); await runWebServerBackup(backup);
await keepLatestNBackups(backup);
return true; return true;
}), }),
listBackupFiles: protectedProcedure listBackupFiles: protectedProcedure
+2 -2
View File
@@ -88,7 +88,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return true;
} }
await reloadDockerResource("dokploy"); await reloadDockerResource("dokploy", undefined, packageInfo.version);
return true; return true;
}), }),
cleanRedis: adminProcedure.mutation(async () => { cleanRedis: adminProcedure.mutation(async () => {
@@ -399,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
return DEFAULT_UPDATE_DATA; return DEFAULT_UPDATE_DATA;
} }
return await getUpdateData(); return await getUpdateData(packageInfo.version);
}), }),
updateServer: adminProcedure.mutation(async () => { updateServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
+36
View File
@@ -81,6 +81,7 @@ export const stripeRouter = createTRPCRouter({
metadata: { metadata: {
adminId: owner.id, adminId: owner.id,
}, },
customer_email: owner.email,
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`, success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`, cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
@@ -128,4 +129,39 @@ export const stripeRouter = createTRPCRouter({
return servers.length < user.serversQuantity; return servers.length < user.serversQuantity;
}), }),
getInvoices: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
return [];
}
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
try {
const invoices = await stripe.invoices.list({
customer: stripeCustomerId,
limit: 100,
});
return invoices.data.map((invoice) => ({
id: invoice.id,
number: invoice.number,
status: invoice.status,
amountDue: invoice.amount_due,
amountPaid: invoice.amount_paid,
currency: invoice.currency,
created: invoice.created,
dueDate: invoice.due_date,
hostedInvoiceUrl: invoice.hosted_invoice_url,
invoicePdf: invoice.invoice_pdf,
}));
} catch (_) {
return [];
}
}),
}); });
+2 -2
View File
@@ -58,7 +58,7 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
WITH recent_metrics AS ( WITH recent_metrics AS (
SELECT metrics_json SELECT metrics_json
FROM container_metrics FROM container_metrics
WHERE container_name LIKE ? || '%' WHERE container_name = ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
LIMIT ? LIMIT ?
) )
@@ -98,7 +98,7 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
WITH recent_metrics AS ( WITH recent_metrics AS (
SELECT metrics_json SELECT metrics_json
FROM container_metrics FROM container_metrics
WHERE container_name LIKE ? || '%' WHERE container_name = ?
ORDER BY timestamp DESC ORDER BY timestamp DESC
) )
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
+3 -3
View File
@@ -57,7 +57,6 @@
"drizzle-dbml-generator": "0.10.0", "drizzle-dbml-generator": "0.10.0",
"drizzle-orm": "^0.39.3", "drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1", "drizzle-zod": "0.5.1",
"hi-base32": "^0.5.1",
"yaml": "2.8.1", "yaml": "2.8.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"micromatch": "4.0.8", "micromatch": "4.0.8",
@@ -67,7 +66,6 @@
"node-schedule": "2.1.1", "node-schedule": "2.1.1",
"nodemailer": "6.9.14", "nodemailer": "6.9.14",
"octokit": "3.1.2", "octokit": "3.1.2",
"otpauth": "^9.4.0",
"pino": "9.4.0", "pino": "9.4.0",
"pino-pretty": "11.2.2", "pino-pretty": "11.2.2",
"postgres": "3.4.4", "postgres": "3.4.4",
@@ -80,9 +78,11 @@
"ssh2": "1.15.0", "ssh2": "1.15.0",
"toml": "3.0.0", "toml": "3.0.0",
"ws": "8.16.0", "ws": "8.16.0",
"zod": "^3.25.32" "zod": "^3.25.32",
"semver": "7.7.3"
}, },
"devDependencies": { "devDependencies": {
"@types/semver": "7.7.1",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23", "@types/dockerode": "3.3.23",
+89 -39
View File
@@ -5,12 +5,12 @@ import {
execAsync, execAsync,
execAsyncRemote, execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync"; } from "@dokploy/server/utils/process/execAsync";
import semver from "semver";
import { import {
initializeStandaloneTraefik, initializeStandaloneTraefik,
initializeTraefikService, initializeTraefikService,
type TraefikOptions, type TraefikOptions,
} from "../setup/traefik-setup"; } from "../setup/traefik-setup";
export interface IUpdateData { export interface IUpdateData {
latestVersion: string | null; latestVersion: string | null;
updateAvailable: boolean; updateAvailable: boolean;
@@ -55,56 +55,95 @@ export const getServiceImageDigest = async () => {
}; };
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */ /** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
export const getUpdateData = async (): Promise<IUpdateData> => { export const getUpdateData = async (
let currentDigest: string; currentVersion: string,
): Promise<IUpdateData> => {
try { try {
currentDigest = await getServiceImageDigest(); const baseUrl =
} catch (error) { "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
// TODO: Docker versions 29.0.0 change the way to get the service image digest, so we need to update this in the future we upgrade to that version. let url: string | null = `${baseUrl}?page_size=100`;
return DEFAULT_UPDATE_DATA; let allResults: { digest: string; name: string }[] = [];
}
const baseUrl = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags"; // Fetch all tags from Docker Hub
let url: string | null = `${baseUrl}?page_size=100`; while (url) {
let allResults: { digest: string; name: string }[] = []; const response = await fetch(url, {
while (url) { method: "GET",
const response = await fetch(url, { headers: { "Content-Type": "application/json" },
method: "GET", });
headers: { "Content-Type": "application/json" },
});
const data = (await response.json()) as { const data = (await response.json()) as {
next: string | null; next: string | null;
results: { digest: string; name: string }[]; results: { digest: string; name: string }[];
}; };
allResults = allResults.concat(data.results); allResults = allResults.concat(data.results);
url = data?.next; url = data?.next;
} }
const imageTag = getDokployImageTag(); const currentImageTag = getDokployImageTag();
const searchedDigest = allResults.find((t) => t.name === imageTag)?.digest;
if (!searchedDigest) { // Special handling for canary and feature branches
return DEFAULT_UPDATE_DATA; // For development versions (canary/feature), don't perform update checks
} // These are unstable versions that change frequently, and users on these
// branches are expected to manually manage updates
if (currentImageTag === "canary" || currentImageTag === "feature") {
const currentDigest = await getServiceImageDigest();
const latestDigest = allResults.find(
(t) => t.name === currentImageTag,
)?.digest;
if (!latestDigest) {
return DEFAULT_UPDATE_DATA;
}
if (currentDigest !== latestDigest) {
return {
latestVersion: currentImageTag,
updateAvailable: true,
};
}
return {
latestVersion: currentImageTag,
updateAvailable: false,
};
}
if (imageTag === "latest") { // For stable versions, use semver comparison
const versionedTag = allResults.find( // Find the "latest" tag and get its digest
(t) => t.digest === searchedDigest && t.name.startsWith("v"), const latestTag = allResults.find((t) => t.name === "latest");
);
if (!versionedTag) { if (!latestTag) {
return DEFAULT_UPDATE_DATA; return DEFAULT_UPDATE_DATA;
} }
const { name: latestVersion, digest } = versionedTag; // Find the versioned tag (v0.x.x) that has the same digest as "latest"
const updateAvailable = digest !== currentDigest; const latestVersionTag = allResults.find(
(t) => t.digest === latestTag.digest && t.name.startsWith("v"),
);
return { latestVersion, updateAvailable }; if (!latestVersionTag) {
return DEFAULT_UPDATE_DATA;
}
const latestVersion = latestVersionTag.name;
// Use semver to compare versions for stable releases
const cleanedCurrent = semver.clean(currentVersion);
const cleanedLatest = semver.clean(latestVersion);
if (!cleanedCurrent || !cleanedLatest) {
return DEFAULT_UPDATE_DATA;
}
// Check if the latest version is greater than the current version
const updateAvailable = semver.gt(cleanedLatest, cleanedCurrent);
return {
latestVersion,
updateAvailable,
};
} catch (error) {
console.error("Error fetching update data:", error);
return DEFAULT_UPDATE_DATA;
} }
const updateAvailable = searchedDigest !== currentDigest;
return { latestVersion: imageTag, updateAvailable };
}; };
interface TreeDataItem { interface TreeDataItem {
@@ -254,11 +293,22 @@ fi`;
export const reloadDockerResource = async ( export const reloadDockerResource = async (
resourceName: string, resourceName: string,
serverId?: string, serverId?: string,
version?: string,
) => { ) => {
const resourceType = await getDockerResourceType(resourceName, serverId); const resourceType = await getDockerResourceType(resourceName, serverId);
let command = ""; let command = "";
if (resourceType === "service") { if (resourceType === "service") {
command = `docker service update --force ${resourceName}`; if (resourceName === "dokploy") {
const currentImageTag = getDokployImageTag();
let imageTag = version;
if (currentImageTag === "canary" || currentImageTag === "feature") {
imageTag = currentImageTag;
}
command = `docker service update --force --image dokploy/dokploy:${imageTag} ${resourceName}`;
} else {
command = `docker service update --force ${resourceName}`;
}
} else if (resourceType === "standalone") { } else if (resourceType === "standalone") {
command = `docker restart ${resourceName}`; command = `docker restart ${resourceName}`;
} else { } else {
+27 -60
View File
@@ -51,9 +51,6 @@ importers:
'@hono/zod-validator': '@hono/zod-validator':
specifier: 0.3.0 specifier: 0.3.0
version: 0.3.0([email protected])([email protected]) version: 0.3.0([email protected])([email protected])
'@nerimity/mimiqueue':
specifier: 1.2.3
version: 1.2.3([email protected])
dotenv: dotenv:
specifier: ^16.4.5 specifier: ^16.4.5
version: 16.4.5 version: 16.4.5
@@ -313,9 +310,6 @@ importers:
fancy-ansi: fancy-ansi:
specifier: ^0.1.3 specifier: ^0.1.3
version: 0.1.3 version: 0.1.3
hi-base32:
specifier: ^0.5.1
version: 0.5.1
i18next: i18next:
specifier: ^23.16.8 specifier: ^23.16.8
version: 23.16.8 version: 23.16.8
@@ -364,9 +358,6 @@ importers:
octokit: octokit:
specifier: 3.1.2 specifier: 3.1.2
version: 3.1.2 version: 3.1.2
otpauth:
specifier: ^9.4.0
version: 9.4.0
pino: pino:
specifier: 9.4.0 specifier: 9.4.0
version: 9.4.0 version: 9.4.0
@@ -406,6 +397,9 @@ importers:
recharts: recharts:
specifier: ^2.15.3 specifier: ^2.15.3
version: 2.15.3([email protected]([email protected]))([email protected]) version: 2.15.3([email protected]([email protected]))([email protected])
semver:
specifier: 7.7.3
version: 7.7.3
shell-quote: shell-quote:
specifier: ^1.8.1 specifier: ^1.8.1
version: 1.8.2 version: 1.8.2
@@ -491,6 +485,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: 18.3.0 specifier: 18.3.0
version: 18.3.0 version: 18.3.0
'@types/semver':
specifier: 7.7.1
version: 7.7.1
'@types/shell-quote': '@types/shell-quote':
specifier: ^1.7.5 specifier: ^1.7.5
version: 1.7.5 version: 1.7.5
@@ -678,9 +675,6 @@ importers:
drizzle-zod: drizzle-zod:
specifier: 0.5.1 specifier: 0.5.1
version: 0.5.1([email protected](@opentelemetry/[email protected])(@types/[email protected])([email protected])([email protected]))([email protected]) version: 0.5.1([email protected](@opentelemetry/[email protected])(@types/[email protected])([email protected])([email protected]))([email protected])
hi-base32:
specifier: ^0.5.1
version: 0.5.1
lodash: lodash:
specifier: 4.17.21 specifier: 4.17.21
version: 4.17.21 version: 4.17.21
@@ -705,9 +699,6 @@ importers:
octokit: octokit:
specifier: 3.1.2 specifier: 3.1.2
version: 3.1.2 version: 3.1.2
otpauth:
specifier: ^9.4.0
version: 9.4.0
pino: pino:
specifier: 9.4.0 specifier: 9.4.0
version: 9.4.0 version: 9.4.0
@@ -729,6 +720,9 @@ importers:
react-dom: react-dom:
specifier: 18.2.0 specifier: 18.2.0
version: 18.2.0([email protected]) version: 18.2.0([email protected])
semver:
specifier: 7.7.3
version: 7.7.3
shell-quote: shell-quote:
specifier: ^1.8.1 specifier: ^1.8.1
version: 1.8.2 version: 1.8.2
@@ -784,6 +778,9 @@ importers:
'@types/react-dom': '@types/react-dom':
specifier: 18.3.0 specifier: 18.3.0
version: 18.3.0 version: 18.3.0
'@types/semver':
specifier: 7.7.1
version: 7.7.1
'@types/shell-quote': '@types/shell-quote':
specifier: ^1.7.5 specifier: ^1.7.5
version: 1.7.5 version: 1.7.5
@@ -1951,11 +1948,6 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@nerimity/[email protected]':
resolution: {integrity: sha512-WPoGe417P+S0FLfl3psRBI5adcAWXb917vCF1qD2yGZ1ggBEnMH6UrUK464gzJEOpAlGt8BBbIp0tgCEazZ47A==}
peerDependencies:
redis: ^4.7.0
'@next/[email protected]': '@next/[email protected]':
resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==} resolution: {integrity: sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==}
@@ -4060,6 +4052,9 @@ packages:
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-eLgbR5KwUh8+6pngBDxS32MymdCsCHnGtwHTrC0GDorbc7NbcnkZAWptDLgZiRk9VRas+B6TyRgPDucq4zRs8g==} resolution: {integrity: sha512-eLgbR5KwUh8+6pngBDxS32MymdCsCHnGtwHTrC0GDorbc7NbcnkZAWptDLgZiRk9VRas+B6TyRgPDucq4zRs8g==}
'@types/[email protected]':
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
'@types/[email protected]': '@types/[email protected]':
resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==} resolution: {integrity: sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==}
@@ -4302,9 +4297,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==}
[email protected]:
resolution: {integrity: sha512-3DpDtxkKO0O/FPlWbk/CrbexjuSxWm1CH1bXlVNVyMBIkKHhT5D85gzHmGJokG3ibNGWQ7pHBmStxUW/z/0LYQ==}
[email protected]: [email protected]:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -5389,9 +5381,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
[email protected]:
resolution: {integrity: sha512-EmBBpvdYh/4XxsnUybsPag6VikPYnN30td+vQk+GI3qpahVEG9+gTkG0aXVxTjBqQ5T6ijbWIu77O+C5WFWsnA==}
[email protected]: [email protected]:
resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==}
@@ -6426,9 +6415,6 @@ packages:
[email protected]: [email protected]:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
[email protected]:
resolution: {integrity: sha512-fHIfzIG5RqCkK9cmV8WU+dPQr9/ebR5QOwGZn2JAr1RQF+lmAuLL2YdtdqvmBjNmgJlYk3KZ4a0XokaEhg1Jsw==}
[email protected]: [email protected]:
resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==}
engines: {node: '>=12.20'} engines: {node: '>=12.20'}
@@ -7087,11 +7073,6 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true hasBin: true
[email protected]:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
engines: {node: '>=10'}
hasBin: true
[email protected]: [email protected]:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -8119,7 +8100,7 @@ snapshots:
'@commitlint/[email protected]': '@commitlint/[email protected]':
dependencies: dependencies:
'@commitlint/types': 19.8.1 '@commitlint/types': 19.8.1
semver: 7.7.2 semver: 7.7.3
'@commitlint/[email protected]': '@commitlint/[email protected]':
dependencies: dependencies:
@@ -8736,7 +8717,7 @@ snapshots:
nopt: 5.0.0 nopt: 5.0.0
npmlog: 5.0.1 npmlog: 5.0.1
rimraf: 3.0.2 rimraf: 3.0.2
semver: 7.7.2 semver: 7.7.3
tar: 6.2.1 tar: 6.2.1
transitivePeerDependencies: transitivePeerDependencies:
- encoding - encoding
@@ -8762,11 +8743,6 @@ snapshots:
'@msgpackr-extract/[email protected]': '@msgpackr-extract/[email protected]':
optional: true optional: true
'@nerimity/[email protected]([email protected])':
dependencies:
async-await-queue: 2.1.4
redis: 4.7.0
'@next/[email protected]': {} '@next/[email protected]': {}
'@next/[email protected]': '@next/[email protected]':
@@ -9327,7 +9303,7 @@ snapshots:
'@opentelemetry/instrumentation': 0.57.2(@opentelemetry/[email protected]) '@opentelemetry/instrumentation': 0.57.2(@opentelemetry/[email protected])
'@opentelemetry/semantic-conventions': 1.28.0 '@opentelemetry/semantic-conventions': 1.28.0
forwarded-parse: 2.1.2 forwarded-parse: 2.1.2
semver: 7.7.2 semver: 7.7.3
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -9528,7 +9504,7 @@ snapshots:
'@types/shimmer': 1.2.0 '@types/shimmer': 1.2.0
import-in-the-middle: 1.14.2 import-in-the-middle: 1.14.2
require-in-the-middle: 7.5.2 require-in-the-middle: 7.5.2
semver: 7.7.2 semver: 7.7.3
shimmer: 1.2.1 shimmer: 1.2.1
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -9673,7 +9649,7 @@ snapshots:
'@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/[email protected]) '@opentelemetry/propagator-b3': 1.30.1(@opentelemetry/[email protected])
'@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/[email protected]) '@opentelemetry/propagator-jaeger': 1.30.1(@opentelemetry/[email protected])
'@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/[email protected]) '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/[email protected])
semver: 7.7.2 semver: 7.7.3
'@opentelemetry/[email protected]': {} '@opentelemetry/[email protected]': {}
@@ -11421,6 +11397,8 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.17.51 '@types/node': 20.17.51
'@types/[email protected]': {}
'@types/[email protected]': {} '@types/[email protected]': {}
'@types/[email protected]': {} '@types/[email protected]': {}
@@ -11673,8 +11651,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -11820,7 +11796,7 @@ snapshots:
lodash: 4.17.21 lodash: 4.17.21
msgpackr: 1.11.4 msgpackr: 1.11.4
node-abort-controller: 3.1.1 node-abort-controller: 3.1.1
semver: 7.7.2 semver: 7.7.3
tslib: 2.8.1 tslib: 2.8.1
uuid: 9.0.1 uuid: 9.0.1
transitivePeerDependencies: transitivePeerDependencies:
@@ -12336,7 +12312,7 @@ snapshots:
'@one-ini/wasm': 0.1.1 '@one-ini/wasm': 0.1.1
commander: 10.0.1 commander: 10.0.1
minimatch: 9.0.1 minimatch: 9.0.1
semver: 7.7.2 semver: 7.7.3
[email protected]: {} [email protected]: {}
@@ -12650,7 +12626,7 @@ snapshots:
'@petamoriken/float16': 3.9.2 '@petamoriken/float16': 3.9.2
debug: 4.4.1 debug: 4.4.1
env-paths: 3.0.0 env-paths: 3.0.0
semver: 7.7.2 semver: 7.7.3
shell-quote: 1.8.2 shell-quote: 1.8.2
which: 4.0.0 which: 4.0.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -12824,8 +12800,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]: {}
[email protected]: {} [email protected]: {}
[email protected]: {} [email protected]: {}
@@ -13138,7 +13112,7 @@ snapshots:
lodash.isstring: 4.0.1 lodash.isstring: 4.0.1
lodash.once: 4.1.1 lodash.once: 4.1.1
ms: 2.1.3 ms: 2.1.3
semver: 7.7.2 semver: 7.7.3
[email protected]: [email protected]:
dependencies: dependencies:
@@ -13962,10 +13936,6 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected]:
dependencies:
'@noble/hashes': 1.7.1
[email protected]: {} [email protected]: {}
[email protected]: [email protected]:
@@ -14674,10 +14644,7 @@ snapshots:
[email protected]: {} [email protected]: {}
[email protected].2: {} [email protected].3: {}
[email protected]:
optional: true
[email protected]: {} [email protected]: {}