Merge branch 'canary' into feat/add-chat

This commit is contained in:
Mauricio Siu
2026-04-11 10:14:13 -06:00
29 changed files with 9106 additions and 108 deletions

View File

@@ -424,6 +424,26 @@ test("Custom entrypoint with internalPath adds addprefix middleware", async () =
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,

View File

@@ -1,5 +1,6 @@
"use client";
import { Bot, Loader2, RotateCcw, X } from "lucide-react";
import { Bot, Loader2, RotateCcw, Settings, X } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
@@ -32,14 +33,13 @@ export function AnalyzeLogs({ logs, context }: Props) {
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
const { mutate, isPending, data, reset } =
api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const handleAnalyze = () => {
if (!aiId || logs.length === 0) return;
@@ -92,38 +92,57 @@ export function AnalyzeLogs({ logs, context }: Props) {
</div>
<div className="p-4 space-y-3">
{!data?.analysis ? (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze {logs.length > MAX_LOG_LINES ? `last ${MAX_LOG_LINES}` : logs.length} lines
</>
)}
</Button>
</>
providers && providers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm text-muted-foreground">
No AI providers configured. Set up a provider to start
analyzing logs.
</p>
<Button size="sm" variant="outline" asChild>
<Link href="/dashboard/settings/ai">
<Settings className="mr-2 h-3.5 w-3.5" />
Configure AI Provider
</Link>
</Button>
</div>
) : (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze{" "}
{logs.length > MAX_LOG_LINES
? `last ${MAX_LOG_LINES}`
: logs.length}{" "}
lines
</>
)}
</Button>
</>
)
) : (
<>
<div className="max-h-[400px] overflow-y-auto">

View File

@@ -378,7 +378,7 @@ export const DockerLogsId: React.FC<Props> = ({
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (

View File

@@ -220,11 +220,11 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}
Used: {String(currentData.cpu.value ?? "0%")}
</span>
<Progress
value={Number.parseInt(
currentData.cpu.value.replace("%", ""),
String(currentData.cpu.value ?? "0%").replace("%", ""),
10,
)}
className="w-[100%]"

View File

@@ -2,6 +2,7 @@ import { loadStripe } from "@stripe/stripe-js";
import clsx from "clsx";
import {
AlertTriangle,
Bell,
CheckIcon,
CreditCard,
FileText,
@@ -25,7 +26,17 @@ import {
CardTitle,
} from "@/components/ui/card";
import { NumberInput } from "@/components/ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
@@ -90,6 +101,8 @@ export const ShowBilling = () => {
api.stripe.createCustomerPortalSession.useMutation();
const { mutateAsync: upgradeSubscription, isPending: isUpgrading } =
api.stripe.upgradeSubscription.useMutation();
const { mutateAsync: updateInvoiceNotifications } =
api.stripe.updateInvoiceNotifications.useMutation();
const utils = api.useUtils();
const [hobbyServerQuantity, setHobbyServerQuantity] = useState(1);
@@ -151,14 +164,66 @@ export const ShowBilling = () => {
<div className="w-full">
<Card className="bg-sidebar p-2.5 rounded-xl max-w-6xl 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 className="flex flex-row items-start justify-between">
<div>
<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>
</div>
{(admin?.user.stripeSubscriptionId || isEnterpriseCloud) && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Bell className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Notification Settings</DialogTitle>
<DialogDescription>
Configure your billing email notifications.
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="invoice-notifications">
Invoice Notifications
</Label>
<p className="text-sm text-muted-foreground">
Receive email notifications for payments and failed
charges.
</p>
</div>
<Switch
id="invoice-notifications"
checked={admin?.user.sendInvoiceNotifications ?? false}
onCheckedChange={async (checked) => {
await updateInvoiceNotifications({
enabled: checked,
})
.then(() => {
utils.user.get.invalidate();
toast.success(
checked
? "Invoice notifications enabled"
: "Invoice notifications disabled",
);
})
.catch(() => {
toast.error(
"Failed to update invoice notifications",
);
});
}}
/>
</div>
</DialogContent>
</Dialog>
)}
</CardHeader>
<CardContent className="space-y-4 py-4 border-t">
<nav className="flex space-x-2 border-b">

View File

@@ -68,7 +68,7 @@ const AI_PROVIDERS = [
{ name: "DeepInfra", apiUrl: "https://api.deepinfra.com/v1/openai" },
{ name: "Ollama", apiUrl: "http://localhost:11434" },
{ name: "OpenRouter", apiUrl: "https://openrouter.ai/api/v1" },
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4/" },
{ name: "Z.AI", apiUrl: "https://api.z.ai/api/paas/v4" },
{ name: "MiniMax", apiUrl: "https://api.minimax.io/v1" },
] as const;

View File

@@ -55,7 +55,8 @@ export const WelcomeSubscription = () => {
const [showConfetti, setShowConfetti] = useState(false);
const stepper = useStepper();
const [isOpen, setIsOpen] = useState(true);
const { push } = useRouter();
const router = useRouter();
const { push } = router;
useEffect(() => {
const confettiShown = localStorage.getItem("hasShownConfetti");
@@ -66,7 +67,22 @@ export const WelcomeSubscription = () => {
}, [showConfetti]);
return (
<Dialog open={isOpen}>
<Dialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) {
const { success, ...rest } = router.query;
router.replace(
{ pathname: router.pathname, query: rest },
undefined,
{
shallow: true,
},
);
}
}}
>
<DialogContent className="sm:max-w-7xl min-h-[75vh]">
{showConfetti ?? "Flaso"}
<div className="flex justify-center items-center w-full">

View File

@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "sendInvoiceNotifications" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1156,6 +1156,13 @@
"when": 1775369858244,
"tag": "0164_slippery_sasquatch",
"breakpoints": true
},
{
"idx": 165,
"version": "7",
"when": 1775845419261,
"tag": "0165_abnormal_greymalkin",
"breakpoints": true
}
]
}

View File

@@ -5,6 +5,10 @@ import { and, asc, eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import Stripe from "stripe";
import { organization, server, user } from "@/server/db/schema";
import {
sendInvoiceEmail,
sendPaymentFailedEmail,
} from "@/server/utils/stripe-notifications";
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
@@ -241,6 +245,11 @@ export default async function handler(
}
const newServersQuantity = admin.serversQuantity;
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
if (admin.sendInvoiceNotifications) {
await sendInvoiceEmail(newInvoice, admin);
}
break;
}
case "invoice.payment_failed": {
@@ -249,7 +258,6 @@ export default async function handler(
const subscription = await stripe.subscriptions.retrieve(
newInvoice.subscription as string,
);
if (subscription.status !== "active") {
const admin = await findUserByStripeCustomerId(
newInvoice.customer as string,
@@ -263,6 +271,10 @@ export default async function handler(
break;
}
if (admin.sendInvoiceNotifications) {
await sendPaymentFailedEmail(newInvoice, admin);
}
await db
.update(user)
.set({

View File

@@ -217,7 +217,7 @@ export const aiRouter = createTRPCRouter({
context: z.enum(["build", "runtime"]),
}),
)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
const aiSettings = await getAiSettingById(input.aiId);
if (!aiSettings?.isEnabled) {
@@ -227,6 +227,13 @@ export const aiRouter = createTRPCRouter({
});
}
if (aiSettings.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Access denied",
});
}
const provider = selectAIProvider(aiSettings);
const model = provider(aiSettings.model);
@@ -253,7 +260,9 @@ ${input.logs}`,
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : `Analysis failed: ${error}`,
error instanceof Error
? error.message
: `Analysis failed: ${error}`,
});
}
}),
@@ -285,7 +294,9 @@ ${input.logs}`,
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : `Connection failed: ${error}`,
error instanceof Error
? error.message
: `Connection failed: ${error}`,
});
}
}),

View File

@@ -6,6 +6,7 @@ import {
findEnvironmentById,
findGitProviderById,
findProjectById,
getAccessibleServerIds,
getApplicationStats,
getContainerLogs,
IS_CLOUD,
@@ -27,7 +28,6 @@ import {
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {

View File

@@ -1168,6 +1168,7 @@ export const composeRouter = createTRPCRouter({
input.since,
input.search,
compose.serverId,
true,
);
}),
});

View File

@@ -9,6 +9,7 @@ import {
findEnvironmentById,
findMariadbById,
findProjectById,
getAccessibleServerIds,
getContainerLogs,
getServiceContainerCommand,
IS_CLOUD,
@@ -20,7 +21,6 @@ import {
stopService,
stopServiceRemote,
updateMariadbById,
getAccessibleServerIds,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import {

View File

@@ -205,11 +205,16 @@ export const stripeRouter = createTRPCRouter({
mode: "subscription",
line_items: items,
...(stripeCustomerId
? { customer: stripeCustomerId }
? {
customer: stripeCustomerId,
customer_update: { name: "auto", address: "auto" },
}
: { customer_email: owner.email }),
metadata: {
adminId: owner.id,
},
billing_address_collection: "required",
tax_id_collection: { enabled: true },
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/dashboard/settings/servers?success=true`,
cancel_url: `${WEBSITE_URL}/dashboard/settings/billing`,
@@ -332,6 +337,22 @@ export const stripeRouter = createTRPCRouter({
},
),
updateInvoiceNotifications: adminProcedure
.input(z.object({ enabled: z.boolean() }))
.mutation(async ({ ctx, input }) => {
if (!IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available in Dokploy Cloud",
});
}
const owner = await findUserById(ctx.user.ownerId);
await updateUser(owner.id, {
sendInvoiceNotifications: input.enabled,
});
return { ok: true };
}),
getInvoices: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;

View File

@@ -0,0 +1,113 @@
import InvoiceNotificationEmail from "@dokploy/server/emails/emails/invoice-notification";
import PaymentFailedEmail from "@dokploy/server/emails/emails/payment-failed";
import { sendEmail } from "@dokploy/server/verification/send-verification-email";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import type Stripe from "stripe";
function formatAmount(amountInCents: number, currency: string): string {
const amount = amountInCents / 100;
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency: currency.toUpperCase(),
});
return formatter.format(amount);
}
const downloadPdf = async (url: string): Promise<Buffer | null> => {
try {
const response = await fetch(url);
if (!response.ok) return null;
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
} catch {
return null;
}
};
export const sendInvoiceEmail = async (
invoice: Stripe.Invoice,
admin: { email: string; firstName: string },
) => {
if (!invoice.hosted_invoice_url) return;
try {
const amountFormatted = formatAmount(invoice.amount_paid, invoice.currency);
const htmlContent = await renderAsync(
InvoiceNotificationEmail({
userName: admin.firstName || "User",
invoiceNumber: invoice.number || invoice.id,
amountPaid: amountFormatted,
currency: invoice.currency,
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
hostedInvoiceUrl: invoice.hosted_invoice_url,
}),
);
const attachments: { filename: string; content: Buffer }[] = [];
if (invoice.invoice_pdf) {
const pdfBuffer = await downloadPdf(invoice.invoice_pdf);
if (pdfBuffer) {
attachments.push({
filename: `dokploy-invoice-${invoice.number || invoice.id}.pdf`,
content: pdfBuffer,
});
}
}
await sendEmail({
email: admin.email,
subject: `Dokploy Invoice ${invoice.number || ""} - ${amountFormatted}`,
text: htmlContent,
attachments,
});
console.log(
`Invoice email sent to ${admin.email} for invoice ${invoice.number}`,
);
} catch (error) {
console.error(
`Failed to send invoice email to ${admin.email}:`,
error instanceof Error ? error.message : error,
);
}
};
export const sendPaymentFailedEmail = async (
invoice: Stripe.Invoice,
admin: { email: string; firstName: string },
) => {
if (!invoice.hosted_invoice_url) return;
try {
const amountFormatted = formatAmount(invoice.amount_due, invoice.currency);
const htmlContent = await renderAsync(
PaymentFailedEmail({
userName: admin.firstName || "User",
invoiceNumber: invoice.number || invoice.id,
amountDue: amountFormatted,
currency: invoice.currency,
date: format(new Date(invoice.created * 1000), "MMM dd, yyyy"),
hostedInvoiceUrl: invoice.hosted_invoice_url,
}),
);
await sendEmail({
email: admin.email,
subject: `Action required: Dokploy payment failed - ${amountFormatted}`,
text: htmlContent,
});
console.log(
`Payment failed email sent to ${admin.email} for invoice ${invoice.number}`,
);
} catch (error) {
console.error(
`Failed to send payment failed email to ${admin.email}:`,
error instanceof Error ? error.message : error,
);
}
};

View File

@@ -33,7 +33,7 @@ app.use(async (c, next) => {
app.post("/create-backup", zValidator("json", jobQueueSchema), async (c) => {
const data = c.req.valid("json");
scheduleJob(data);
await scheduleJob(data);
logger.info({ data }, `[${data.type}] created successfully`);
return c.json({ message: `[${data.type}] created successfully` });
});
@@ -70,7 +70,7 @@ app.post("/update-backup", zValidator("json", jobQueueSchema), async (c) => {
}
logger.info({ result }, "Job removed");
}
scheduleJob(data);
await scheduleJob(data);
logger.info("Backup updated successfully");
return c.json({ message: "Backup updated successfully" });
@@ -103,8 +103,8 @@ process.on("uncaughtException", (err) => {
logger.error(err, "Uncaught exception");
});
process.on("unhandledRejection", (reason, promise) => {
logger.error({ promise, reason }, "Unhandled Rejection at: Promise");
process.on("unhandledRejection", (reason, _promise) => {
logger.error(reason instanceof Error ? reason : { reason: String(reason) }, "Unhandled Rejection at: Promise");
});
const port = Number.parseInt(process.env.PORT || "3000");

View File

@@ -21,28 +21,28 @@ export const cleanQueue = async () => {
}
};
export const scheduleJob = (job: QueueJob) => {
export const scheduleJob = async (job: QueueJob) => {
if (job.type === "backup") {
jobQueue.add(job.backupId, job, {
await jobQueue.add(job.backupId, job, {
repeat: {
pattern: job.cronSchedule,
},
});
} else if (job.type === "server") {
jobQueue.add(`${job.serverId}-cleanup`, job, {
await jobQueue.add(`${job.serverId}-cleanup`, job, {
repeat: {
pattern: job.cronSchedule,
},
});
} else if (job.type === "schedule") {
jobQueue.add(job.scheduleId, job, {
await jobQueue.add(job.scheduleId, job, {
repeat: {
pattern: job.cronSchedule,
tz: job.timezone || "UTC",
},
});
} else if (job.type === "volume-backup") {
jobQueue.add(job.volumeBackupId, job, {
await jobQueue.add(job.volumeBackupId, job, {
repeat: {
pattern: job.cronSchedule,
},

View File

@@ -135,11 +135,15 @@ export const initializeJobs = async () => {
for (const server of servers) {
const { serverId } = server;
scheduleJob({
serverId,
type: "server",
cronSchedule: CLEANUP_CRON_JOB,
});
try {
await scheduleJob({
serverId,
type: "server",
cronSchedule: CLEANUP_CRON_JOB,
});
} catch (error) {
logger.error(error, `Failed to schedule cleanup job for server ${serverId}`);
}
}
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
@@ -157,11 +161,15 @@ export const initializeJobs = async () => {
});
for (const backup of backupsResult) {
scheduleJob({
backupId: backup.backupId,
type: "backup",
cronSchedule: backup.schedule,
});
try {
await scheduleJob({
backupId: backup.backupId,
type: "backup",
cronSchedule: backup.schedule,
});
} catch (error) {
logger.error(error, `Failed to schedule backup ${backup.backupId}`);
}
}
logger.info({ Quantity: backupsResult.length }, "Backups Initialized");
@@ -197,11 +205,15 @@ export const initializeJobs = async () => {
);
for (const schedule of filteredSchedulesBasedOnServerStatus) {
scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
try {
await scheduleJob({
scheduleId: schedule.scheduleId,
type: "schedule",
cronSchedule: schedule.cronExpression,
});
} catch (error) {
logger.error(error, `Failed to schedule ${schedule.scheduleId}`);
}
}
logger.info(
{ Quantity: filteredSchedulesBasedOnServerStatus.length },
@@ -236,11 +248,15 @@ export const initializeJobs = async () => {
);
for (const volumeBackup of filteredVolumeBackupsBasedOnServerStatus) {
scheduleJob({
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
cronSchedule: volumeBackup.cronExpression,
});
try {
await scheduleJob({
volumeBackupId: volumeBackup.volumeBackupId,
type: "volume-backup",
cronSchedule: volumeBackup.cronExpression,
});
} catch (error) {
logger.error(error, `Failed to schedule volume backup ${volumeBackup.volumeBackupId}`);
}
}
logger.info(

View File

@@ -65,6 +65,9 @@ export const user = pgTable("user", {
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
sendInvoiceNotifications: boolean("sendInvoiceNotifications")
.notNull()
.default(false),
isEnterpriseCloud: boolean("isEnterpriseCloud").notNull().default(false),
trustedOrigins: text("trustedOrigins").array(),
bookmarkedTemplates: text("bookmarkedTemplates")

View File

@@ -0,0 +1,171 @@
import {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
userName: string;
invoiceNumber: string;
amountPaid: string;
currency: string;
date: string;
hostedInvoiceUrl: string;
};
export const InvoiceNotificationEmail = ({
userName = "User",
invoiceNumber = "INV-0001",
amountPaid = "$4.50",
currency = "usd",
date = "2024-01-01",
hostedInvoiceUrl = "https://invoice.stripe.com/example",
}: TemplateProps) => {
const previewText = `Your Dokploy invoice ${invoiceNumber} for ${amountPaid} is ready`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
{/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
{/* Body */}
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Invoice Payment Confirmed
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, thank you for your payment. Here's a summary
of your invoice.
</Text>
{/* Invoice Details Card */}
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
<Row className="bg-[#fafafa]">
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Invoice No.
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{invoiceNumber}
</Text>
</Column>
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Date
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{date}
</Text>
</Column>
</Row>
<Hr className="border-[#e4e4e7] m-0" />
<Row>
<Column className="px-[20px] py-[14px]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Amount Paid
</Text>
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
{amountPaid}{" "}
<span className="text-[#71717a] text-[12px] font-normal uppercase">
{currency}
</span>
</Text>
</Column>
</Row>
</Section>
{/* Status Badge */}
<Section className="mb-[24px]">
<Row>
<Column>
<div
className="inline-block rounded-full bg-[#dcfce7] px-[12px] py-[6px]"
style={{ display: "inline-block" }}
>
<Text className="text-[#15803d] text-[12px] font-semibold m-0">
Payment Successful
</Text>
</div>
</Column>
</Row>
</Section>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={hostedInvoiceUrl}
className="bg-[#09090b] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
View Invoice Online
</Button>
</Section>
<Text className="text-[#a1a1aa] text-[13px] leading-[20px] m-0 text-center">
A PDF copy of this invoice is attached to this email for your
records.
</Text>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This is an automated email from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you have any questions about your billing, please contact
our{" "}
<Link
href="https://discord.gg/2tBnJ3jDJc"
className="text-[#71717a] underline"
>
support team
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default InvoiceNotificationEmail;

View File

@@ -0,0 +1,175 @@
import {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type TemplateProps = {
userName: string;
invoiceNumber: string;
amountDue: string;
currency: string;
date: string;
hostedInvoiceUrl: string;
};
export const PaymentFailedEmail = ({
userName = "User",
invoiceNumber = "INV-0001",
amountDue = "$4.50",
currency = "usd",
date = "2024-01-01",
hostedInvoiceUrl = "https://invoice.stripe.com/example",
}: TemplateProps) => {
const previewText = `Action required: Your Dokploy payment for ${amountDue} failed`;
return (
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind
config={{
theme: {
extend: {
colors: {
brand: "#007291",
},
},
},
}}
>
<Body className="bg-[#f4f4f5] my-auto mx-auto font-sans">
<Container className="my-[40px] mx-auto max-w-[520px]">
{/* Header */}
<Section className="bg-[#09090b] rounded-t-xl px-[40px] py-[32px] text-center">
<Img
src="https://raw.githubusercontent.com/Dokploy/website/refs/heads/main/apps/docs/public/logo-dokploy-blackpng.png"
width="190"
height="120"
alt="Dokploy"
className="my-0 mx-auto"
/>
</Section>
{/* Body */}
<Section className="bg-white px-[40px] py-[32px]">
<Heading className="text-[#09090b] text-[22px] font-semibold m-0 mb-[8px]">
Payment Failed
</Heading>
<Text className="text-[#71717a] text-[14px] leading-[22px] m-0 mb-[24px]">
Hello {userName}, we were unable to process your payment. Please
update your payment method to avoid service interruption.
</Text>
{/* Invoice Details Card */}
<Section className="border border-solid border-[#e4e4e7] rounded-lg overflow-hidden mb-[24px]">
<Row className="bg-[#fafafa]">
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Invoice No.
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{invoiceNumber}
</Text>
</Column>
<Column className="px-[20px] py-[14px] w-[50%]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Date
</Text>
<Text className="text-[#09090b] text-[14px] font-semibold m-0 mt-[4px]">
{date}
</Text>
</Column>
</Row>
<Hr className="border-[#e4e4e7] m-0" />
<Row>
<Column className="px-[20px] py-[14px]">
<Text className="text-[#71717a] text-[12px] font-medium uppercase tracking-wider m-0">
Amount Due
</Text>
<Text className="text-[#09090b] text-[20px] font-bold m-0 mt-[4px]">
{amountDue}{" "}
<span className="text-[#71717a] text-[12px] font-normal uppercase">
{currency}
</span>
</Text>
</Column>
</Row>
</Section>
{/* Status Badge */}
<Section className="mb-[24px]">
<Row>
<Column>
<div
className="inline-block rounded-full bg-[#fee2e2] px-[12px] py-[6px]"
style={{ display: "inline-block" }}
>
<Text className="text-[#dc2626] text-[12px] font-semibold m-0">
Payment Failed
</Text>
</div>
</Column>
</Row>
</Section>
{/* Warning */}
<Section className="bg-[#fefce8] border border-solid border-[#fef08a] rounded-lg px-[20px] py-[16px] mb-[24px]">
<Text className="text-[#854d0e] text-[13px] leading-[20px] m-0">
If the payment issue is not resolved, your servers will be
deactivated. Please update your payment method as soon as
possible.
</Text>
</Section>
{/* CTA Button */}
<Section className="text-center mb-[24px]">
<Button
href={hostedInvoiceUrl}
className="bg-[#dc2626] rounded-lg text-white text-[14px] font-semibold no-underline text-center px-[24px] py-[12px]"
>
Update Payment Method
</Button>
</Section>
</Section>
{/* Footer */}
<Section className="bg-[#fafafa] rounded-b-xl px-[40px] py-[24px] text-center border-t border-solid border-[#e4e4e7]">
<Text className="text-[#a1a1aa] text-[12px] leading-[18px] m-0">
This is an automated email from{" "}
<Link
href="https://dokploy.com"
className="text-[#71717a] underline"
>
Dokploy Cloud
</Link>
. If you have any questions about your billing, please contact
our{" "}
<Link
href="https://discord.gg/2tBnJ3jDJc"
className="text-[#71717a] underline"
>
support team
</Link>
.
</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
};
export default PaymentFailedEmail;

View File

@@ -440,17 +440,16 @@ export const removeCompose = async (
}
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
cd ${projectPath} && env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
docker network disconnect ${compose.appName} dokploy-traefik;
env -i PATH="$PATH" docker compose -p ${compose.appName} down ${
deleteVolumes ? "--volumes" : ""
} && rm -rf ${projectPath}`;
};
rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command, {
cwd: projectPath,
});
await execAsync(command);
}
}
} catch (error) {

View File

@@ -355,14 +355,45 @@ export const getContainersByAppLabel = async (
};
export const getContainerLogs = async (
appName: string,
appNameOrId: string,
tail = 100,
since = "all",
search?: string,
serverId?: string | null,
useContainerIdDirectly = false,
): Promise<string> => {
const exec = (cmd: string) =>
serverId ? execAsyncRemote(serverId, cmd) : execAsync(cmd);
let target = appNameOrId;
let isService = false;
if (!useContainerIdDirectly) {
// Find the real container ID by appName filter
const findResult = await exec(
`docker ps -q --filter "name=^${appNameOrId}" | head -1`,
);
const containerId = findResult.stdout.trim();
if (!containerId) {
// Fallback: try as a swarm service
const svcResult = await exec(
`docker service ls -q --filter "name=${appNameOrId}" | head -1`,
);
const serviceId = svcResult.stdout.trim();
if (!serviceId) {
throw new Error(`No container or service found for: ${appNameOrId}`);
}
isService = true;
} else {
target = containerId;
}
}
const sinceFlag = since === "all" ? "" : `--since ${since}`;
const baseCommand = `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${appName}`;
const baseCommand = isService
? `docker service logs --timestamps --raw --tail ${tail} ${sinceFlag} ${target}`
: `docker container logs --timestamps --tail ${tail} ${sinceFlag} ${target}`;
const escapedSearch = search?.replace(/'/g, "'\\''") ?? "";
const command = search
@@ -370,10 +401,7 @@ export const getContainerLogs = async (
: `${baseCommand} 2>&1`;
try {
const result = serverId
? await execAsyncRemote(serverId, command)
: await execAsync(command);
const result = await exec(command);
return result.stdout;
} catch (error: unknown) {
if (

View File

@@ -106,6 +106,7 @@ export const getCreateEnvFileCommand = (compose: ComposeNested) => {
const envFilePath = join(dirname(composeFilePath), ".env");
let envContent = `APP_NAME=${appName}\n`;
envContent += `COMPOSE_PROJECT_NAME=${appName}\n`;
envContent += env || "";
if (!envContent.includes("DOCKER_CONFIG")) {
envContent += "\nDOCKER_CONFIG=/root/.docker";

View File

@@ -19,6 +19,7 @@ export const sendEmailNotification = async (
connection: typeof email.$inferInsert,
subject: string,
htmlContent: string,
attachments?: { filename: string; content: Buffer }[],
) => {
try {
const {
@@ -41,6 +42,7 @@ export const sendEmailNotification = async (
subject,
html: htmlContent,
textEncoding: "base64",
attachments,
});
} catch (err) {
console.log(err);

View File

@@ -151,16 +151,18 @@ export const createRouterConfig = async (
routerConfig.middlewares?.push("redirect-to-https");
} else {
// Add path rewriting middleware if needed
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
// stripPrefix must come before addPrefix so Traefik strips the
// public path first, then prepends the internal path.
if (stripPath && path && path !== "/") {
const stripMiddleware = `stripprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(stripMiddleware);
}
if (internalPath && internalPath !== "/" && internalPath !== path) {
const pathMiddleware = `addprefix-${appName}-${uniqueConfigKey}`;
routerConfig.middlewares?.push(pathMiddleware);
}
// redirects - skip for preview deployments as wildcard subdomains
// should not inherit parent redirect rules (e.g., www-redirect)
if (domain.domainType !== "preview") {

View File

@@ -3,10 +3,12 @@ export const sendEmail = async ({
email,
subject,
text,
attachments,
}: {
email: string;
subject: string;
text: string;
attachments?: { filename: string; content: Buffer }[];
}) => {
await sendEmailNotification(
{
@@ -19,6 +21,7 @@ export const sendEmail = async ({
},
subject,
text,
attachments,
);
return true;