Merge remote-tracking branch 'origin/canary' into feature/pushover-notifications

This commit is contained in:
Plui Sol
2026-01-12 21:31:48 -05:00
59 changed files with 8198 additions and 246 deletions

View File

@@ -13,7 +13,6 @@
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.14.3",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.4.5",
"hono": "^4.7.10",
"pino": "9.4.0",

View File

@@ -25,7 +25,7 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application-preview"),
serverId: z.string().min(1),
}),

View File

@@ -4,6 +4,7 @@ import {
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -54,7 +55,14 @@ export const deploy = async (job: DeployJob) => {
previewStatus: "running",
});
if (job.server) {
if (job.type === "deploy") {
if (job.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Rebuild Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
} else if (job.type === "deploy") {
await deployPreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog || "Preview Deployment",

View File

@@ -25,7 +25,7 @@ if (typeof window === "undefined") {
}
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
createEnvFile: true,

View File

@@ -3,7 +3,7 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
railpackVersion: "0.15.4",
rollbackActive: false,
applicationId: "",
previewLabels: [],

View File

@@ -1,6 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Cog } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -20,8 +20,39 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
// Railpack versions from https://github.com/railwayapp/railpack/releases
export const RAILPACK_VERSIONS = [
"0.15.4",
"0.15.3",
"0.15.2",
"0.15.1",
"0.15.0",
"0.14.0",
"0.13.0",
"0.12.0",
"0.11.0",
"0.10.0",
"0.9.2",
"0.9.1",
"0.9.0",
"0.8.0",
"0.7.0",
"0.6.0",
"0.5.0",
"0.4.0",
"0.3.0",
"0.2.2",
] as const;
export enum BuildType {
dockerfile = "dockerfile",
heroku_buildpacks = "heroku_buildpacks",
@@ -65,7 +96,7 @@ const mySchema = z.discriminatedUnion("buildType", [
}),
z.object({
buildType: z.literal(BuildType.railpack),
railpackVersion: z.string().nullable().default("0.2.2"),
railpackVersion: z.string().nullable().default("0.15.4"),
}),
z.object({
buildType: z.literal(BuildType.static),
@@ -152,6 +183,8 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
});
const buildType = form.watch("buildType");
const railpackVersion = form.watch("railpackVersion");
const [isManualRailpackVersion, setIsManualRailpackVersion] = useState(false);
useEffect(() => {
if (data) {
@@ -163,6 +196,14 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
};
form.reset(resetData(typedData));
// Check if railpack version is manual (not in the predefined list)
if (
data.railpackVersion &&
!RAILPACK_VERSIONS.includes(data.railpackVersion as any)
) {
setIsManualRailpackVersion(true);
}
}
}, [data, form]);
@@ -186,7 +227,7 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.static ? data.isStaticSpa : null,
railpackVersion:
data.buildType === BuildType.railpack
? data.railpackVersion || "0.2.2"
? data.railpackVersion || "0.15.4"
: null,
})
.then(async () => {
@@ -403,23 +444,88 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
/>
)}
{buildType === BuildType.railpack && (
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
<Input
placeholder="Railpack Version"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<>
<FormField
control={form.control}
name="railpackVersion"
render={({ field }) => (
<FormItem>
<FormLabel>Railpack Version</FormLabel>
<FormControl>
{isManualRailpackVersion ? (
<div className="space-y-2">
<Input
placeholder="Enter custom version (e.g., 0.15.4)"
{...field}
value={field.value ?? ""}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
setIsManualRailpackVersion(false);
field.onChange("0.15.4");
}}
>
Use predefined versions
</Button>
</div>
) : (
<Select
onValueChange={(value) => {
if (value === "manual") {
setIsManualRailpackVersion(true);
field.onChange("");
} else {
field.onChange(value);
}
}}
value={field.value ?? "0.15.4"}
>
<SelectTrigger>
<SelectValue placeholder="Select Railpack version" />
</SelectTrigger>
<SelectContent>
<SelectItem value="manual">
<span className="font-medium">
Manual (Custom Version)
</span>
</SelectItem>
{RAILPACK_VERSIONS.map((version) => (
<SelectItem key={version} value={version}>
v{version}
{version === "0.15.4" && (
<Badge
variant="secondary"
className="ml-2 px-1 text-xs"
>
Latest
</Badge>
)}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</FormControl>
<FormDescription>
Select a Railpack version or choose manual to enter a
custom version.{" "}
<a
href="https://github.com/railwayapp/railpack/releases"
target="_blank"
rel="noreferrer"
className="text-primary underline underline-offset-4"
>
View releases
</a>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">

View File

@@ -256,9 +256,9 @@ export const ShowDeployments = ({
return (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
className="flex flex-col gap-4 rounded-lg border p-4 sm:flex-row sm:items-center sm:justify-between"
>
<div className="flex flex-col">
<div className="flex flex-1 flex-col min-w-0">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{index + 1}. {deployment.status}
<StatusTooltip
@@ -313,8 +313,8 @@ export const ShowDeployments = ({
)}
</div>
</div>
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<div className="flex w-full flex-col items-start gap-2 sm:w-auto sm:max-w-[300px] sm:items-end sm:justify-start">
<div className="text-sm capitalize text-muted-foreground flex flex-wrap items-center gap-2">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
@@ -333,7 +333,7 @@ export const ShowDeployments = ({
)}
</div>
<div className="flex flex-row items-center gap-2">
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center sm:justify-end">
{deployment.pid && deployment.status === "running" && (
<DialogAction
title="Kill Process"
@@ -355,6 +355,7 @@ export const ShowDeployments = ({
variant="destructive"
size="sm"
isLoading={isKillingProcess}
className="w-full sm:w-auto"
>
Kill Process
</Button>
@@ -364,6 +365,7 @@ export const ShowDeployments = ({
onClick={() => {
setActiveLog(deployment);
}}
className="w-full sm:w-auto"
>
View
</Button>
@@ -405,6 +407,7 @@ export const ShowDeployments = ({
variant="secondary"
size="sm"
isLoading={isRollingBack}
className="w-full sm:w-auto"
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback

View File

@@ -232,9 +232,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`${gitlabUrl}/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

View File

@@ -2,6 +2,7 @@ import {
ExternalLink,
FileText,
GitPullRequest,
Hammer,
Loader2,
PenSquare,
RocketIcon,
@@ -22,6 +23,13 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { api } from "@/utils/api";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
@@ -38,6 +46,9 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const { mutateAsync: redeployPreviewDeployment } =
api.previewDeployment.redeploy.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
@@ -46,6 +57,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: (data) =>
data?.some((d) => d.previewStatus === "running") ? 2000 : false,
},
);
@@ -193,6 +206,58 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</Button>
</ShowDeploymentsModal>
<DialogAction
title="Rebuild Preview Deployment"
description="Are you sure you want to rebuild this preview deployment?"
type="default"
onClick={async () => {
await redeployPreviewDeployment({
previewDeploymentId:
deployment.previewDeploymentId,
})
.then(() => {
toast.success(
"Preview deployment rebuild started",
);
refetchPreviewDeployments();
})
.catch(() => {
toast.error(
"Error rebuilding preview deployment",
);
});
}}
>
<Button
variant="outline"
size="sm"
isLoading={status === "running"}
className="gap-2"
>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<Hammer className="size-4" />
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent
sideOffset={5}
className="z-[60]"
>
<p>
Rebuild the preview deployment without
downloading new code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
</TooltipProvider>
</Button>
</DialogAction>
<AddPreviewDomain
previewDeploymentId={`${deployment.previewDeploymentId}`}
domainId={deployment.domain?.domainId}

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -97,6 +97,16 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
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 {
data: repositories,
isLoading: isLoadingRepositories,
@@ -224,9 +234,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormItem className="md:col-span-2 flex flex-col">
<div className="flex items-center justify-between">
<FormLabel>Repository</FormLabel>
{field.value.owner && field.value.repo && (
{field.value.gitlabPathNamespace && (
<Link
href={`https://gitlab.com/${field.value.owner}/${field.value.repo}`}
href={`${gitlabUrl}/${field.value.gitlabPathNamespace}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"

View File

@@ -559,6 +559,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
type="password"
placeholder="******************"
autoComplete="one-time-code"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>
@@ -578,6 +579,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Input
type="password"
placeholder="******************"
enablePasswordGenerator={true}
{...field}
/>
</FormControl>

View File

@@ -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>
);
};

View File

@@ -4,11 +4,13 @@ import {
AlertTriangle,
CheckIcon,
CreditCard,
FileText,
Loader2,
MinusIcon,
PlusIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,7 +39,22 @@ export const calculatePrice = (count: number, isAnnual = false) => {
if (count <= 1) return 4.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 = () => {
const router = useRouter();
const { data: servers } = api.server.count.useQuery();
const { data: admin } = api.user.get.useQuery();
const { data, isLoading } = api.stripe.getProducts.useQuery();
@@ -76,17 +93,41 @@ export const ShowBilling = () => {
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<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</CardDescription>
<CardDescription>
Manage your subscription and invoices
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<div className="flex flex-col gap-4 w-full">
<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="flex flex-col gap-4 w-full mt-6">
<Tabs
defaultValue="monthly"
value={isAnnual ? "annual" : "monthly"}

View File

@@ -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>
);
};

View File

@@ -1,7 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Loader2, User } from "lucide-react";
import { Loader2, Palette, User } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -27,6 +27,7 @@ import {
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { getAvatarType, isSolidColorAvatar } from "@/lib/avatar-utils";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Configure2FA } from "./configure-2fa";
@@ -74,6 +75,7 @@ export const ProfileForm = () => {
} = api.user.update.useMutation();
const { t } = useTranslation("settings");
const [gravatarHash, setGravatarHash] = useState<string | null>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
const availableAvatars = useMemo(() => {
if (gravatarHash === null) return randomImages;
@@ -274,16 +276,8 @@ export const ProfileForm = () => {
onValueChange={(e) => {
field.onChange(e);
}}
defaultValue={
field.value?.startsWith("data:")
? "upload"
: field.value
}
value={
field.value?.startsWith("data:")
? "upload"
: field.value
}
defaultValue={getAvatarType(field.value)}
value={getAvatarType(field.value)}
className="flex flex-row flex-wrap gap-2 max-xl:justify-center"
>
<FormItem key="no-avatar">
@@ -370,6 +364,40 @@ export const ProfileForm = () => {
/>
</FormLabel>
</FormItem>
<FormItem key="color-avatar">
<FormLabel className="[&:has([data-state=checked])>.color-avatar]:border-primary [&:has([data-state=checked])>.color-avatar]:border-1 [&:has([data-state=checked])>.color-avatar]:p-px cursor-pointer relative">
<FormControl>
<RadioGroupItem
value="color"
className="sr-only"
/>
</FormControl>
<div
className="color-avatar h-12 w-12 rounded-full border hover:p-px hover:border-primary transition-colors flex items-center justify-center overflow-hidden cursor-pointer"
style={{
backgroundColor: isSolidColorAvatar(
field.value,
)
? field.value
: undefined,
}}
onClick={() =>
colorInputRef.current?.click()
}
>
{!isSolidColorAvatar(field.value) && (
<Palette className="h-5 w-5 text-muted-foreground" />
)}
</div>
<input
ref={colorInputRef}
type="color"
className="absolute opacity-0 pointer-events-none w-12 h-12 top-0 left-0"
value={field.value}
onChange={field.onChange}
/>
</FormLabel>
</FormItem>
{availableAvatars.map((image) => (
<FormItem key={image}>
<FormLabel className="[&:has([data-state=checked])>img]:border-primary [&:has([data-state=checked])>img]:border-1 [&:has([data-state=checked])>img]:p-px cursor-pointer">

View File

@@ -22,7 +22,6 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -89,15 +88,15 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
</Button>
</DialogTrigger>
) : (
<DropdownMenuItem
<Button
className="w-full cursor-pointer "
onSelect={(e) => {
e.preventDefault();
size="sm"
onClick={() => {
setIsOpen(true);
}}
>
Setup Server
</DropdownMenuItem>
Setup Server <Settings className="size-4" />
</Button>
)}
<DialogContent className="sm:max-w-4xl ">
<DialogHeader>

View File

@@ -6,9 +6,7 @@ import {
Loader2,
MoreHorizontal,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
User,
@@ -31,9 +29,7 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -285,7 +281,32 @@ export const ShowServers = () => {
{/* Compact Actions */}
{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>
{server.sshKeyId && (
<Tooltip>
@@ -311,20 +332,6 @@ export const ShowServers = () => {
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<div>
<SetupServer
serverId={server.serverId}
asButton={true}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Setup Server</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<div>

View File

@@ -10,7 +10,7 @@ export const ToggleVisibilityInput = ({ ...props }: InputProps) => {
return (
<div className="flex w-full items-center space-x-2">
<Input ref={inputRef} type={"password"} {...props} />
<Input ref={inputRef} {...props} type="password" />
<Button
variant={"secondary"}
onClick={() => {

View File

@@ -1,6 +1,6 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import * as React from "react";
import { isSolidColorAvatar } from "@/lib/avatar-utils";
import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
@@ -20,14 +20,33 @@ Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
));
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & {
src?: string | null;
}
>(({ className, src, ...props }, ref) => {
if (isSolidColorAvatar(src)) {
return (
<div
key={`solid-${src}`}
ref={ref}
className={cn("aspect-square h-full w-full rounded-full", className)}
style={{
backgroundColor: src,
}}
{...props}
/>
);
}
return (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
src={src ?? ""}
{...props}
/>
);
});
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<

View File

@@ -1,18 +1,75 @@
import { EyeIcon, EyeOffIcon } from "lucide-react";
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
import * as React from "react";
import { generateRandomPassword } from "@/lib/password-utils";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
errorMessage?: string;
enablePasswordGenerator?: boolean;
passwordGeneratorLength?: number;
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, errorMessage, type, ...props }, ref) => {
(
{
className,
errorMessage,
type,
enablePasswordGenerator = false,
passwordGeneratorLength,
...props
},
ref,
) => {
const [showPassword, setShowPassword] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
const isPassword = type === "password";
const shouldShowGenerator =
isPassword &&
enablePasswordGenerator !== false &&
!props.disabled &&
!props.readOnly;
const inputType = isPassword ? (showPassword ? "text" : "password") : type;
const setRefs = React.useCallback(
(node: HTMLInputElement | null) => {
// @ts-ignore
inputRef.current = node;
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
},
[ref],
);
const handleGeneratePassword = () => {
const nextValue =
typeof passwordGeneratorLength === "number" &&
passwordGeneratorLength > 0
? generateRandomPassword(Math.floor(passwordGeneratorLength))
: generateRandomPassword();
const input = inputRef.current;
if (!input) {
return;
}
const valueSetter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
"value",
)?.set;
if (valueSetter) {
valueSetter.call(input, nextValue);
} else {
input.value = nextValue;
}
input.dispatchEvent(new Event("input", { bubbles: true }));
};
return (
<>
<div className="relative w-full">
@@ -21,25 +78,39 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
className={cn(
// bg-gray
"flex h-10 w-full rounded-md bg-input px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border disabled:cursor-not-allowed disabled:opacity-50",
isPassword && "pr-10", // Add padding for the eye icon
isPassword && (shouldShowGenerator ? "pr-16" : "pr-10"),
className,
)}
ref={ref}
ref={setRefs}
{...props}
/>
{isPassword && (
<button
type="button"
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
<div className="absolute inset-y-0 right-0 flex items-center gap-1 pr-3 text-muted-foreground">
{shouldShowGenerator && (
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={handleGeneratePassword}
aria-label="Generate password"
title="Generate password"
tabIndex={-1}
>
<RefreshCcw className="h-4 w-4" />
</button>
)}
</button>
<button
type="button"
className="hover:text-foreground focus:outline-none"
onClick={() => setShowPassword(!showPassword)}
tabIndex={-1}
>
{showPassword ? (
<EyeOffIcon className="h-4 w-4" />
) : (
<EyeIcon className="h-4 w-4" />
)}
</button>
</div>
)}
</div>
{errorMessage && (

View File

@@ -0,0 +1 @@
ALTER TABLE "application" ALTER COLUMN "railpackVersion" SET DEFAULT '0.15.4';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
/**
* Checks if the given avatar value represents a solid color in hexadecimal format.
*
* @param value Avatar value to check.
*
* @return True if the avatar is a solid color, false otherwise.
*/
export function isSolidColorAvatar(value?: string | null) {
return (
(value?.startsWith("#") && /^#[0-9A-Fa-f]{6}$/.test(value)) ||
value?.startsWith("color:") ||
false
);
}
/**
* Gets the avatar type for form selection (RadioGroup value).
*
* @param value Avatar value.
*
* @return "upload" for base64 images, "color" for solid colors, or the original value for other types.
*/
export function getAvatarType(value?: string | null) {
if (!value) return "";
if (value.startsWith("data:")) return "upload";
if (isSolidColorAvatar(value)) return "color";
return value;
}

View File

@@ -0,0 +1,38 @@
const DEFAULT_PASSWORD_LENGTH = 20;
const DEFAULT_PASSWORD_CHARSET =
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
export const generateRandomPassword = (
length: number = DEFAULT_PASSWORD_LENGTH,
charset: string = DEFAULT_PASSWORD_CHARSET,
) => {
const safeLength =
Number.isFinite(length) && length > 0
? Math.floor(length)
: DEFAULT_PASSWORD_LENGTH;
if (safeLength <= 0 || charset.length === 0) {
return "";
}
const cryptoApi =
typeof globalThis !== "undefined" ? globalThis.crypto : undefined;
if (!cryptoApi?.getRandomValues) {
let fallback = "";
for (let i = 0; i < safeLength; i += 1) {
fallback += charset[Math.floor(Math.random() * charset.length)];
}
return fallback;
}
const values = new Uint32Array(safeLength);
cryptoApi.getRandomValues(values);
let result = "";
for (const value of values) {
result += charset[value % charset.length];
}
return result;
};

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.26.3",
"version": "v0.26.4",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -109,7 +109,6 @@
"drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1",
"fancy-ansi": "^0.1.3",
"hi-base32": "^0.5.1",
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
@@ -126,7 +125,6 @@
"node-schedule": "2.1.1",
"nodemailer": "6.9.14",
"octokit": "3.1.2",
"otpauth": "^9.4.0",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"postgres": "3.4.4",
@@ -155,9 +153,11 @@
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7"
"zod-form-data": "^2.0.7",
"semver": "7.7.3"
},
"devDependencies": {
"@types/semver": "7.7.1",
"@types/shell-quote": "^1.7.5",
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",

View File

@@ -909,7 +909,9 @@ const EnvironmentPage = (
<ProjectEnvironment projectId={projectId}>
<Button variant="outline">Project Environment</Button>
</ProjectEnvironment>
{(auth?.role === "owner" || auth?.canCreateServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canCreateServices) && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
@@ -1032,6 +1034,7 @@ const EnvironmentPage = (
</Button>
</DialogAction>
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<>
<DialogAction

View File

@@ -192,7 +192,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateApplication applicationId={applicationId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={applicationId} type="application" />
)}
</div>

View File

@@ -182,7 +182,9 @@ const Service = (
<div className="flex flex-row gap-2 justify-end">
<UpdateCompose composeId={composeId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={composeId} type="compose" />
)}
</div>

View File

@@ -156,7 +156,9 @@ const Mariadb = (
</div>
<div className="flex flex-row gap-2 justify-end">
<UpdateMariadb mariadbId={mariadbId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mariadbId} type="mariadb" />
)}
</div>

View File

@@ -155,7 +155,9 @@ const Mongo = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMongo mongoId={mongoId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mongoId} type="mongo" />
)}
</div>

View File

@@ -156,7 +156,9 @@ const MySql = (
<div className="flex flex-row gap-2 justify-end">
<UpdateMysql mysqlId={mysqlId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={mysqlId} type="mysql" />
)}
</div>

View File

@@ -154,7 +154,9 @@ const Postgresql = (
<div className="flex flex-row gap-2 justify-end">
<UpdatePostgres postgresId={postgresId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={postgresId} type="postgres" />
)}
</div>

View File

@@ -154,7 +154,9 @@ const Redis = (
<div className="flex flex-row gap-2 justify-end">
<UpdateRedis redisId={redisId} />
{(auth?.role === "owner" || auth?.canDeleteServices) && (
{(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canDeleteServices) && (
<DeleteService id={redisId} type="redis" />
)}
</div>

View File

@@ -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(),
},
};
}

View File

@@ -285,6 +285,7 @@ export const backupRouter = createTRPCRouter({
.mutation(async ({ input }) => {
const backup = await findBackupById(input.backupId);
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
return true;
}),
listBackupFiles: protectedProcedure

View File

@@ -430,7 +430,11 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Deployment queued" };
return {
success: true,
message: "Deployment queued",
composeId: compose.composeId,
};
}),
redeploy: protectedProcedure
.input(apiRedeployCompose)
@@ -468,7 +472,11 @@ export const composeRouter = createTRPCRouter({
removeOnFail: true,
},
);
return { success: true, message: "Redeployment queued" };
return {
success: true,
message: "Redeployment queued",
composeId: compose.composeId,
};
}),
stop: protectedProcedure
.input(apiFindCompose)

View File

@@ -2,11 +2,15 @@ import {
findApplicationById,
findPreviewDeploymentById,
findPreviewDeploymentsByApplicationId,
IS_CLOUD,
removePreviewDeployment,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { apiFindAllByApplication } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const previewDeploymentRouter = createTRPCRouter({
@@ -60,4 +64,55 @@ export const previewDeploymentRouter = createTRPCRouter({
}
return previewDeployment;
}),
redeploy: protectedProcedure
.input(
z.object({
previewDeploymentId: z.string(),
title: z.string().optional(),
description: z.string().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const previewDeployment = await findPreviewDeploymentById(
input.previewDeploymentId,
);
if (
previewDeployment.application.environment.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to redeploy this preview deployment",
});
}
const application = await findApplicationById(
previewDeployment.applicationId,
);
const jobData: DeploymentJob = {
applicationId: previewDeployment.applicationId,
titleLog: input.title || "Rebuild Preview Deployment",
descriptionLog: input.description || "",
type: "redeploy",
applicationType: "application-preview",
previewDeploymentId: input.previewDeploymentId,
server: !!application.serverId,
};
if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error);
});
return true;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
return true;
}),
});

View File

@@ -88,7 +88,7 @@ export const settingsRouter = createTRPCRouter({
if (IS_CLOUD) {
return true;
}
await reloadDockerResource("dokploy");
await reloadDockerResource("dokploy", undefined, packageInfo.version);
return true;
}),
cleanRedis: adminProcedure.mutation(async () => {
@@ -399,7 +399,7 @@ export const settingsRouter = createTRPCRouter({
return DEFAULT_UPDATE_DATA;
}
return await getUpdateData();
return await getUpdateData(packageInfo.version);
}),
updateServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) {

View File

@@ -81,6 +81,7 @@ export const stripeRouter = createTRPCRouter({
metadata: {
adminId: owner.id,
},
customer_email: owner.email,
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
@@ -128,4 +129,39 @@ export const stripeRouter = createTRPCRouter({
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 [];
}
}),
});

View File

@@ -4,6 +4,7 @@ import {
deployPreviewApplication,
rebuildApplication,
rebuildCompose,
rebuildPreviewApplication,
updateApplicationStatus,
updateCompose,
updatePreviewDeployment,
@@ -54,7 +55,14 @@ export const deploymentWorker = new Worker(
previewStatus: "running",
});
if (job.data.type === "deploy") {
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,

View File

@@ -22,7 +22,7 @@ type DeployJob =
titleLog: string;
descriptionLog: string;
server?: boolean;
type: "deploy";
type: "deploy" | "redeploy";
applicationType: "application-preview";
previewDeploymentId: string;
serverId?: string;

View File

@@ -58,7 +58,7 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta
WITH recent_metrics AS (
SELECT metrics_json
FROM container_metrics
WHERE container_name LIKE ? || '%'
WHERE container_name = ?
ORDER BY timestamp DESC
LIMIT ?
)
@@ -98,7 +98,7 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e
WITH recent_metrics AS (
SELECT metrics_json
FROM container_metrics
WHERE container_name LIKE ? || '%'
WHERE container_name = ?
ORDER BY timestamp DESC
)
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC