Merge branch 'canary' of github.com:ChristoferMendes/dokploy into feature/add-custom-webhook-notification-provider

This commit is contained in:
ChristoferMendes
2025-10-31 11:48:35 -03:00
59 changed files with 15659 additions and 512 deletions
+2
View File
@@ -48,6 +48,7 @@ const baseApp: ApplicationNested = {
dockerBuildStage: "", dockerBuildStage: "",
isPreviewDeploymentsActive: false, isPreviewDeploymentsActive: false,
previewBuildArgs: null, previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none", previewCertificateType: "none",
previewCustomCertResolver: null, previewCustomCertResolver: null,
previewEnv: null, previewEnv: null,
@@ -73,6 +74,7 @@ const baseApp: ApplicationNested = {
}, },
}, },
buildArgs: null, buildArgs: null,
buildSecrets: null,
buildPath: "/", buildPath: "/",
gitlabPathNamespace: "", gitlabPathNamespace: "",
buildType: "nixpacks", buildType: "nixpacks",
@@ -228,5 +228,58 @@ describe("helpers functions", () => {
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
); );
}); });
it("should handle JWT payload with newlines and whitespace by trimming them", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithNewlines = `{
"role": "anon",
"iss": "supabase",
"exp": ${expiry}
}
`;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithNewlines,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("anon");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
it("should handle JWT payload with leading and trailing whitespace", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const payloadWithWhitespace = ` {"role": "service_role", "iss": "supabase", "exp": ${expiry}} `;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: payloadWithWhitespace,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("role");
expect(decodedPayload.role).toEqual("service_role");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("supabase");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
});
}); });
}); });
@@ -25,8 +25,10 @@ const baseApp: ApplicationNested = {
registryUrl: "", registryUrl: "",
watchPaths: [], watchPaths: [],
buildArgs: null, buildArgs: null,
buildSecrets: null,
isPreviewDeploymentsActive: false, isPreviewDeploymentsActive: false,
previewBuildArgs: null, previewBuildArgs: null,
previewBuildSecrets: null,
triggerType: "push", triggerType: "push",
previewCertificateType: "none", previewCertificateType: "none",
previewEnv: null, previewEnv: null,
@@ -318,7 +318,7 @@ export const AddVolumes = ({
control={form.control} control={form.control}
name="content" name="content"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem className="max-w-full max-w-[45rem]">
<FormLabel>Content</FormLabel> <FormLabel>Content</FormLabel>
<FormControl> <FormControl>
<FormControl> <FormControl>
@@ -327,7 +327,7 @@ export const AddVolumes = ({
placeholder={`NODE_ENV=production placeholder={`NODE_ENV=production
PORT=3000 PORT=3000
`} `}
className="h-96 font-mono" className="h-96 font-mono "
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -1,6 +1,8 @@
import { Loader2 } from "lucide-react"; import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { import {
Dialog, Dialog,
@@ -29,9 +31,10 @@ export const ShowDeployment = ({
const [data, setData] = useState(""); const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false); const [showExtraLogs, setShowExtraLogs] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]); const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance const wsRef = useRef<WebSocket | null>(null);
const [autoScroll, setAutoScroll] = useState(true); const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const scrollToBottom = () => { const scrollToBottom = () => {
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
@@ -106,6 +109,20 @@ export const ShowDeployment = ({
} }
}, [filteredLogs, autoScroll]); }, [filteredLogs, autoScroll]);
const handleCopy = () => {
const logContent = filteredLogs
.map(({ timestamp, message }: LogLine) =>
`${timestamp?.toISOString() || ""} ${message}`.trim(),
)
.join("\n");
const success = copy(logContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const optionalErrors = parseLogs(errorMessage || ""); const optionalErrors = parseLogs(errorMessage || "");
return ( return (
@@ -128,13 +145,27 @@ export const ShowDeployment = ({
<DialogHeader> <DialogHeader>
<DialogTitle>Deployment</DialogTitle> <DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2"> <DialogDescription className="flex items-center gap-2">
<span> <span className="flex items-center gap-2">
See all the details of this deployment |{" "} See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs"> <Badge variant="blank" className="text-xs">
{filteredLogs.length} lines {filteredLogs.length} lines
</Badge> </Badge>
</span> </span>
<Button
variant="outline"
size="sm"
className="h-7"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
{serverId && ( {serverId && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
@@ -108,6 +108,21 @@ export const ShowEnvironment = ({ id, type }: Props) => {
}); });
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return ( return (
<div className="flex w-full flex-col gap-5 "> <div className="flex w-full flex-col gap-5 ">
<Card className="bg-background"> <Card className="bg-background">
@@ -12,6 +12,7 @@ import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({ const addEnvironmentSchema = z.object({
env: z.string(), env: z.string(),
buildArgs: z.string(), buildArgs: z.string(),
buildSecrets: z.string(),
}); });
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>; type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -37,6 +38,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
defaultValues: { defaultValues: {
env: "", env: "",
buildArgs: "", buildArgs: "",
buildSecrets: "",
}, },
resolver: zodResolver(addEnvironmentSchema), resolver: zodResolver(addEnvironmentSchema),
}); });
@@ -44,15 +46,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Watch form values // Watch form values
const currentEnv = form.watch("env"); const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs"); const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const hasChanges = const hasChanges =
currentEnv !== (data?.env || "") || currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || ""); currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
env: data.env || "", env: data.env || "",
buildArgs: data.buildArgs || "", buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
}); });
} }
}, [data, form]); }, [data, form]);
@@ -61,6 +66,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
mutateAsync({ mutateAsync({
env: formData.env, env: formData.env,
buildArgs: formData.buildArgs, buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
applicationId, applicationId,
}) })
.then(async () => { .then(async () => {
@@ -76,9 +82,25 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
form.reset({ form.reset({
env: data?.env || "", env: data?.env || "",
buildArgs: data?.buildArgs || "", buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
}); });
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return ( return (
<Card className="bg-background px-6 pb-6"> <Card className="bg-background px-6 pb-6">
<Form {...form}> <Form {...form}>
@@ -104,13 +126,36 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && ( {data?.buildType === "dockerfile" && (
<Secrets <Secrets
name="buildArgs" name="buildArgs"
title="Build-time Variables" title="Build-time Arguments"
description={ description={
<span> <span>
Available only at build-time. See documentation&nbsp; Arguments are available only at build-time. See
documentation&nbsp;
<a <a
className="text-primary" className="text-primary"
href="https://docs.docker.com/build/guide/build-args/" href="https://docs.docker.com/build/building/variables/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<Secrets
name="buildSecrets"
title="Build-time Secrets"
description={
<span>
Secrets are specially designed for sensitive information and
are only available at build-time. See documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -46,6 +46,7 @@ const schema = z
.object({ .object({
env: z.string(), env: z.string(),
buildArgs: z.string(), buildArgs: z.string(),
buildSecrets: z.string(),
wildcardDomain: z.string(), wildcardDomain: z.string(),
port: z.number(), port: z.number(),
previewLimit: z.number(), previewLimit: z.number(),
@@ -109,6 +110,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
form.reset({ form.reset({
env: data.previewEnv || "", env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "", buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.traefik.me", wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000, port: data.previewPort || 3000,
previewLabels: data.previewLabels || [], previewLabels: data.previewLabels || [],
@@ -127,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
updateApplication({ updateApplication({
previewEnv: formData.env, previewEnv: formData.env,
previewBuildArgs: formData.buildArgs, previewBuildArgs: formData.buildArgs,
previewBuildSecrets: formData.buildSecrets,
previewWildcard: formData.wildcardDomain, previewWildcard: formData.wildcardDomain,
previewPort: formData.port, previewPort: formData.port,
previewLabels: formData.previewLabels, previewLabels: formData.previewLabels,
@@ -467,13 +470,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && ( {data?.buildType === "dockerfile" && (
<Secrets <Secrets
name="buildArgs" name="buildArgs"
title="Build-time Variables" title="Build-time Arguments"
description={ description={
<span> <span>
Available only at build-time. See documentation&nbsp; Arguments are available only at build-time. See
documentation&nbsp;
<a <a
className="text-primary" className="text-primary"
href="https://docs.docker.com/build/guide/build-args/" href="https://docs.docker.com/build/building/variables/"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</span>
}
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<Secrets
name="buildSecrets"
title="Build-time Secrets"
description={
<span>
Secrets are specially designed for sensitive information
and are only available at build-time. See
documentation&nbsp;
<a
className="text-primary"
href="https://docs.docker.com/build/building/secrets/"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -6,6 +6,7 @@ import {
Terminal, Terminal,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -33,6 +34,9 @@ interface Props {
} }
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => { export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
new Set(),
);
const { const {
data: schedules, data: schedules,
isLoading: isLoadingSchedules, isLoading: isLoadingSchedules,
@@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
enabled: !!id, enabled: !!id,
}, },
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } = const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
api.schedule.delete.useMutation(); api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
const { mutateAsync: runManually, isLoading } = const handleRunManually = async (scheduleId: string) => {
api.schedule.runManually.useMutation(); setRunningSchedules((prev) => new Set(prev).add(scheduleId));
try {
await runManually({ scheduleId });
toast.success("Schedule run successfully");
await refetchSchedules();
} catch {
toast.error("Error running schedule");
} finally {
setRunningSchedules((prev) => {
const newSet = new Set(prev);
newSet.delete(scheduleId);
return newSet;
});
}
};
return ( return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]"> <Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
@@ -67,7 +84,6 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
Schedule tasks to run automatically at specified intervals. Schedule tasks to run automatically at specified intervals.
</CardDescription> </CardDescription>
</div> </div>
{schedules && schedules.length > 0 && ( {schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} /> <HandleSchedules id={id} scheduleType={scheduleType} />
)} )}
@@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
</CardHeader> </CardHeader>
<CardContent className="px-0"> <CardContent className="px-0">
{isLoadingSchedules ? ( {isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]"> <div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" /> <Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70"> <span className="text-sm text-muted-foreground/70">
Loading scheduled tasks... Loading scheduled tasks...
@@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
return ( return (
<div <div
key={schedule.scheduleId} key={schedule.scheduleId}
className="flex items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50" className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3 w-full sm:w-auto">
<div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5"> <div className="flex flex-shrink-0 h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<Clock className="size-4 text-primary/70" /> <Clock className="size-4 text-primary/70" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 w-full sm:w-auto">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3"> <h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
{schedule.name} {schedule.name}
@@ -132,16 +148,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
)} )}
</div> </div>
{schedule.command && ( {schedule.command && (
<div className="flex items-center gap-2"> <div className="flex items-start gap-2 max-w-full">
<Terminal className="size-3.5 text-muted-foreground/70" /> <Terminal className="size-3.5 text-muted-foreground/70 flex-shrink-0 mt-0.5" />
<code className="font-mono text-[10px] text-muted-foreground/70"> <code className="font-mono text-[10px] text-muted-foreground/70 break-all max-w-[calc(100%-20px)]">
{schedule.command} {schedule.command}
</code> </code>
</div> </div>
)} )}
</div> </div>
</div> </div>
<div className="flex items-center gap-0.5 md:gap-1.5"> <div className="flex items-center gap-0.5 md:gap-1.5">
<ShowDeploymentsModal <ShowDeploymentsModal
id={schedule.scheduleId} id={schedule.scheduleId}
@@ -149,10 +164,9 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
serverId={serverId || undefined} serverId={serverId || undefined}
> >
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " /> <ClipboardList className="size-4 transition-colors" />
</Button> </Button>
</ShowDeploymentsModal> </ShowDeploymentsModal>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
isLoading={isLoading} disabled={runningSchedules.has(schedule.scheduleId)}
onClick={async () => { onClick={() =>
toast.success("Schedule run successfully"); handleRunManually(schedule.scheduleId)
}
await runManually({
scheduleId: schedule.scheduleId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchSchedules();
})
.catch(() => {
toast.error("Error running schedule");
});
}}
> >
<Play className="size-4 transition-colors" /> {runningSchedules.has(schedule.scheduleId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Run Manual Schedule</TooltipContent> <TooltipContent>Run Manual Schedule</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<HandleSchedules <HandleSchedules
scheduleId={schedule.scheduleId} scheduleId={schedule.scheduleId}
id={id} id={id}
scheduleType={scheduleType} scheduleType={scheduleType}
/> />
<DialogAction <DialogAction
title="Delete Schedule" title="Delete Schedule"
description="Are you sure you want to delete this schedule?" description="Are you sure you want to delete this schedule?"
@@ -214,8 +217,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10"
isLoading={isDeleting} disabled={isDeleting}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button> </Button>
@@ -5,6 +5,7 @@ import {
Play, Play,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@@ -38,6 +39,7 @@ export const ShowVolumeBackups = ({
type = "application", type = "application",
serverId, serverId,
}: Props) => { }: Props) => {
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
const { const {
data: volumeBackups, data: volumeBackups,
isLoading: isLoadingVolumeBackups, isLoading: isLoadingVolumeBackups,
@@ -51,19 +53,33 @@ export const ShowVolumeBackups = ({
enabled: !!id, enabled: !!id,
}, },
); );
const utils = api.useUtils(); const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } = const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
api.volumeBackups.delete.useMutation(); api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually } =
const { mutateAsync: runManually, isLoading } =
api.volumeBackups.runManually.useMutation(); api.volumeBackups.runManually.useMutation();
const handleRunManually = async (volumeBackupId: string) => {
setRunningBackups((prev) => new Set(prev).add(volumeBackupId));
try {
await runManually({ volumeBackupId });
toast.success("Volume backup run successfully");
await refetchVolumeBackups();
} catch {
toast.error("Error running volume backup");
} finally {
setRunningBackups((prev) => {
const newSet = new Set(prev);
newSet.delete(volumeBackupId);
return newSet;
});
}
};
return ( return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]"> <Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0"> <CardHeader className="px-0">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center flex-wrap gap-2">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2"> <CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups Volume Backups
@@ -73,12 +89,10 @@ export const ShowVolumeBackups = ({
intervals. intervals.
</CardDescription> </CardDescription>
</div> </div>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-2">
{volumeBackups && volumeBackups.length > 0 && ( {volumeBackups && volumeBackups.length > 0 && (
<> <>
<HandleVolumeBackups id={id} volumeBackupType={type} /> <HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<RestoreVolumeBackups <RestoreVolumeBackups
id={id} id={id}
@@ -93,7 +107,7 @@ export const ShowVolumeBackups = ({
</CardHeader> </CardHeader>
<CardContent className="px-0"> <CardContent className="px-0">
{isLoadingVolumeBackups ? ( {isLoadingVolumeBackups ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]"> <div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" /> <Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70"> <span className="text-sm text-muted-foreground/70">
Loading volume backups... Loading volume backups...
@@ -113,13 +127,13 @@ export const ShowVolumeBackups = ({
return ( return (
<div <div
key={volumeBackup.volumeBackupId} key={volumeBackup.volumeBackupId}
className="flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50" className="flex flex-col sm:flex-row sm:items-center flex-wrap sm:flex-nowrap gap-y-2 justify-between rounded-lg border p-3 transition-colors bg-muted/50 w-full"
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3 w-full sm:w-auto">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5"> <div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<DatabaseBackup className="size-4 text-primary/70" /> <DatabaseBackup className="size-4 text-primary/70" />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5 w-full sm:w-auto">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium leading-none"> <h3 className="text-sm font-medium leading-none">
{volumeBackup.name} {volumeBackup.name}
@@ -143,18 +157,16 @@ export const ShowVolumeBackups = ({
</div> </div>
</div> </div>
</div> </div>
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
<div className="flex items-center gap-1.5">
<ShowDeploymentsModal <ShowDeploymentsModal
id={volumeBackup.volumeBackupId} id={volumeBackup.volumeBackupId}
type="volumeBackup" type="volumeBackup"
serverId={serverId || undefined} serverId={serverId || undefined}
> >
<Button variant="ghost" size="icon"> <Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " /> <ClipboardList className="size-4 transition-colors" />
</Button> </Button>
</ShowDeploymentsModal> </ShowDeploymentsModal>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -162,25 +174,18 @@ export const ShowVolumeBackups = ({
type="button" type="button"
variant="ghost" variant="ghost"
size="icon" size="icon"
isLoading={isLoading} disabled={runningBackups.has(
onClick={async () => { volumeBackup.volumeBackupId,
toast.success("Volume backup run successfully"); )}
onClick={() =>
await runManually({ handleRunManually(volumeBackup.volumeBackupId)
volumeBackupId: volumeBackup.volumeBackupId, }
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchVolumeBackups();
})
.catch(() => {
toast.error("Error running volume backup");
});
}}
> >
<Play className="size-4 transition-colors" /> {runningBackups.has(volumeBackup.volumeBackupId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -188,13 +193,11 @@ export const ShowVolumeBackups = ({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
<HandleVolumeBackups <HandleVolumeBackups
volumeBackupId={volumeBackup.volumeBackupId} volumeBackupId={volumeBackup.volumeBackupId}
id={id} id={id}
volumeBackupType={type} volumeBackupType={type}
/> />
<DialogAction <DialogAction
title="Delete Volume Backup" title="Delete Volume Backup"
description="Are you sure you want to delete this volume backup?" description="Are you sure you want to delete this volume backup?"
@@ -218,7 +221,7 @@ export const ShowVolumeBackups = ({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="group hover:bg-red-500/10 " className="group hover:bg-red-500/10"
isLoading={isDeleting} isLoading={isDeleting}
> >
<Trash2 className="size-4 text-primary group-hover:text-red-500" /> <Trash2 className="size-4 text-primary group-hover:text-red-500" />
@@ -230,7 +233,7 @@ export const ShowVolumeBackups = ({
})} })}
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg"> <div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<DatabaseBackup className="size-8 mb-4 text-muted-foreground" /> <DatabaseBackup className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground"> <p className="text-lg font-medium text-muted-foreground">
No volume backups No volume backups
@@ -74,6 +74,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
await mutateAsync({ await mutateAsync({
composeId, composeId,
composeFile: data.composeFile, composeFile: data.composeFile,
composePath: "./docker-compose.yml",
sourceType: "raw", sourceType: "raw",
}) })
.then(async () => { .then(async () => {
@@ -1,4 +1,12 @@
import { Download as DownloadIcon, Loader2, Pause, Play } from "lucide-react"; import copy from "copy-to-clipboard";
import {
Check,
Copy,
Download as DownloadIcon,
Loader2,
Pause,
Play,
} from "lucide-react";
import React, { useEffect, useRef } from "react"; import React, { useEffect, useRef } from "react";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -67,6 +75,7 @@ export const DockerLogsId: React.FC<Props> = ({
const isPausedRef = useRef(false); const isPausedRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const scrollToBottom = () => { const scrollToBottom = () => {
if (autoScroll && scrollRef.current) { if (autoScroll && scrollRef.current) {
@@ -237,6 +246,29 @@ export const DockerLogsId: React.FC<Props> = ({
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const handleCopy = async () => {
const logContent = filteredLogs
.map(
({
timestamp,
message,
}: {
timestamp: Date | null;
message: string;
}) =>
showTimestamp
? `${timestamp?.toISOString() || "No timestamp"} ${message}`
: message,
)
.join("\n");
const success = copy(logContent);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleFilter = (logs: LogLine[]) => { const handleFilter = (logs: LogLine[]) => {
return logs.filter((log) => { return logs.filter((log) => {
const logType = getLogType(log.message).type; const logType = getLogType(log.message).type;
@@ -320,6 +352,21 @@ export const DockerLogsId: React.FC<Props> = ({
)} )}
{isPaused ? "Resume" : "Pause"} {isPaused ? "Resume" : "Pause"}
</Button> </Button>
<Button
variant="outline"
size="sm"
className="h-9"
onClick={handleCopy}
disabled={filteredLogs.length === 0}
title="Copy logs to clipboard"
>
{copied ? (
<Check className="mr-2 h-4 w-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
)}
Copy
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -281,7 +281,7 @@ export const ImpersonationBar = () => {
<div className="flex items-center gap-4 flex-1 flex-wrap"> <div className="flex items-center gap-4 flex-1 flex-wrap">
<Avatar className="h-10 w-10"> <Avatar className="h-10 w-10">
<AvatarImage <AvatarImage
className="object-cover" className="object-cover"
src={data?.user?.image || ""} src={data?.user?.image || ""}
alt={data?.user?.name || ""} alt={data?.user?.name || ""}
/> />
@@ -248,7 +248,7 @@ export const AdvancedEnvironmentSelector = ({
</DropdownMenuItem> </DropdownMenuItem>
{/* Action buttons for non-production environments */} {/* Action buttons for non-production environments */}
<EnvironmentVariables environmentId={environment.environmentId}> {/* <EnvironmentVariables environmentId={environment.environmentId}>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -259,7 +259,7 @@ export const AdvancedEnvironmentSelector = ({
> >
<Terminal className="h-3 w-3" /> <Terminal className="h-3 w-3" />
</Button> </Button>
</EnvironmentVariables> </EnvironmentVariables> */}
{environment.name !== "production" && ( {environment.name !== "production" && (
<div className="flex items-center gap-1 px-2"> <div className="flex items-center gap-1 px-2">
<Button <Button
@@ -82,6 +82,21 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
.finally(() => {}); .finally(() => {});
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, isOpen]);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -81,6 +81,21 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.finally(() => {}); .finally(() => {});
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && isOpen) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, isOpen]);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
{(isError || isErrorConnection) && ( {(isError || isErrorConnection) && (
<AlertBlock type="error" className="break-words"> <AlertBlock type="error" className="w-full">
{connectionError?.message || error?.message} {connectionError?.message || error?.message}
</AlertBlock> </AlertBlock>
)} )}
@@ -1,11 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
AlertTriangle,
Mail,
MessageCircleMore,
PenBoxIcon,
PlusIcon,
} from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -13,6 +7,7 @@ import { z } from "zod";
import { import {
DiscordIcon, DiscordIcon,
GotifyIcon, GotifyIcon,
LarkIcon,
NtfyIcon, NtfyIcon,
SlackIcon, SlackIcon,
TelegramIcon, TelegramIcon,
@@ -120,6 +115,12 @@ export const notificationSchema = z.discriminatedUnion("type", [
headers: z.string().optional(), headers: z.string().optional(),
}) })
.merge(notificationBaseSchema), .merge(notificationBaseSchema),
z
.object({
type: z.literal("lark"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]); ]);
export const notificationsMap = { export const notificationsMap = {
@@ -135,6 +136,10 @@ export const notificationsMap = {
icon: <DiscordIcon />, icon: <DiscordIcon />,
label: "Discord", label: "Discord",
}, },
lark: {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
email: { email: {
icon: <Mail size={29} className="text-muted-foreground" />, icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email", label: "Email",
@@ -186,6 +191,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation(); api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } = const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation(); api.notification.testCustomConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const slackMutation = notificationId const slackMutation = notificationId
? api.notification.updateSlack.useMutation() ? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation(); : api.notification.createSlack.useMutation();
@@ -207,6 +214,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const customMutation = notificationId const customMutation = notificationId
? api.notification.updateCustom.useMutation() ? api.notification.updateCustom.useMutation()
: api.notification.createCustom.useMutation(); : api.notification.createCustom.useMutation();
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const form = useForm<NotificationSchema>({ const form = useForm<NotificationSchema>({
defaultValues: { defaultValues: {
@@ -316,6 +326,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
serverUrl: notification.ntfy?.serverUrl, serverUrl: notification.ntfy?.serverUrl,
name: notification.name, name: notification.name,
dockerCleanup: notification.dockerCleanup, dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "lark") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
}); });
} else if (notification.notificationType === "custom") { } else if (notification.notificationType === "custom") {
form.reset({ form.reset({
@@ -344,6 +367,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation, gotify: gotifyMutation,
ntfy: ntfyMutation, ntfy: ntfyMutation,
custom: customMutation, custom: customMutation,
lark: larkMutation,
}; };
const onSubmit = async (data: NotificationSchema) => { const onSubmit = async (data: NotificationSchema) => {
@@ -461,6 +485,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "", notificationId: notificationId || "",
customId: notification?.customId || "", customId: notification?.customId || "",
}); });
} else if (data.type === "lark") {
promise = larkMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
larkId: notification?.larkId || "",
serverThreshold: serverThreshold,
});
} }
if (promise) { if (promise) {
@@ -549,7 +586,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/> />
<Label <Label
htmlFor={key} htmlFor={key}
className="flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer" className="h-24 flex flex-col gap-2 items-center justify-between rounded-md border-2 border-muted bg-popover p-4 hover:bg-accent hover:text-accent-foreground peer-data-[state=checked]:border-primary [&:has([data-state=checked])]:border-primary cursor-pointer"
> >
{value.icon} {value.icon}
{value.label} {value.label}
@@ -1090,6 +1127,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/> />
</div> </div>
)} )}
{type === "lark" && (
<>
<FormField
control={form.control}
name="webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxxxxxxxxx"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div> </div>
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -1241,7 +1299,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingEmail || isLoadingEmail ||
isLoadingGotify || isLoadingGotify ||
isLoadingNtfy || isLoadingNtfy ||
isLoadingCustom isLoadingCustom ||
isLoadingLark
} }
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
@@ -1290,6 +1349,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
endpoint: form.getValues("endpoint") as string, endpoint: form.getValues("endpoint") as string,
headers: form.getValues("headers") as string | undefined, headers: form.getValues("headers") as string | undefined,
}); });
} else if (type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
});
} }
toast.success("Connection Success"); toast.success("Connection Success");
} catch { } catch {
@@ -2,7 +2,6 @@ import {
Bell, Bell,
Loader2, Loader2,
Mail, Mail,
MessageCircleMore,
PenBoxIcon, PenBoxIcon,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
@@ -10,6 +9,7 @@ import { toast } from "sonner";
import { import {
DiscordIcon, DiscordIcon,
GotifyIcon, GotifyIcon,
LarkIcon,
NtfyIcon, NtfyIcon,
SlackIcon, SlackIcon,
TelegramIcon, TelegramIcon,
@@ -42,7 +42,7 @@ export const ShowNotifications = () => {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Add your providers to receive notifications, like Discord, Slack, Add your providers to receive notifications, like Discord, Slack,
Telegram, Email. Telegram, Email, Lark.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-2 py-8 border-t">
@@ -107,6 +107,11 @@ export const ShowNotifications = () => {
<PenBoxIcon className="size-6 text-muted-foreground" /> <PenBoxIcon className="size-6 text-muted-foreground" />
</div> </div>
)} )}
{notification.notificationType === "lark" && (
<div className="flex items-center justify-center rounded-lg">
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name} {notification.name}
</span> </span>
@@ -0,0 +1,429 @@
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import {
CopyIcon,
DownloadIcon,
KeyRound,
RefreshCw,
ShieldOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import {
BACKUP_CODES_PLACEHOLDER,
backupCodeTemplate,
DATE_PLACEHOLDER,
USERNAME_PLACEHOLDER,
} from "./enable-2fa";
const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
});
type PasswordForm = z.infer<typeof PasswordSchema>;
type Step = "password" | "actions" | "backup-codes";
export const Configure2FA = () => {
const utils = api.useUtils();
const { data: currentUser } = api.user.get.useQuery();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<Step>("password");
const [password, setPassword] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const [showDisableConfirm, setShowDisableConfirm] = useState(false);
const [isDisabling, setIsDisabling] = useState(false);
const [isRegenerating, setIsRegenerating] = useState(false);
const form = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
useEffect(() => {
if (!isDialogOpen) {
setStep("password");
setPassword("");
setBackupCodes([]);
form.reset();
}
}, [isDialogOpen, form]);
const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsRegenerating(true);
try {
// Verify password by attempting to generate backup codes
// This validates the password and checks if 2FA is enabled
const result = await authClient.twoFactor.generateBackupCodes({
password: formData.password,
});
if (result.error) {
form.setError("password", { message: result.error.message });
toast.error(result.error.message);
return;
}
// If we get here, password is correct
setPassword(formData.password);
setStep("actions");
} catch (error) {
form.setError("password", {
message: error instanceof Error ? error.message : "Incorrect password",
});
toast.error("Incorrect password");
} finally {
setIsRegenerating(false);
}
};
const handleRegenerateBackupCodes = async () => {
setIsRegenerating(true);
try {
const result = await authClient.twoFactor.generateBackupCodes({
password,
});
if (result.error) {
toast.error(result.error.message);
return;
}
if (result.data?.backupCodes) {
setBackupCodes(result.data.backupCodes);
setStep("backup-codes");
toast.success("Backup codes regenerated successfully");
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to regenerate backup codes",
);
} finally {
setIsRegenerating(false);
}
};
const handleDisable2FA = async () => {
setIsDisabling(true);
try {
const result = await authClient.twoFactor.disable({
password,
});
if (result.error) {
toast.error(result.error.message);
return;
}
toast.success("2FA disabled successfully");
utils.user.get.invalidate();
setIsDialogOpen(false);
setShowDisableConfirm(false);
} catch (error) {
toast.error("Failed to disable 2FA. Please try again.");
} finally {
setIsDisabling(false);
}
};
const handleCloseDialog = () => {
if (step === "backup-codes") {
setStep("actions");
} else {
setIsDialogOpen(false);
}
};
const handleDownloadBackupCodes = () => {
if (!backupCodes || backupCodes.length === 0) {
toast.error("No backup codes to download.");
return;
}
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
const blob = new Blob([backupCodesText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleCopyBackupCodes = () => {
const date = new Date();
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
copy(backupCodesText);
toast.success("Backup codes copied to clipboard");
};
return (
<>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button variant="secondary">
<KeyRound className="size-4 text-muted-foreground" />
Manage 2FA
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>
{step === "password" && "Verify Your Identity"}
{step === "actions" && "2FA Configuration"}
{step === "backup-codes" && "New Backup Codes"}
</DialogTitle>
<DialogDescription>
{step === "password" &&
"Enter your password to manage your 2FA settings"}
{step === "actions" &&
"Choose an action to manage your two-factor authentication"}
{step === "backup-codes" &&
"Save these backup codes in a secure place"}
</DialogDescription>
</DialogHeader>
{step === "password" && (
<Form {...form}>
<form
onSubmit={form.handleSubmit(handlePasswordSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to continue
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isRegenerating}>
Continue
</Button>
</div>
</form>
</Form>
)}
{step === "actions" && (
<div className="space-y-4">
<div className="grid gap-3">
<div className="flex flex-col gap-2 p-4 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center gap-2">
<RefreshCw className="size-4" />
Regenerate Backup Codes
</h4>
<p className="text-sm text-muted-foreground mt-1">
Generate new backup codes to replace your existing ones.
This will invalidate all previous backup codes.
</p>
</div>
</div>
<Button
onClick={handleRegenerateBackupCodes}
variant="outline"
className="w-full mt-2"
isLoading={isRegenerating}
>
<RefreshCw className="size-4 mr-2" />
Regenerate Backup Codes
</Button>
</div>
<div className="flex flex-col gap-2 p-4 border border-destructive/50 rounded-lg hover:bg-destructive/5 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center gap-2 text-destructive">
<ShieldOff className="size-4" />
Disable 2FA
</h4>
<p className="text-sm text-muted-foreground mt-1">
Completely disable two-factor authentication for your
account. This will make your account less secure.
</p>
</div>
</div>
<Button
onClick={() => setShowDisableConfirm(true)}
variant="destructive"
className="w-full mt-2"
>
<ShieldOff className="size-4 mr-2" />
Disable 2FA
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
>
Close
</Button>
</div>
</div>
)}
{step === "backup-codes" && (
<div className="space-y-4">
<div className="w-full space-y-3 border rounded-lg p-4 bg-muted/50">
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code
key={index}
className="bg-background p-2 rounded text-sm font-mono text-center"
>
{code}
</code>
))}
</div>
<p className="text-sm text-muted-foreground">
Save these backup codes in a secure place. You can use them to
access your account if you lose access to your authenticator
device. Each code can only be used once.
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleDownloadBackupCodes}
className="flex-1"
>
<DownloadIcon className="size-4 mr-2" />
Download
</Button>
<Button
variant="outline"
onClick={handleCopyBackupCodes}
className="flex-1"
>
<CopyIcon className="size-4 mr-2" />
Copy
</Button>
</div>
<div className="flex justify-end gap-4">
<Button variant="outline" onClick={handleCloseDialog}>
Back to Actions
</Button>
<Button onClick={() => setIsDialogOpen(false)}>Done</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<AlertDialog
open={showDisableConfirm}
onOpenChange={setShowDisableConfirm}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently disable Two-Factor Authentication for your
account. Your account will be less secure without 2FA enabled.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDisable2FA}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={isDisabling}
>
{isDisabling ? "Disabling..." : "Disable 2FA"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
@@ -1,135 +0,0 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const PasswordSchema = z.object({
password: z.string().min(8, {
message: "Password is required",
}),
});
type PasswordForm = z.infer<typeof PasswordSchema>;
export const Disable2FA = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const form = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const handleSubmit = async (formData: PasswordForm) => {
setIsLoading(true);
try {
const result = await authClient.twoFactor.disable({
password: formData.password,
});
if (result.error) {
form.setError("password", {
message: result.error.message,
});
toast.error(result.error.message);
return;
}
toast.success("2FA disabled successfully");
utils.user.get.invalidate();
setIsOpen(false);
} catch {
form.setError("password", {
message: "Connection error. Please try again.",
});
toast.error("Connection error. Please try again.");
} finally {
setIsLoading(false);
}
};
return (
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">Disable 2FA</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently disable
Two-Factor Authentication for your account.
</AlertDialogDescription>
</AlertDialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
/>
</FormControl>
<FormDescription>
Enter your password to disable 2FA
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => {
form.reset();
setIsOpen(false);
}}
>
Cancel
</Button>
<Button type="submit" variant="destructive" isLoading={isLoading}>
Disable 2FA
</Button>
</div>
</form>
</Form>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { Fingerprint, QrCode } from "lucide-react"; import copy from "copy-to-clipboard";
import { CopyIcon, DownloadIcon, Fingerprint, QrCode } from "lucide-react";
import QRCode from "qrcode"; import QRCode from "qrcode";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -29,6 +30,12 @@ import {
InputOTPGroup, InputOTPGroup,
InputOTPSlot, InputOTPSlot,
} from "@/components/ui/input-otp"; } from "@/components/ui/input-otp";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client"; import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
@@ -54,6 +61,26 @@ type TwoFactorSetupData = {
type PasswordForm = z.infer<typeof PasswordSchema>; type PasswordForm = z.infer<typeof PasswordSchema>;
type PinForm = z.infer<typeof PinSchema>; type PinForm = z.infer<typeof PinSchema>;
export const USERNAME_PLACEHOLDER = "%username%";
export const DATE_PLACEHOLDER = "%date%";
export const BACKUP_CODES_PLACEHOLDER = "%backupCodes%";
export const backupCodeTemplate = `Dokploy - BACKUP VERIFICATION CODES
Points to note
--------------
# Each code can be used only once.
# Do not share these codes with anyone.
Generated codes
---------------
Username: ${USERNAME_PLACEHOLDER}
Generated on: ${DATE_PLACEHOLDER}
${BACKUP_CODES_PLACEHOLDER}
`;
export const Enable2FA = () => { export const Enable2FA = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const [data, setData] = useState<TwoFactorSetupData | null>(null); const [data, setData] = useState<TwoFactorSetupData | null>(null);
@@ -62,6 +89,7 @@ export const Enable2FA = () => {
const [step, setStep] = useState<"password" | "verify">("password"); const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState(""); const [otpValue, setOtpValue] = useState("");
const { data: currentUser } = api.user.get.useQuery();
const handleVerifySubmit = async (e: React.FormEvent) => { const handleVerifySubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -178,6 +206,54 @@ export const Enable2FA = () => {
} }
}; };
const handleDownloadBackupCodes = () => {
if (!backupCodes || backupCodes.length === 0) {
toast.error("No backup codes to download.");
return;
}
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const filename = `dokploy-2fa-backup-codes-${year}${month}${day}.txt`;
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
const blob = new Blob([backupCodesText], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
};
const handleCopyBackupCodes = () => {
const date = new Date();
const backupCodesFormatted = backupCodes
.map((code, index) => ` ${index + 1}. ${code}`)
.join("\n");
const backupCodesText = backupCodeTemplate
.replace(USERNAME_PLACEHOLDER, currentUser?.user?.email || "unknown")
.replace(DATE_PLACEHOLDER, date.toLocaleString())
.replace(BACKUP_CODES_PLACEHOLDER, backupCodesFormatted);
copy(backupCodesText);
toast.success("Backup codes copied to clipboard");
};
return ( return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -264,6 +340,7 @@ export const Enable2FA = () => {
<span className="text-sm font-medium"> <span className="text-sm font-medium">
Scan this QR code with your authenticator app Scan this QR code with your authenticator app
</span> </span>
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
<img <img
src={data.qrCodeUrl} src={data.qrCodeUrl}
alt="2FA QR Code" alt="2FA QR Code"
@@ -281,7 +358,46 @@ export const Enable2FA = () => {
{backupCodes && backupCodes.length > 0 && ( {backupCodes && backupCodes.length > 0 && (
<div className="w-full space-y-3 border rounded-lg p-4"> <div className="w-full space-y-3 border rounded-lg p-4">
<h4 className="font-medium">Backup Codes</h4> <div className="flex items-center justify-between">
<h4 className="font-medium">Backup Codes</h4>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleCopyBackupCodes}
>
<CopyIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Copy</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon"
onClick={handleDownloadBackupCodes}
>
<DownloadIcon className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => ( {backupCodes.map((code, index) => (
<code <code
@@ -29,7 +29,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils"; import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { Disable2FA } from "./disable-2fa"; import { Configure2FA } from "./configure-2fa";
import { Enable2FA } from "./enable-2fa"; import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({ const profileSchema = z.object({
@@ -62,7 +62,6 @@ const randomImages = [
]; ];
export const ProfileForm = () => { export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery(); const { data, refetch, isLoading } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -120,28 +119,27 @@ export const ProfileForm = () => {
}, [form, data]); }, [form, data]);
const onSubmit = async (values: Profile) => { const onSubmit = async (values: Profile) => {
await mutateAsync({ try {
email: values.email.toLowerCase(), await mutateAsync({
password: values.password || undefined, email: values.email.toLowerCase(),
image: values.image, password: values.password || undefined,
currentPassword: values.currentPassword || undefined, image: values.image,
allowImpersonation: values.allowImpersonation, currentPassword: values.currentPassword || undefined,
name: values.name || undefined, allowImpersonation: values.allowImpersonation,
}) name: values.name || undefined,
.then(async () => {
await refetch();
toast.success("Profile Updated");
form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
});
})
.catch(() => {
toast.error("Error updating the profile");
}); });
await refetch();
toast.success("Profile Updated");
form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
name: values.name || "",
});
} catch (error) {
toast.error("Error updating the profile");
}
}; };
return ( return (
@@ -158,7 +156,8 @@ export const ProfileForm = () => {
{t("settings.profile.description")} {t("settings.profile.description")}
</CardDescription> </CardDescription>
</div> </div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
</CardHeader> </CardHeader>
<CardContent className="space-y-2 py-8 border-t"> <CardContent className="space-y-2 py-8 border-t">
@@ -304,6 +303,7 @@ export const ProfileForm = () => {
} }
> >
{field.value?.startsWith("data:") ? ( {field.value?.startsWith("data:") ? (
// biome-ignore lint/performance/noImgElement: this is an justified use of img element
<img <img
src={field.value} src={field.value}
alt="Custom avatar" alt="Custom avatar"
@@ -362,6 +362,7 @@ export const ProfileForm = () => {
/> />
</FormControl> </FormControl>
{/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */}
<img <img
key={image} key={image}
src={image} src={image}
@@ -75,6 +75,21 @@ export const EditTraefikEnv = ({ children, serverId }: Props) => {
}); });
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading && !canEdit) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading, canEdit]);
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild>{children}</DialogTrigger> <DialogTrigger asChild>{children}</DialogTrigger>
@@ -88,7 +88,32 @@ export const DiscordIcon = ({ className }: Props) => {
</svg> </svg>
); );
}; };
export const LarkIcon = ({ className }: Props) => {
return (
<svg
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
data-icon="LarkLogoColorful"
className={cn("size-9", className)}
>
<path
d="m12.924 12.803.056-.054c.038-.034.076-.072.11-.11l.077-.076.23-.227 1.334-1.319.335-.331c.063-.063.13-.123.195-.183a7.777 7.777 0 0 1 1.823-1.24 7.607 7.607 0 0 1 1.014-.4 13.177 13.177 0 0 0-2.5-5.013 1.203 1.203 0 0 0-.94-.448h-9.65c-.173 0-.246.224-.107.325a28.23 28.23 0 0 1 8 9.098c.007-.006.016-.013.023-.022Z"
fill="#00D6B9"
/>
<path
d="M9.097 21.299a13.258 13.258 0 0 0 11.82-7.247 5.576 5.576 0 0 1-.731 1.076 5.315 5.315 0 0 1-.745.7 5.117 5.117 0 0 1-.615.404 4.626 4.626 0 0 1-.726.331 5.312 5.312 0 0 1-1.883.312 5.892 5.892 0 0 1-.524-.031 6.509 6.509 0 0 1-.729-.126c-.06-.016-.12-.029-.18-.044-.166-.044-.33-.092-.494-.14-.082-.024-.164-.046-.246-.072-.123-.038-.247-.072-.366-.11l-.3-.095-.284-.094-.192-.067c-.08-.025-.155-.053-.234-.082a3.49 3.49 0 0 1-.167-.06c-.11-.04-.221-.079-.328-.12-.063-.025-.126-.047-.19-.072l-.252-.098c-.088-.035-.18-.07-.268-.107l-.174-.07c-.072-.028-.141-.06-.214-.088l-.164-.07c-.057-.024-.114-.05-.17-.075l-.149-.066-.135-.06-.14-.063a90.183 90.183 0 0 1-.141-.066 4.808 4.808 0 0 0-.18-.083c-.063-.028-.123-.06-.186-.088a5.697 5.697 0 0 1-.199-.098 27.762 27.762 0 0 1-8.067-5.969.18.18 0 0 0-.312.123l.006 9.21c0 .4.199.779.533 1a13.177 13.177 0 0 0 7.326 2.205Z"
fill="#3370FF"
/>
<path
d="M23.732 9.295a7.55 7.55 0 0 0-3.35-.776 7.521 7.521 0 0 0-2.284.35c-.054.016-.107.035-.158.05a8.297 8.297 0 0 0-.855.35 7.14 7.14 0 0 0-.552.297 6.716 6.716 0 0 0-.533.347c-.123.089-.243.18-.363.275-.13.104-.252.211-.375.321-.067.06-.13.123-.196.184l-.334.328-1.338 1.321-.23.228-.076.075c-.038.038-.076.073-.11.11l-.057.054a1.914 1.914 0 0 1-.085.08c-.032.028-.063.06-.095.088a13.286 13.286 0 0 1-2.748 1.946c.06.028.12.057.18.082l.142.066c.044.022.091.041.139.063l.135.06.149.067.17.075.164.07c.073.031.142.06.215.088.056.025.116.047.173.07.088.034.177.072.268.107.085.031.168.066.253.098l.189.072c.11.041.218.082.328.12.057.019.11.041.167.06.08.028.155.053.234.082l.192.066.284.095.3.095c.123.037.243.075.366.11l.246.072c.164.048.331.095.495.14.06.015.12.03.18.043.114.029.227.05.34.07.13.022.26.04.389.057a5.815 5.815 0 0 0 .994.019 5.172 5.172 0 0 0 1.413-.3 5.405 5.405 0 0 0 .726-.334c.06-.035.122-.07.182-.108a7.96 7.96 0 0 0 .432-.297 5.362 5.362 0 0 0 .577-.517 5.285 5.285 0 0 0 .37-.429 5.797 5.797 0 0 0 .527-.827l.13-.258 1.166-2.325-.003.006a7.391 7.391 0 0 1 1.527-2.186Z"
fill="#133C9A"
/>
</svg>
);
};
export const GotifyIcon = ({ className }: Props) => { export const GotifyIcon = ({ className }: Props) => {
return ( return (
<svg <svg
+1 -1
View File
@@ -44,7 +44,7 @@ export const UserNav = () => {
> >
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-lg">
<AvatarImage <AvatarImage
className="object-cover" className="object-cover"
src={data?.user?.image || ""} src={data?.user?.image || ""}
alt={data?.user?.image || ""} alt={data?.user?.image || ""}
/> />
@@ -39,13 +39,19 @@ export function AlertBlock({
<div <div
{...props} {...props}
className={cn( className={cn(
"flex items-center flex-row gap-4 rounded-lg p-2", "flex items-start flex-row gap-4 rounded-lg p-2",
iconClassName, iconClassName,
className, className,
)} )}
> >
{icon || <Icon className="text-current" />} <div className="flex-shrink-0 mt-0.5">
<span className="text-sm text-current">{children}</span> {icon || <Icon className="text-current" />}
</div>
<div className="flex-1 min-w-0">
<span className="text-sm text-current break-words overflow-wrap-anywhere whitespace-pre-wrap">
{children}
</span>
</div>
</div> </div>
); );
} }
+3
View File
@@ -55,6 +55,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref, ref,
) => { ) => {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
const type = props.type ?? undefined;
return ( return (
<> <>
<Comp <Comp
@@ -65,6 +67,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref} ref={ref}
{...props} {...props}
disabled={isLoading || props.disabled} disabled={isLoading || props.disabled}
type={type}
> >
{isLoading && <Loader2 className="animate-spin" />} {isLoading && <Loader2 className="animate-spin" />}
<Slottable>{children}</Slottable> <Slottable>{children}</Slottable>
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "previewBuildSecrets" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildSecrets" text;
@@ -0,0 +1,8 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'lark';--> statement-breakpoint
CREATE TABLE "lark" (
"larkId" text PRIMARY KEY NOT NULL,
"webhookUrl" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "larkId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_larkId_lark_larkId_fk" FOREIGN KEY ("larkId") REFERENCES "public"."lark"("larkId") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -820,6 +820,20 @@
"when": 1759645163834, "when": 1759645163834,
"tag": "0116_amusing_firedrake", "tag": "0116_amusing_firedrake",
"breakpoints": true "breakpoints": true
},
{
"idx": 117,
"version": "7",
"when": 1761370953274,
"tag": "0117_lumpy_nuke",
"breakpoints": true
},
{
"idx": 118,
"version": "7",
"when": 1761415824484,
"tag": "0118_loose_anita_blake",
"breakpoints": true
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.25.5", "version": "v0.25.6",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -368,14 +368,14 @@ export const extractCommitedPaths = async (
.map((change: any) => change.new?.target?.hash) .map((change: any) => change.new?.target?.hash)
.filter(Boolean); .filter(Boolean);
const commitedPaths: string[] = []; const commitedPaths: string[] = [];
const username =
bitbucket?.bitbucketWorkspaceName || bitbucket?.bitbucketUsername || "";
for (const commit of commitHashes) { for (const commit of commitHashes) {
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucket?.bitbucketUsername}/${repository}/diffstat/${commit}`; const url = `https://api.bitbucket.org/2.0/repositories/${username}/${repository}/diffstat/${commit}`;
try { try {
const response = await fetch(url, { const response = await fetch(url, {
headers: getBitbucketHeaders(bitbucket!), headers: getBitbucketHeaders(bitbucket!),
}); });
const data = await response.json(); const data = await response.json();
for (const value of data.values) { for (const value of data.values) {
if (value?.new?.path) commitedPaths.push(value.new.path); if (value?.new?.path) commitedPaths.push(value.new.path);
@@ -14,6 +14,7 @@ import {
PlusIcon, PlusIcon,
Search, Search,
ServerIcon, ServerIcon,
SquareTerminal,
Trash2, Trash2,
X, X,
} from "lucide-react"; } from "lucide-react";
@@ -33,6 +34,7 @@ import { AddDatabase } from "@/components/dashboard/project/add-database";
import { AddTemplate } from "@/components/dashboard/project/add-template"; import { AddTemplate } from "@/components/dashboard/project/add-template";
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector"; import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
import { DuplicateProject } from "@/components/dashboard/project/duplicate-project"; import { DuplicateProject } from "@/components/dashboard/project/duplicate-project";
import { EnvironmentVariables } from "@/components/dashboard/project/environment-variables";
import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment"; import { ProjectEnvironment } from "@/components/dashboard/projects/project-environment";
import { import {
MariadbIcon, MariadbIcon,
@@ -46,6 +48,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -95,7 +98,6 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
export type Services = { export type Services = {
appName: string; appName: string;
@@ -776,6 +778,11 @@ const EnvironmentPage = (
projectId={projectId} projectId={projectId}
currentEnvironmentId={environmentId} currentEnvironmentId={environmentId}
/> />
<EnvironmentVariables environmentId={environmentId}>
<Button variant="ghost" size="icon">
<SquareTerminal className="size-5 text-muted-foreground cursor-pointer" />
</Button>
</EnvironmentVariables>
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{currentEnvironment.description || "No description provided"} {currentEnvironment.description || "No description provided"}
+6
View File
@@ -62,6 +62,12 @@ export const aiRouter = createTRPCRouter({
case "ollama": case "ollama":
response = await fetch(`${input.apiUrl}/api/tags`, { headers }); response = await fetch(`${input.apiUrl}/api/tags`, { headers });
break; break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
{ headers: {} },
);
break;
default: default:
if (!input.apiKey) if (!input.apiKey)
throw new TRPCError({ throw new TRPCError({
@@ -360,6 +360,7 @@ export const applicationRouter = createTRPCRouter({
await updateApplication(input.applicationId, { await updateApplication(input.applicationId, {
env: input.env, env: input.env,
buildArgs: input.buildArgs, buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
}); });
return true; return true;
}), }),
@@ -2,6 +2,7 @@ import {
createCustomNotification, createCustomNotification,
createDiscordNotification, createDiscordNotification,
createEmailNotification, createEmailNotification,
createLarkNotification,
createGotifyNotification, createGotifyNotification,
createNtfyNotification, createNtfyNotification,
createSlackNotification, createSlackNotification,
@@ -12,6 +13,7 @@ import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendLarkNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification, sendNtfyNotification,
sendServerThresholdNotifications, sendServerThresholdNotifications,
@@ -20,6 +22,7 @@ import {
updateCustomNotification, updateCustomNotification,
updateDiscordNotification, updateDiscordNotification,
updateEmailNotification, updateEmailNotification,
updateLarkNotification,
updateGotifyNotification, updateGotifyNotification,
updateNtfyNotification, updateNtfyNotification,
updateSlackNotification, updateSlackNotification,
@@ -39,6 +42,7 @@ import {
apiCreateCustom, apiCreateCustom,
apiCreateDiscord, apiCreateDiscord,
apiCreateEmail, apiCreateEmail,
apiCreateLark,
apiCreateGotify, apiCreateGotify,
apiCreateNtfy, apiCreateNtfy,
apiCreateSlack, apiCreateSlack,
@@ -47,6 +51,7 @@ import {
apiTestCustomConnection, apiTestCustomConnection,
apiTestDiscordConnection, apiTestDiscordConnection,
apiTestEmailConnection, apiTestEmailConnection,
apiTestLarkConnection,
apiTestGotifyConnection, apiTestGotifyConnection,
apiTestNtfyConnection, apiTestNtfyConnection,
apiTestSlackConnection, apiTestSlackConnection,
@@ -54,6 +59,7 @@ import {
apiUpdateCustom, apiUpdateCustom,
apiUpdateDiscord, apiUpdateDiscord,
apiUpdateEmail, apiUpdateEmail,
apiUpdateLark,
apiUpdateGotify, apiUpdateGotify,
apiUpdateNtfy, apiUpdateNtfy,
apiUpdateSlack, apiUpdateSlack,
@@ -335,6 +341,7 @@ export const notificationRouter = createTRPCRouter({
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
lark: true,
}, },
orderBy: desc(notifications.createdAt), orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -571,6 +578,63 @@ export const notificationRouter = createTRPCRouter({
}); });
} }
}), }),
createLark: adminProcedure
.input(apiCreateLark)
.mutation(async ({ input, ctx }) => {
try {
return await createLarkNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateLark: adminProcedure
.input(apiUpdateLark)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateLarkNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testLarkConnection: adminProcedure
.input(apiTestLarkConnection)
.mutation(async ({ input }) => {
try {
await sendLarkNotification(input, {
msg_type: "text",
content: {
text: "Hi, From Dokploy 👋",
},
});
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
getEmailProviders: adminProcedure.query(async ({ ctx }) => { getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({ return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
+1 -1
View File
@@ -22,7 +22,7 @@ export const getGiteaOAuthUrl = (
} }
const redirectUri = `${baseUrl}/api/providers/gitea/callback`; const redirectUri = `${baseUrl}/api/providers/gitea/callback`;
const scopes = "repo repo:status read:user read:org"; const scopes = "read:repository read:user read:organization";
return `${giteaUrl}/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent( return `${giteaUrl}/login/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(
redirectUri, redirectUri,
+3 -2
View File
@@ -117,7 +117,7 @@ func getRealOS() string {
func GetServerMetrics() database.ServerMetric { func GetServerMetrics() database.ServerMetric {
v, _ := mem.VirtualMemory() v, _ := mem.VirtualMemory()
c, _ := cpu.Percent(0, false) c, _ := cpu.Percent(time.Second, false)
cpuInfo, _ := cpu.Info() cpuInfo, _ := cpu.Info()
diskInfo, _ := disk.Usage("/") diskInfo, _ := disk.Usage("/")
netInfo, _ := net.IOCounters(false) netInfo, _ := net.IOCounters(false)
@@ -130,7 +130,8 @@ func GetServerMetrics() database.ServerMetric {
} }
memTotalGB := float64(v.Total) / 1024 / 1024 / 1024 memTotalGB := float64(v.Total) / 1024 / 1024 / 1024
memUsedGB := float64(v.Used) / 1024 / 1024 / 1024 memAvailableGB := float64(v.Available) / 1024 / 1024 / 1024
memUsedGB := memTotalGB - memAvailableGB
memUsedPercent := (memUsedGB / memTotalGB) * 100 memUsedPercent := (memUsedGB / memTotalGB) * 100
var networkIn, networkOut float64 var networkIn, networkOut float64
@@ -80,6 +80,7 @@ export const applications = pgTable("application", {
previewEnv: text("previewEnv"), previewEnv: text("previewEnv"),
watchPaths: text("watchPaths").array(), watchPaths: text("watchPaths").array(),
previewBuildArgs: text("previewBuildArgs"), previewBuildArgs: text("previewBuildArgs"),
previewBuildSecrets: text("previewBuildSecrets"),
previewLabels: text("previewLabels").array(), previewLabels: text("previewLabels").array(),
previewWildcard: text("previewWildcard"), previewWildcard: text("previewWildcard"),
previewPort: integer("previewPort").default(3000), previewPort: integer("previewPort").default(3000),
@@ -99,6 +100,7 @@ export const applications = pgTable("application", {
).default(true), ).default(true),
rollbackActive: boolean("rollbackActive").default(false), rollbackActive: boolean("rollbackActive").default(false),
buildArgs: text("buildArgs"), buildArgs: text("buildArgs"),
buildSecrets: text("buildSecrets"),
memoryReservation: text("memoryReservation"), memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"), memoryLimit: text("memoryLimit"),
cpuReservation: text("cpuReservation"), cpuReservation: text("cpuReservation"),
@@ -253,6 +255,7 @@ const createSchema = createInsertSchema(applications, {
autoDeploy: z.boolean(), autoDeploy: z.boolean(),
env: z.string().optional(), env: z.string().optional(),
buildArgs: z.string().optional(), buildArgs: z.string().optional(),
buildSecrets: z.string().optional(),
name: z.string().min(1), name: z.string().min(1),
description: z.string().optional(), description: z.string().optional(),
memoryReservation: z.string().optional(), memoryReservation: z.string().optional(),
@@ -304,6 +307,7 @@ const createSchema = createInsertSchema(applications, {
previewPort: z.number().optional(), previewPort: z.number().optional(),
previewEnv: z.string().optional(), previewEnv: z.string().optional(),
previewBuildArgs: z.string().optional(), previewBuildArgs: z.string().optional(),
previewBuildSecrets: z.string().optional(),
previewWildcard: z.string().optional(), previewWildcard: z.string().optional(),
previewLimit: z.number().optional(), previewLimit: z.number().optional(),
previewHttps: z.boolean().optional(), previewHttps: z.boolean().optional(),
@@ -458,6 +462,7 @@ export const apiSaveEnvironmentVariables = createSchema
applicationId: true, applicationId: true,
env: true, env: true,
buildArgs: true, buildArgs: true,
buildSecrets: true,
}) })
.required(); .required();
-1
View File
@@ -12,7 +12,6 @@ import { gitea } from "./gitea";
import { github } from "./github"; import { github } from "./github";
import { gitlab } from "./gitlab"; import { gitlab } from "./gitlab";
import { mounts } from "./mount"; import { mounts } from "./mount";
import { projects } from "./project";
import { schedules } from "./schedule"; import { schedules } from "./schedule";
import { server } from "./server"; import { server } from "./server";
import { applicationStatus, triggerType } from "./shared"; import { applicationStatus, triggerType } from "./shared";
@@ -13,6 +13,7 @@ export const notificationType = pgEnum("notificationType", [
"gotify", "gotify",
"ntfy", "ntfy",
"custom", "custom",
"lark",
]); ]);
export const notifications = pgTable("notification", { export const notifications = pgTable("notification", {
@@ -52,6 +53,9 @@ export const notifications = pgTable("notification", {
customId: text("customId").references(() => custom.customId, { customId: text("customId").references(() => custom.customId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
larkId: text("larkId").references(() => lark.larkId, {
onDelete: "cascade",
}),
organizationId: text("organizationId") organizationId: text("organizationId")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
@@ -129,6 +133,14 @@ export const custom = pgTable("custom", {
headers: text("headers"), // JSON string headers: text("headers"), // JSON string
}); });
export const lark = pgTable("lark", {
larkId: text("larkId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
webhookUrl: text("webhookUrl").notNull(),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({ export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, { slack: one(slack, {
fields: [notifications.slackId], fields: [notifications.slackId],
@@ -158,6 +170,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.customId], fields: [notifications.customId],
references: [custom.customId], references: [custom.customId],
}), }),
lark: one(lark, {
fields: [notifications.larkId],
references: [lark.larkId],
}),
organization: one(organization, { organization: one(organization, {
fields: [notifications.organizationId], fields: [notifications.organizationId],
references: [organization.id], references: [organization.id],
@@ -382,6 +398,31 @@ export const apiTestCustomConnection = z.object({
headers: z.string().optional(), headers: z.string().optional(),
}); });
export const apiCreateLark = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
serverThreshold: true,
})
.extend({
webhookUrl: z.string().min(1),
})
.required();
export const apiUpdateLark = apiCreateLark.partial().extend({
notificationId: z.string().min(1),
larkId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestLarkConnection = apiCreateLark.pick({
webhookUrl: true,
});
export const apiSendTest = notificationsSchema export const apiSendTest = notificationsSchema
.extend({ .extend({
botToken: z.string(), botToken: z.string(),
+2 -2
View File
@@ -68,7 +68,6 @@ export * from "./utils/backups/postgres";
export * from "./utils/backups/utils"; export * from "./utils/backups/utils";
export * from "./utils/backups/web-server"; export * from "./utils/backups/web-server";
export * from "./utils/builders/compose"; export * from "./utils/builders/compose";
export * from "./utils/startup/cancell-deployments";
export * from "./utils/builders/docker-file"; export * from "./utils/builders/docker-file";
export * from "./utils/builders/drop"; export * from "./utils/builders/drop";
export * from "./utils/builders/heroku"; export * from "./utils/builders/heroku";
@@ -77,7 +76,6 @@ export * from "./utils/builders/nixpacks";
export * from "./utils/builders/paketo"; export * from "./utils/builders/paketo";
export * from "./utils/builders/static"; export * from "./utils/builders/static";
export * from "./utils/builders/utils"; export * from "./utils/builders/utils";
export * from "./utils/cluster/upload"; export * from "./utils/cluster/upload";
export * from "./utils/databases/rebuild"; export * from "./utils/databases/rebuild";
export * from "./utils/docker/collision"; export * from "./utils/docker/collision";
@@ -113,6 +111,8 @@ export * from "./utils/providers/raw";
export * from "./utils/schedules/index"; export * from "./utils/schedules/index";
export * from "./utils/schedules/utils"; export * from "./utils/schedules/utils";
export * from "./utils/servers/remote-docker"; export * from "./utils/servers/remote-docker";
export * from "./utils/startup/cancell-deployments";
export * from "./utils/tracking/hubspot";
export * from "./utils/traefik/application"; export * from "./utils/traefik/application";
export * from "./utils/traefik/domain"; export * from "./utils/traefik/domain";
export * from "./utils/traefik/file-types"; export * from "./utils/traefik/file-types";
+23 -1
View File
@@ -10,6 +10,7 @@ import { db } from "../db";
import * as schema from "../db/schema"; import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin"; import { getUserByToken } from "../services/admin";
import { updateUser } from "../services/user"; import { updateUser } from "../services/user";
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email"; import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils"; import { getPublicIpWithFallback } from "../wss/utils";
@@ -115,7 +116,7 @@ const { handler, api } = betterAuth({
} }
} }
}, },
after: async (user) => { after: async (user, context) => {
const isAdminPresent = await db.query.member.findFirst({ const isAdminPresent = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"), where: eq(schema.member.role, "owner"),
}); });
@@ -126,6 +127,27 @@ const { handler, api } = betterAuth({
}); });
} }
if (IS_CLOUD) {
try {
const hutk = getHubSpotUTK(
context?.request?.headers?.get("cookie") || undefined,
);
const hubspotSuccess = await submitToHubSpot(
{
email: user.email,
firstName: user.name,
lastName: user.name,
},
hutk,
);
if (!hubspotSuccess) {
console.error("Failed to submit to HubSpot");
}
} catch (error) {
console.error("Error submitting to HubSpot", error);
}
}
if (IS_CLOUD || !isAdminPresent) { if (IS_CLOUD || !isAdminPresent) {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const organization = await tx const organization = await tx
+4 -3
View File
@@ -472,7 +472,8 @@ export const deployPreviewApplication = async ({
}); });
application.appName = previewDeployment.appName; application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = application.previewBuildArgs; application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
if (application.sourceType === "github") { if (application.sourceType === "github") {
await cloneGithubRepository({ await cloneGithubRepository({
@@ -579,7 +580,8 @@ export const deployRemotePreviewApplication = async ({
}); });
application.appName = previewDeployment.appName; application.appName = previewDeployment.appName;
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`; application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildArgs = application.previewBuildArgs; application.buildArgs = `${application.previewBuildArgs}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
application.buildSecrets = `${application.previewBuildSecrets}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain?.host}`;
if (application.serverId) { if (application.serverId) {
let command = "set -e;"; let command = "set -e;";
@@ -666,7 +668,6 @@ export const rebuildRemoteApplication = async ({
echo "Error occurred ❌, check the logs for details." >> ${deployment.logPath}; echo "Error occurred ❌, check the logs for details." >> ${deployment.logPath};
echo "${encodedContent}" | base64 -d >> "${deployment.logPath}";`, echo "${encodedContent}" | base64 -d >> "${deployment.logPath}";`,
); );
await updateDeploymentStatus(deployment.deploymentId, "error"); await updateDeploymentStatus(deployment.deploymentId, "error");
await updateApplicationStatus(applicationId, "error"); await updateApplicationStatus(applicationId, "error");
throw error; throw error;
@@ -3,6 +3,7 @@ import {
type apiCreateCustom, type apiCreateCustom,
type apiCreateDiscord, type apiCreateDiscord,
type apiCreateEmail, type apiCreateEmail,
type apiCreateLark,
type apiCreateGotify, type apiCreateGotify,
type apiCreateNtfy, type apiCreateNtfy,
type apiCreateSlack, type apiCreateSlack,
@@ -10,6 +11,7 @@ import {
type apiUpdateCustom, type apiUpdateCustom,
type apiUpdateDiscord, type apiUpdateDiscord,
type apiUpdateEmail, type apiUpdateEmail,
type apiUpdateLark,
type apiUpdateGotify, type apiUpdateGotify,
type apiUpdateNtfy, type apiUpdateNtfy,
type apiUpdateSlack, type apiUpdateSlack,
@@ -17,6 +19,7 @@ import {
custom, custom,
discord, discord,
email, email,
lark,
gotify, gotify,
notifications, notifications,
ntfy, ntfy,
@@ -677,6 +680,7 @@ export const findNotificationById = async (notificationId: string) => {
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
lark: true,
}, },
}); });
if (!notification) { if (!notification) {
@@ -697,6 +701,94 @@ export const removeNotificationById = async (notificationId: string) => {
return result[0]; return result[0];
}; };
export const createLarkNotification = async (
input: typeof apiCreateLark._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newLark = await tx
.insert(lark)
.values({
webhookUrl: input.webhookUrl,
})
.returning()
.then((value) => value[0]);
if (!newLark) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting lark",
});
}
const newDestination = await tx
.insert(notifications)
.values({
larkId: newLark.larkId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "lark",
organizationId: organizationId,
serverThreshold: input.serverThreshold,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateLarkNotification = async (
input: typeof apiUpdateLark._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
serverThreshold: input.serverThreshold,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(lark)
.set({
webhookUrl: input.webhookUrl,
})
.where(eq(lark.larkId, input.larkId))
.returning()
.then((value) => value[0]);
return newDestination;
});
};
export const updateNotificationById = async ( export const updateNotificationById = async (
notificationId: string, notificationId: string,
notificationData: Partial<Notification>, notificationData: Partial<Notification>,
+2 -2
View File
@@ -141,8 +141,8 @@ export function processValue(
} }
if ( if (
typeof payload === "string" && typeof payload === "string" &&
payload.startsWith("{") && payload.trimStart().startsWith("{") &&
payload.endsWith("}") payload.trimEnd().endsWith("}")
) { ) {
try { try {
payload = JSON.parse(payload); payload = JSON.parse(payload);
@@ -16,6 +16,7 @@ export function getProviderName(apiUrl: string) {
if (apiUrl.includes("api.mistral.ai")) return "mistral"; if (apiUrl.includes("api.mistral.ai")) return "mistral";
if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama"; if (apiUrl.includes(":11434") || apiUrl.includes("ollama")) return "ollama";
if (apiUrl.includes("api.deepinfra.com")) return "deepinfra"; if (apiUrl.includes("api.deepinfra.com")) return "deepinfra";
if (apiUrl.includes("generativelanguage.googleapis.com")) return "gemini";
return "custom"; return "custom";
} }
@@ -66,6 +67,13 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
baseURL: config.apiUrl, baseURL: config.apiUrl,
apiKey: config.apiKey, apiKey: config.apiKey,
}); });
case "gemini":
return createOpenAICompatible({
name: "gemini",
baseURL: config.apiUrl,
queryParams: { key: config.apiKey },
headers: {},
});
case "custom": case "custom":
return createOpenAICompatible({ return createOpenAICompatible({
name: "custom", name: "custom",
@@ -1,5 +1,8 @@
import type { WriteStream } from "node:fs"; import type { WriteStream } from "node:fs";
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils"; import {
getEnviromentVariablesObject,
prepareEnvironmentVariables,
} from "@dokploy/server/utils/docker/utils";
import { import {
getBuildAppDirectory, getBuildAppDirectory,
getDockerContextPath, getDockerContextPath,
@@ -17,6 +20,7 @@ export const buildCustomDocker = async (
env, env,
publishDirectory, publishDirectory,
buildArgs, buildArgs,
buildSecrets,
dockerBuildStage, dockerBuildStage,
cleanCache, cleanCache,
} = application; } = application;
@@ -26,11 +30,6 @@ export const buildCustomDocker = async (
const defaultContextPath = const defaultContextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || "."; dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
const dockerContextPath = getDockerContextPath(application); const dockerContextPath = getDockerContextPath(application);
@@ -44,9 +43,29 @@ export const buildCustomDocker = async (
commandArgs.push("--target", dockerBuildStage); commandArgs.push("--target", dockerBuildStage);
} }
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
for (const arg of args) { for (const arg of args) {
commandArgs.push("--build-arg", arg); commandArgs.push("--build-arg", arg);
} }
const secrets = getEnviromentVariablesObject(
buildSecrets,
application.environment.project.env,
application.environment.env,
);
for (const key in secrets) {
// Although buildx is smart enough to know we may be referring to an environment variable name,
// we still make sure it doesn't fall back to type=file.
// See: https://docs.docker.com/reference/cli/docker/buildx/build/#secret
commandArgs.push("--secret", `type=env,id=${key}`);
}
/* /*
Do not generate an environment file when publishDirectory is specified, Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed. as it could be publicly exposed.
@@ -70,6 +89,10 @@ export const buildCustomDocker = async (
}, },
{ {
cwd: dockerContextPath || defaultContextPath, cwd: dockerContextPath || defaultContextPath,
env: {
...process.env,
...secrets,
},
}, },
); );
} catch (error) { } catch (error) {
@@ -86,6 +109,7 @@ export const getDockerCommand = (
env, env,
publishDirectory, publishDirectory,
buildArgs, buildArgs,
buildSecrets,
dockerBuildStage, dockerBuildStage,
cleanCache, cleanCache,
} = application; } = application;
@@ -96,11 +120,6 @@ export const getDockerCommand = (
const defaultContextPath = const defaultContextPath =
dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || "."; dockerFilePath.substring(0, dockerFilePath.lastIndexOf("/") + 1) || ".";
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
const dockerContextPath = const dockerContextPath =
getDockerContextPath(application) || defaultContextPath; getDockerContextPath(application) || defaultContextPath;
@@ -115,10 +134,33 @@ export const getDockerCommand = (
commandArgs.push("--no-cache"); commandArgs.push("--no-cache");
} }
const args = prepareEnvironmentVariables(
buildArgs,
application.environment.project.env,
application.environment.env,
);
for (const arg of args) { for (const arg of args) {
commandArgs.push("--build-arg", `'${arg}'`); commandArgs.push("--build-arg", `'${arg}'`);
} }
const secrets = getEnviromentVariablesObject(
buildSecrets,
application.environment.project.env,
application.environment.env,
);
const joinedSecrets = Object.entries(secrets)
.map(([key, value]) => `${key}='${value.replace(/'/g, "'\"'\"'")}'`)
.join(" ");
for (const key in secrets) {
// Although buildx is smart enough to know we may be referring to an environment variable name,
// we still make sure it doesn't fall back to `type=file`.
// See: https://docs.docker.com/reference/cli/docker/buildx/build/#secret
commandArgs.push("--secret", `type=env,id=${key}`);
}
/* /*
Do not generate an environment file when publishDirectory is specified, Do not generate an environment file when publishDirectory is specified,
as it could be publicly exposed. as it could be publicly exposed.
@@ -140,7 +182,7 @@ cd ${dockerContextPath} >> ${logPath} 2>> ${logPath} || {
exit 1; exit 1;
} }
docker ${commandArgs.join(" ")} >> ${logPath} 2>> ${logPath} || { ${joinedSecrets} docker ${commandArgs.join(" ")} >> ${logPath} 2>> ${logPath} || {
echo "❌ Docker build failed" >> ${logPath}; echo "❌ Docker build failed" >> ${logPath};
exit 1; exit 1;
} }
@@ -8,6 +8,7 @@ import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendLarkNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification, sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
@@ -46,11 +47,12 @@ export const sendBuildErrorNotifications = async ({
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
lark: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom } = const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification; notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
@@ -236,5 +238,117 @@ export const sendBuildErrorNotifications = async ({
type: "build", type: "build",
}); });
} }
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage = errorMessage.substring(0, limitCharacter);
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "⚠️ Build Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Type:**\n${applicationType}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
{
tag: "button",
text: {
tag: "plain_text",
content: "View Build Details",
},
type: "danger",
width: "default",
size: "medium",
behaviors: [
{
type: "open_url",
default_url: buildLink,
pc_url: "",
ios_url: "",
android_url: "",
},
],
margin: "0px 0px 0px 0px",
},
],
},
},
});
}
} }
}; };
@@ -6,231 +6,337 @@ import { renderAsync } from "@react-email/components";
import { format } from "date-fns"; import { format } from "date-fns";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendLarkNotification,
sendNtfyNotification, sendGotifyNotification,
sendSlackNotification, sendNtfyNotification,
sendTelegramNotification, sendSlackNotification,
sendTelegramNotification,
} from "./utils"; } from "./utils";
interface Props { interface Props {
projectName: string; projectName: string;
applicationName: string; applicationName: string;
applicationType: string; applicationType: string;
buildLink: string; buildLink: string;
organizationId: string; organizationId: string;
domains: Domain[]; domains: Domain[];
} }
export const sendBuildSuccessNotifications = async ({ export const sendBuildSuccessNotifications = async ({
projectName, projectName,
applicationName, applicationName,
applicationType, applicationType,
buildLink, buildLink,
organizationId, organizationId,
domains, domains,
}: Props) => { }: Props) => {
const date = new Date(); const date = new Date();
const unixDate = ~~(Number(date) / 1000); const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({ const notificationList = await db.query.notifications.findMany({
where: and( where: and(
eq(notifications.appDeploy, true), eq(notifications.appDeploy, true),
eq(notifications.organizationId, organizationId), eq(notifications.organizationId, organizationId)
), ),
with: { with: {
email: true, email: true,
discord: true, discord: true,
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
}, lark: true,
}); },
});
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom } = const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification; notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
BuildSuccessEmail({ BuildSuccessEmail({
projectName, projectName,
applicationName, applicationName,
applicationType, applicationType,
buildLink, buildLink,
date: date.toLocaleString(), date: date.toLocaleString(),
}), })
).catch(); ).catch();
await sendEmailNotification(email, "Build success for dokploy", template); await sendEmailNotification(email, "Build success for dokploy", template);
} }
if (discord) { if (discord) {
const decorate = (decoration: string, text: string) => const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim(); `${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, { await sendDiscordNotification(discord, {
title: decorate(">", "`✅` Build Success"), title: decorate(">", "`✅` Build Success"),
color: 0x57f287, color: 0x57f287,
fields: [ fields: [
{ {
name: decorate("`🛠️`", "Project"), name: decorate("`🛠️`", "Project"),
value: projectName, value: projectName,
inline: true, inline: true,
}, },
{ {
name: decorate("`⚙️`", "Application"), name: decorate("`⚙️`", "Application"),
value: applicationName, value: applicationName,
inline: true, inline: true,
}, },
{ {
name: decorate("`❔`", "Type"), name: decorate("`❔`", "Type"),
value: applicationType, value: applicationType,
inline: true, inline: true,
}, },
{ {
name: decorate("`📅`", "Date"), name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`, value: `<t:${unixDate}:D>`,
inline: true, inline: true,
}, },
{ {
name: decorate("`⌚`", "Time"), name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`, value: `<t:${unixDate}:t>`,
inline: true, inline: true,
}, },
{ {
name: decorate("`❓`", "Type"), name: decorate("`❓`", "Type"),
value: "Successful", value: "Successful",
inline: true, inline: true,
}, },
{ {
name: decorate("`🧷`", "Build Link"), name: decorate("`🧷`", "Build Link"),
value: `[Click here to access build link](${buildLink})`, value: `[Click here to access build link](${buildLink})`,
}, },
], ],
timestamp: date.toISOString(), timestamp: date.toISOString(),
footer: { footer: {
text: "Dokploy Build Notification", text: "Dokploy Build Notification",
}, },
}); });
} }
if (gotify) { if (gotify) {
const decorate = (decoration: string, text: string) => const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`; `${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification( await sendGotifyNotification(
gotify, gotify,
decorate("✅", "Build Success"), decorate("✅", "Build Success"),
`${decorate("🛠️", `Project: ${projectName}`)}` + `${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` + `${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("❔", `Type: ${applicationType}`)}` + `${decorate("❔", `Type: ${applicationType}`)}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` + `${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${decorate("🔗", `Build details:\n${buildLink}`)}`, `${decorate("🔗", `Build details:\n${buildLink}`)}`
); );
} }
if (ntfy) { if (ntfy) {
await sendNtfyNotification( await sendNtfyNotification(
ntfy, ntfy,
"Build Success", "Build Success",
"white_check_mark", "white_check_mark",
`view, Build details, ${buildLink}, clear=true;`, `view, Build details, ${buildLink}, clear=true;`,
`🛠Project: ${projectName}\n` + `🛠Project: ${projectName}\n` +
`⚙️Application: ${applicationName}\n` + `⚙️Application: ${applicationName}\n` +
`❔Type: ${applicationType}\n` + `❔Type: ${applicationType}\n` +
`🕒Date: ${date.toLocaleString()}`, `🕒Date: ${date.toLocaleString()}`
); );
} }
if (telegram) { if (telegram) {
const chunkArray = <T>(array: T[], chunkSize: number): T[][] => const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) => Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>
array.slice(i * chunkSize, i * chunkSize + chunkSize), array.slice(i * chunkSize, i * chunkSize + chunkSize)
); );
const inlineButton = [ const inlineButton = [
[ [
{ {
text: "Deployment Logs", text: "Deployment Logs",
url: buildLink, url: buildLink,
}, },
], ],
...chunkArray(domains, 2).map((chunk) => ...chunkArray(domains, 2).map((chunk) =>
chunk.map((data) => ({ chunk.map((data) => ({
text: data.host, text: data.host,
url: `${data.https ? "https" : "http"}://${data.host}`, url: `${data.https ? "https" : "http"}://${data.host}`,
})), }))
), ),
]; ];
await sendTelegramNotification( await sendTelegramNotification(
telegram, telegram,
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format( `<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(
date, date,
"PP", "PP"
)}\n<b>Time:</b> ${format(date, "pp")}`, )}\n<b>Time:</b> ${format(date, "pp")}`,
inlineButton, inlineButton
); );
} }
if (slack) { if (slack) {
const { channel } = slack; const { channel } = slack;
await sendSlackNotification(slack, { await sendSlackNotification(slack, {
channel: channel, channel: channel,
attachments: [ attachments: [
{ {
color: "#00FF00", color: "#00FF00",
pretext: ":white_check_mark: *Build Success*", pretext: ":white_check_mark: *Build Success*",
fields: [ fields: [
{ {
title: "Project", title: "Project",
value: projectName, value: projectName,
short: true, short: true,
}, },
{ {
title: "Application", title: "Application",
value: applicationName, value: applicationName,
short: true, short: true,
}, },
{ {
title: "Type", title: "Type",
value: applicationType, value: applicationType,
short: true, short: true,
}, },
{ {
title: "Time", title: "Time",
value: date.toLocaleString(), value: date.toLocaleString(),
short: true, short: true,
}, },
], ],
actions: [ actions: [
{ {
type: "button", type: "button",
text: "View Build Details", text: "View Build Details",
url: buildLink, url: buildLink,
}, },
], ],
}, },
], ],
}); });
} }
if (custom) { if (custom) {
await sendCustomNotification(custom, { await sendCustomNotification(custom, {
title: "Build Success", title: "Build Success",
message: "Build completed successfully", message: "Build completed successfully",
projectName, projectName,
applicationName, applicationName,
applicationType, applicationType,
buildLink, buildLink,
timestamp: date.toISOString(), timestamp: date.toISOString(),
date: date.toLocaleString(), date: date.toLocaleString(),
domains: domains.map((domain) => domain.host).join(", "), domains: domains.map((domain) => domain.host).join(", "),
status: "success", status: "success",
type: "build", type: "build",
}); });
} }
}
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Build Success",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Type:**\n${applicationType}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
{
tag: "button",
text: {
tag: "plain_text",
content: "View Build Details",
},
type: "primary",
width: "default",
size: "medium",
behaviors: [
{
type: "open_url",
default_url: buildLink,
pc_url: "",
ios_url: "",
android_url: "",
},
],
margin: "0px 0px 0px 0px",
},
],
},
},
});
}
}
}; };
@@ -8,6 +8,7 @@ import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendLarkNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification, sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
@@ -46,11 +47,12 @@ export const sendDatabaseBackupNotifications = async ({
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
lark: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom } = const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification; notification;
if (email) { if (email) {
@@ -260,5 +262,120 @@ export const sendDatabaseBackupNotifications = async ({
status: type, status: type,
}); });
} }
if (lark) {
const limitCharacter = 800;
const truncatedErrorMessage =
errorMessage && errorMessage.length > limitCharacter
? errorMessage.substring(0, limitCharacter)
: errorMessage;
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content:
type === "success"
? "✅ Database Backup Successful"
: "❌ Database Backup Failed",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: type === "success" ? "green" : "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Project:**\n${projectName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Database Type:**\n${databaseType}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Status:**\n${type === "success" ? "Successful" : "Failed"}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Application:**\n${applicationName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Database Name:**\n${databaseName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
...(type === "error" && truncatedErrorMessage
? [
{
tag: "markdown",
content: `**Error Message:**\n\`\`\`\n${truncatedErrorMessage}\n\`\`\``,
text_align: "left",
text_size: "normal_v2",
},
]
: []),
],
},
},
});
}
} }
}; };
@@ -8,6 +8,7 @@ import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendLarkNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification, sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
@@ -33,11 +34,12 @@ export const sendDockerCleanupNotifications = async (
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
lark: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom } = const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification; notification;
if (email) { if (email) {
@@ -150,5 +152,83 @@ export const sendDockerCleanupNotifications = async (
type: "docker-cleanup", type: "docker-cleanup",
}); });
} }
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Docker Cleanup",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Status:**\nSuccessful`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Cleanup Details:**\n${message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Date:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
});
}
} }
}; };
@@ -8,6 +8,7 @@ import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendLarkNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification, sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
@@ -27,11 +28,12 @@ export const sendDokployRestartNotifications = async () => {
gotify: true, gotify: true,
ntfy: true, ntfy: true,
custom: true, custom: true,
lark: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify, ntfy, custom } = const { email, discord, telegram, slack, gotify, ntfy, custom, lark } =
notification; notification;
if (email) { if (email) {
@@ -153,5 +155,81 @@ export const sendDokployRestartNotifications = async () => {
console.log(error); console.log(error);
} }
} }
if (lark) {
try {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: "✅ Dokploy Server Restarted",
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "green",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Status:**\nSuccessful`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Restart Time:**\n${format(date, "PP pp")}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
});
} catch (error) {
console.log(error);
}
}
} }
}; };
@@ -4,6 +4,7 @@ import { notifications } from "../../db/schema";
import { import {
sendCustomNotification, sendCustomNotification,
sendDiscordNotification, sendDiscordNotification,
sendLarkNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -36,6 +37,7 @@ export const sendServerThresholdNotifications = async (
telegram: true, telegram: true,
slack: true, slack: true,
custom: true, custom: true,
lark: true,
}, },
}); });
@@ -43,7 +45,7 @@ export const sendServerThresholdNotifications = async (
const typeColor = 0xff0000; // Rojo para indicar alerta const typeColor = 0xff0000; // Rojo para indicar alerta
for (const notification of notificationList) { for (const notification of notificationList) {
const { discord, telegram, slack, custom } = notification; const { discord, telegram, slack, custom, lark } = notification;
if (discord) { if (discord) {
const decorate = (decoration: string, text: string) => const decorate = (decoration: string, text: string) =>
@@ -168,5 +170,101 @@ export const sendServerThresholdNotifications = async (
alertType: "server-threshold", alertType: "server-threshold",
}); });
} }
if (lark) {
await sendLarkNotification(lark, {
msg_type: "interactive",
card: {
schema: "2.0",
config: {
update_multi: true,
style: {
text_size: {
normal_v2: {
default: "normal",
pc: "normal",
mobile: "heading",
},
},
},
},
header: {
title: {
tag: "plain_text",
content: `⚠️ Server ${payload.Type} Alert`,
},
subtitle: {
tag: "plain_text",
content: "",
},
template: "red",
padding: "12px 12px 12px 12px",
},
body: {
direction: "vertical",
padding: "12px 12px 12px 12px",
elements: [
{
tag: "column_set",
columns: [
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Server Name:**\n${payload.ServerName}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Current Value:**\n${payload.Value.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Message:**\n${payload.Message}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
{
tag: "column",
width: "weighted",
elements: [
{
tag: "markdown",
content: `**Type:**\n${payload.Type === "CPU" ? "🔲" : "💾"} ${payload.Type}`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Threshold:**\n${payload.Threshold.toFixed(2)}%`,
text_align: "left",
text_size: "normal_v2",
},
{
tag: "markdown",
content: `**Alert Time:**\n${date.toLocaleString()}`,
text_align: "left",
text_size: "normal_v2",
},
],
vertical_align: "top",
weight: 1,
},
],
},
],
},
},
});
}
} }
}; };
@@ -2,6 +2,7 @@ import type {
custom, custom,
discord, discord,
email, email,
lark,
gotify, gotify,
ntfy, ntfy,
slack, slack,
@@ -192,3 +193,18 @@ export const sendCustomNotification = async (
throw error; throw error;
} }
}; };
export const sendLarkNotification = async (
connection: typeof lark.$inferInsert,
message: any,
) => {
try {
await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(message),
});
} catch (err) {
console.log(err);
}
};
@@ -0,0 +1,125 @@
interface HubSpotFormField {
objectTypeId: string;
name: string;
value: string;
}
interface HubSpotFormData {
fields: HubSpotFormField[];
context: {
pageUri: string;
pageName: string;
hutk?: string; // HubSpot UTK from cookies
};
}
interface SignUpFormData {
firstName?: string;
lastName?: string;
email?: string;
}
/**
* Extract HubSpot UTK (User Token) from cookies
* This is used for tracking and attribution in HubSpot
*/
export function getHubSpotUTK(cookieHeader?: string): string | null {
if (!cookieHeader) return null;
const name = "hubspotutk=";
const decodedCookie = decodeURIComponent(cookieHeader);
const cookieArray = decodedCookie.split(";");
for (let i = 0; i < cookieArray.length; i++) {
const cookie = cookieArray[i]?.trim();
if (!cookie) continue;
if (cookie.indexOf(name) === 0) {
return cookie.substring(name.length, cookie.length);
}
}
return null;
}
/**
* Convert contact form data to HubSpot form format
*/
export function formatContactDataForHubSpot(
contactData: SignUpFormData,
hutk?: string | null,
): HubSpotFormData {
const formData: HubSpotFormData = {
fields: [
{
objectTypeId: "0-1", // Contact object type
name: "firstname",
value: contactData.firstName || "",
},
{
objectTypeId: "0-1",
name: "lastname",
value: contactData.lastName || "",
},
{
objectTypeId: "0-1",
name: "email",
value: contactData.email || "",
},
],
context: {
pageUri: "https://app.dokploy.com/register",
pageName: "Sign Up",
},
};
// Add HubSpot UTK if available
if (hutk) {
formData.context.hutk = hutk;
}
return formData;
}
/**
* Submit form data to HubSpot Forms API
*/
export async function submitToHubSpot(
contactData: SignUpFormData,
hutk?: string | null,
): Promise<boolean> {
try {
const portalId = process.env.HUBSPOT_PORTAL_ID;
const formGuid = process.env.HUBSPOT_FORM_GUID;
if (!portalId || !formGuid) {
console.error(
"HubSpot configuration missing: HUBSPOT_PORTAL_ID or HUBSPOT_FORM_GUID not set",
);
return false;
}
const formData = formatContactDataForHubSpot(contactData, hutk);
const response = await fetch(
`https://api.hsforms.com/submissions/v3/integration/submit/${portalId}/${formGuid}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
},
);
if (!response.ok) {
const errorText = await response.text();
console.error("HubSpot API error:", response.status, errorText);
return false;
}
const result = await response.json();
console.log("HubSpot submission successful:", result);
return true;
} catch (error) {
console.error("Error submitting to HubSpot:", error);
return false;
}
}