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:21 -03:00
59 changed files with 15659 additions and 512 deletions

View File

@@ -48,6 +48,7 @@ const baseApp: ApplicationNested = {
dockerBuildStage: "",
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
previewCertificateType: "none",
previewCustomCertResolver: null,
previewEnv: null,
@@ -73,6 +74,7 @@ const baseApp: ApplicationNested = {
},
},
buildArgs: null,
buildSecrets: null,
buildPath: "/",
gitlabPathNamespace: "",
buildType: "nixpacks",

View File

@@ -228,5 +228,58 @@ describe("helpers functions", () => {
"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);
});
});
});

View File

@@ -25,8 +25,10 @@ const baseApp: ApplicationNested = {
registryUrl: "",
watchPaths: [],
buildArgs: null,
buildSecrets: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
previewBuildSecrets: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,

View File

@@ -318,7 +318,7 @@ export const AddVolumes = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem>
<FormItem className="max-w-full max-w-[45rem]">
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -327,7 +327,7 @@ export const AddVolumes = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono"
className="h-96 font-mono "
{...field}
/>
</FormControl>

View File

@@ -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 { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
@@ -29,9 +31,10 @@ export const ShowDeployment = ({
const [data, setData] = useState("");
const [showExtraLogs, setShowExtraLogs] = useState(false);
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 scrollRef = useRef<HTMLDivElement>(null);
const [copied, setCopied] = useState(false);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
@@ -106,6 +109,20 @@ export const ShowDeployment = ({
}
}, [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 || "");
return (
@@ -128,13 +145,27 @@ export const ShowDeployment = ({
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span>
<span className="flex items-center gap-2">
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</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 && (
<div className="flex items-center space-x-2">
<Checkbox

View File

@@ -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 (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">

View File

@@ -12,6 +12,7 @@ import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -37,6 +38,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
defaultValues: {
env: "",
buildArgs: "",
buildSecrets: "",
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -44,15 +46,18 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Watch form values
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "");
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
useEffect(() => {
if (data) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
});
}
}, [data, form]);
@@ -61,6 +66,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
mutateAsync({
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
applicationId,
})
.then(async () => {
@@ -76,9 +82,25 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
form.reset({
env: data?.env || "",
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 (
<Card className="bg-background px-6 pb-6">
<Form {...form}>
@@ -104,13 +126,36 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
title="Build-time Arguments"
description={
<span>
Available only at build-time. See documentation&nbsp;
Arguments are available only at build-time. See
documentation&nbsp;
<a
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"
rel="noopener noreferrer"
>

View File

@@ -46,6 +46,7 @@ const schema = z
.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
wildcardDomain: z.string(),
port: z.number(),
previewLimit: z.number(),
@@ -109,6 +110,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
form.reset({
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
@@ -127,6 +129,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
updateApplication({
previewEnv: formData.env,
previewBuildArgs: formData.buildArgs,
previewBuildSecrets: formData.buildSecrets,
previewWildcard: formData.wildcardDomain,
previewPort: formData.port,
previewLabels: formData.previewLabels,
@@ -467,13 +470,37 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
{data?.buildType === "dockerfile" && (
<Secrets
name="buildArgs"
title="Build-time Variables"
title="Build-time Arguments"
description={
<span>
Available only at build-time. See documentation&nbsp;
Arguments are available only at build-time. See
documentation&nbsp;
<a
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"
rel="noopener noreferrer"
>

View File

@@ -6,6 +6,7 @@ import {
Terminal,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -33,6 +34,9 @@ interface Props {
}
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const [runningSchedules, setRunningSchedules] = useState<Set<string>>(
new Set(),
);
const {
data: schedules,
isLoading: isLoadingSchedules,
@@ -46,14 +50,27 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
api.schedule.delete.useMutation();
const { mutateAsync: runManually } = api.schedule.runManually.useMutation();
const { mutateAsync: runManually, isLoading } =
api.schedule.runManually.useMutation();
const handleRunManually = async (scheduleId: string) => {
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 (
<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.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
@@ -75,7 +91,7 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
</CardHeader>
<CardContent className="px-0">
{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" />
<span className="text-sm text-muted-foreground/70">
Loading scheduled tasks...
@@ -91,13 +107,13 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
return (
<div
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">
<Clock className="size-4 text-primary/70" />
</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">
<h3 className="text-sm font-medium leading-none [overflow-wrap:anywhere] line-clamp-3">
{schedule.name}
@@ -132,16 +148,15 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
)}
</div>
{schedule.command && (
<div className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground/70" />
<code className="font-mono text-[10px] text-muted-foreground/70">
<div className="flex items-start gap-2 max-w-full">
<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 break-all max-w-[calc(100%-20px)]">
{schedule.command}
</code>
</div>
)}
</div>
</div>
<div className="flex items-center gap-0.5 md:gap-1.5">
<ShowDeploymentsModal
id={schedule.scheduleId}
@@ -149,10 +164,9 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
<ClipboardList className="size-4 transition-colors" />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -160,37 +174,26 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
type="button"
variant="ghost"
size="icon"
isLoading={isLoading}
onClick={async () => {
toast.success("Schedule run successfully");
await runManually({
scheduleId: schedule.scheduleId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchSchedules();
})
.catch(() => {
toast.error("Error running schedule");
});
}}
disabled={runningSchedules.has(schedule.scheduleId)}
onClick={() =>
handleRunManually(schedule.scheduleId)
}
>
<Play className="size-4 transition-colors" />
{runningSchedules.has(schedule.scheduleId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Schedule</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleSchedules
scheduleId={schedule.scheduleId}
id={id}
scheduleType={scheduleType}
/>
<DialogAction
title="Delete Schedule"
description="Are you sure you want to delete this schedule?"
@@ -214,8 +217,8 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isDeleting}
className="group hover:bg-red-500/10"
disabled={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>

View File

@@ -5,6 +5,7 @@ import {
Play,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -38,6 +39,7 @@ export const ShowVolumeBackups = ({
type = "application",
serverId,
}: Props) => {
const [runningBackups, setRunningBackups] = useState<Set<string>>(new Set());
const {
data: volumeBackups,
isLoading: isLoadingVolumeBackups,
@@ -51,19 +53,33 @@ export const ShowVolumeBackups = ({
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteVolumeBackup, isLoading: isDeleting } =
api.volumeBackups.delete.useMutation();
const { mutateAsync: runManually, isLoading } =
const { mutateAsync: runManually } =
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 (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<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">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Volume Backups
@@ -73,12 +89,10 @@ export const ShowVolumeBackups = ({
intervals.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-wrap">
{volumeBackups && volumeBackups.length > 0 && (
<>
<HandleVolumeBackups id={id} volumeBackupType={type} />
<div className="flex items-center gap-2">
<RestoreVolumeBackups
id={id}
@@ -93,7 +107,7 @@ export const ShowVolumeBackups = ({
</CardHeader>
<CardContent className="px-0">
{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" />
<span className="text-sm text-muted-foreground/70">
Loading volume backups...
@@ -113,13 +127,13 @@ export const ShowVolumeBackups = ({
return (
<div
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">
<DatabaseBackup className="size-4 text-primary/70" />
</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">
<h3 className="text-sm font-medium leading-none">
{volumeBackup.name}
@@ -143,18 +157,16 @@ export const ShowVolumeBackups = ({
</div>
</div>
</div>
<div className="flex items-center gap-1.5">
<div className="flex items-center gap-1.5 mt-2 sm:mt-0 sm:ml-3">
<ShowDeploymentsModal
id={volumeBackup.volumeBackupId}
type="volumeBackup"
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
<ClipboardList className="size-4 transition-colors" />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
@@ -162,25 +174,18 @@ export const ShowVolumeBackups = ({
type="button"
variant="ghost"
size="icon"
isLoading={isLoading}
onClick={async () => {
toast.success("Volume backup run successfully");
await runManually({
volumeBackupId: volumeBackup.volumeBackupId,
})
.then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchVolumeBackups();
})
.catch(() => {
toast.error("Error running volume backup");
});
}}
disabled={runningBackups.has(
volumeBackup.volumeBackupId,
)}
onClick={() =>
handleRunManually(volumeBackup.volumeBackupId)
}
>
<Play className="size-4 transition-colors" />
{runningBackups.has(volumeBackup.volumeBackupId) ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Play className="size-4 transition-colors" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
@@ -188,13 +193,11 @@ export const ShowVolumeBackups = ({
</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleVolumeBackups
volumeBackupId={volumeBackup.volumeBackupId}
id={id}
volumeBackupType={type}
/>
<DialogAction
title="Delete Volume Backup"
description="Are you sure you want to delete this volume backup?"
@@ -218,7 +221,7 @@ export const ShowVolumeBackups = ({
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
className="group hover:bg-red-500/10"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
@@ -230,7 +233,7 @@ export const ShowVolumeBackups = ({
})}
</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" />
<p className="text-lg font-medium text-muted-foreground">
No volume backups

View File

@@ -74,6 +74,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
await mutateAsync({
composeId,
composeFile: data.composeFile,
composePath: "./docker-compose.yml",
sourceType: "raw",
})
.then(async () => {

View File

@@ -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 { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
@@ -67,6 +75,7 @@ export const DockerLogsId: React.FC<Props> = ({
const isPausedRef = useRef(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [isLoading, setIsLoading] = React.useState(false);
const [copied, setCopied] = React.useState(false);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
@@ -237,6 +246,29 @@ export const DockerLogsId: React.FC<Props> = ({
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[]) => {
return logs.filter((log) => {
const logType = getLogType(log.message).type;
@@ -320,6 +352,21 @@ export const DockerLogsId: React.FC<Props> = ({
)}
{isPaused ? "Resume" : "Pause"}
</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
variant="outline"
size="sm"

View File

@@ -281,7 +281,7 @@ export const ImpersonationBar = () => {
<div className="flex items-center gap-4 flex-1 flex-wrap">
<Avatar className="h-10 w-10">
<AvatarImage
className="object-cover"
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
/>

View File

@@ -248,7 +248,7 @@ export const AdvancedEnvironmentSelector = ({
</DropdownMenuItem>
{/* Action buttons for non-production environments */}
<EnvironmentVariables environmentId={environment.environmentId}>
{/* <EnvironmentVariables environmentId={environment.environmentId}>
<Button
variant="ghost"
size="sm"
@@ -259,7 +259,7 @@ export const AdvancedEnvironmentSelector = ({
>
<Terminal className="h-3 w-3" />
</Button>
</EnvironmentVariables>
</EnvironmentVariables> */}
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<Button

View File

@@ -82,6 +82,21 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
.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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>

View File

@@ -81,6 +81,21 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
.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 (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>

View File

@@ -217,7 +217,7 @@ export const HandleDestinations = ({ destinationId }: Props) => {
</DialogDescription>
</DialogHeader>
{(isError || isErrorConnection) && (
<AlertBlock type="error" className="break-words">
<AlertBlock type="error" className="w-full">
{connectionError?.message || error?.message}
</AlertBlock>
)}

View File

@@ -1,11 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import {
AlertTriangle,
Mail,
MessageCircleMore,
PenBoxIcon,
PlusIcon,
} from "lucide-react";
import { AlertTriangle, Mail, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -13,6 +7,7 @@ import { z } from "zod";
import {
DiscordIcon,
GotifyIcon,
LarkIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
@@ -120,6 +115,12 @@ export const notificationSchema = z.discriminatedUnion("type", [
headers: z.string().optional(),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("lark"),
webhookUrl: z.string().min(1, { message: "Webhook URL is required" }),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -135,6 +136,10 @@ export const notificationsMap = {
icon: <DiscordIcon />,
label: "Discord",
},
lark: {
icon: <LarkIcon className="text-muted-foreground" />,
label: "Lark",
},
email: {
icon: <Mail size={29} className="text-muted-foreground" />,
label: "Email",
@@ -186,6 +191,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testNtfyConnection.useMutation();
const { mutateAsync: testCustomConnection, isLoading: isLoadingCustom } =
api.notification.testCustomConnection.useMutation();
const { mutateAsync: testLarkConnection, isLoading: isLoadingLark } =
api.notification.testLarkConnection.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -207,6 +214,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const customMutation = notificationId
? api.notification.updateCustom.useMutation()
: api.notification.createCustom.useMutation();
const larkMutation = notificationId
? api.notification.updateLark.useMutation()
: api.notification.createLark.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -316,6 +326,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
serverUrl: notification.ntfy?.serverUrl,
name: notification.name,
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") {
form.reset({
@@ -344,6 +367,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
gotify: gotifyMutation,
ntfy: ntfyMutation,
custom: customMutation,
lark: larkMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -461,6 +485,19 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
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) {
@@ -549,7 +586,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
<Label
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.label}
@@ -1090,6 +1127,27 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</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 className="flex flex-col gap-4">
@@ -1241,7 +1299,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingEmail ||
isLoadingGotify ||
isLoadingNtfy ||
isLoadingCustom
isLoadingCustom ||
isLoadingLark
}
variant="secondary"
onClick={async () => {
@@ -1290,6 +1349,10 @@ export const HandleNotifications = ({ notificationId }: Props) => {
endpoint: form.getValues("endpoint") as string,
headers: form.getValues("headers") as string | undefined,
});
} else if (type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
});
}
toast.success("Connection Success");
} catch {

View File

@@ -2,7 +2,6 @@ import {
Bell,
Loader2,
Mail,
MessageCircleMore,
PenBoxIcon,
Trash2,
} from "lucide-react";
@@ -10,6 +9,7 @@ import { toast } from "sonner";
import {
DiscordIcon,
GotifyIcon,
LarkIcon,
NtfyIcon,
SlackIcon,
TelegramIcon,
@@ -42,7 +42,7 @@ export const ShowNotifications = () => {
</CardTitle>
<CardDescription>
Add your providers to receive notifications, like Discord, Slack,
Telegram, Email.
Telegram, Email, Lark.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -107,6 +107,11 @@ export const ShowNotifications = () => {
<PenBoxIcon className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "lark" && (
<div className="flex items-center justify-center rounded-lg">
<LarkIcon className="size-7 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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 { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
@@ -29,6 +30,12 @@ import {
InputOTPGroup,
InputOTPSlot,
} from "@/components/ui/input-otp";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
@@ -54,6 +61,26 @@ type TwoFactorSetupData = {
type PasswordForm = z.infer<typeof PasswordSchema>;
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 = () => {
const utils = api.useUtils();
const [data, setData] = useState<TwoFactorSetupData | null>(null);
@@ -62,6 +89,7 @@ export const Enable2FA = () => {
const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState("");
const { data: currentUser } = api.user.get.useQuery();
const handleVerifySubmit = async (e: React.FormEvent) => {
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 (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
@@ -264,6 +340,7 @@ export const Enable2FA = () => {
<span className="text-sm font-medium">
Scan this QR code with your authenticator app
</span>
{/** biome-ignore lint/performance/noImgElement: This is a valid use case for an img element */}
<img
src={data.qrCodeUrl}
alt="2FA QR Code"
@@ -281,7 +358,46 @@ export const Enable2FA = () => {
{backupCodes && backupCodes.length > 0 && (
<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">
{backupCodes.map((code, index) => (
<code

View File

@@ -29,7 +29,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Switch } from "@/components/ui/switch";
import { generateSHA256Hash, getFallbackAvatarInitials } from "@/lib/utils";
import { api } from "@/utils/api";
import { Disable2FA } from "./disable-2fa";
import { Configure2FA } from "./configure-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
@@ -62,7 +62,6 @@ const randomImages = [
];
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -120,28 +119,27 @@ export const ProfileForm = () => {
}, [form, data]);
const onSubmit = async (values: Profile) => {
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || 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");
try {
await mutateAsync({
email: values.email.toLowerCase(),
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
name: values.name || undefined,
});
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 (
@@ -158,7 +156,8 @@ export const ProfileForm = () => {
{t("settings.profile.description")}
</CardDescription>
</div>
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Disable2FA />}
{!data?.user.twoFactorEnabled ? <Enable2FA /> : <Configure2FA />}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
@@ -304,6 +303,7 @@ export const ProfileForm = () => {
}
>
{field.value?.startsWith("data:") ? (
// biome-ignore lint/performance/noImgElement: this is an justified use of img element
<img
src={field.value}
alt="Custom avatar"
@@ -362,6 +362,7 @@ export const ProfileForm = () => {
/>
</FormControl>
{/* biome-ignore lint/performance/noImgElement: this is an justified use of img element */}
<img
key={image}
src={image}

View File

@@ -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 (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>

View File

@@ -88,7 +88,32 @@ export const DiscordIcon = ({ className }: Props) => {
</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) => {
return (
<svg

View File

@@ -44,7 +44,7 @@ export const UserNav = () => {
>
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage
className="object-cover"
className="object-cover"
src={data?.user?.image || ""}
alt={data?.user?.image || ""}
/>

View File

@@ -39,13 +39,19 @@ export function AlertBlock({
<div
{...props}
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,
className,
)}
>
{icon || <Icon className="text-current" />}
<span className="text-sm text-current">{children}</span>
<div className="flex-shrink-0 mt-0.5">
{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>
);
}

View File

@@ -55,6 +55,8 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref,
) => {
const Comp = asChild ? Slot : "button";
const type = props.type ?? undefined;
return (
<>
<Comp
@@ -65,6 +67,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
ref={ref}
{...props}
disabled={isLoading || props.disabled}
type={type}
>
{isLoading && <Loader2 className="animate-spin" />}
<Slottable>{children}</Slottable>

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "previewBuildSecrets" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildSecrets" text;

View File

@@ -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

View File

@@ -820,6 +820,20 @@
"when": 1759645163834,
"tag": "0116_amusing_firedrake",
"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
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.5",
"version": "v0.25.6",
"private": true,
"license": "Apache-2.0",
"type": "module",

View File

@@ -368,14 +368,14 @@ export const extractCommitedPaths = async (
.map((change: any) => change.new?.target?.hash)
.filter(Boolean);
const commitedPaths: string[] = [];
const username =
bitbucket?.bitbucketWorkspaceName || bitbucket?.bitbucketUsername || "";
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 {
const response = await fetch(url, {
headers: getBitbucketHeaders(bitbucket!),
});
const data = await response.json();
for (const value of data.values) {
if (value?.new?.path) commitedPaths.push(value.new.path);

View File

@@ -14,6 +14,7 @@ import {
PlusIcon,
Search,
ServerIcon,
SquareTerminal,
Trash2,
X,
} from "lucide-react";
@@ -33,6 +34,7 @@ import { AddDatabase } from "@/components/dashboard/project/add-database";
import { AddTemplate } from "@/components/dashboard/project/add-template";
import { AdvancedEnvironmentSelector } from "@/components/dashboard/project/advanced-environment-selector";
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 {
MariadbIcon,
@@ -46,6 +48,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
@@ -95,7 +98,6 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
export type Services = {
appName: string;
@@ -776,6 +778,11 @@ const EnvironmentPage = (
projectId={projectId}
currentEnvironmentId={environmentId}
/>
<EnvironmentVariables environmentId={environmentId}>
<Button variant="ghost" size="icon">
<SquareTerminal className="size-5 text-muted-foreground cursor-pointer" />
</Button>
</EnvironmentVariables>
</CardTitle>
<CardDescription>
{currentEnvironment.description || "No description provided"}

View File

@@ -62,6 +62,12 @@ export const aiRouter = createTRPCRouter({
case "ollama":
response = await fetch(`${input.apiUrl}/api/tags`, { headers });
break;
case "gemini":
response = await fetch(
`${input.apiUrl}/models?key=${encodeURIComponent(input.apiKey)}`,
{ headers: {} },
);
break;
default:
if (!input.apiKey)
throw new TRPCError({

View File

@@ -360,6 +360,7 @@ export const applicationRouter = createTRPCRouter({
await updateApplication(input.applicationId, {
env: input.env,
buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
});
return true;
}),

View File

@@ -2,6 +2,7 @@ import {
createCustomNotification,
createDiscordNotification,
createEmailNotification,
createLarkNotification,
createGotifyNotification,
createNtfyNotification,
createSlackNotification,
@@ -12,6 +13,7 @@ import {
sendCustomNotification,
sendDiscordNotification,
sendEmailNotification,
sendLarkNotification,
sendGotifyNotification,
sendNtfyNotification,
sendServerThresholdNotifications,
@@ -20,6 +22,7 @@ import {
updateCustomNotification,
updateDiscordNotification,
updateEmailNotification,
updateLarkNotification,
updateGotifyNotification,
updateNtfyNotification,
updateSlackNotification,
@@ -39,6 +42,7 @@ import {
apiCreateCustom,
apiCreateDiscord,
apiCreateEmail,
apiCreateLark,
apiCreateGotify,
apiCreateNtfy,
apiCreateSlack,
@@ -47,6 +51,7 @@ import {
apiTestCustomConnection,
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestLarkConnection,
apiTestGotifyConnection,
apiTestNtfyConnection,
apiTestSlackConnection,
@@ -54,6 +59,7 @@ import {
apiUpdateCustom,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateLark,
apiUpdateGotify,
apiUpdateNtfy,
apiUpdateSlack,
@@ -335,6 +341,7 @@ export const notificationRouter = createTRPCRouter({
gotify: true,
ntfy: true,
custom: true,
lark: true,
},
orderBy: desc(notifications.createdAt),
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 }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),

View File

@@ -22,7 +22,7 @@ export const getGiteaOAuthUrl = (
}
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(
redirectUri,

View File

@@ -117,7 +117,7 @@ func getRealOS() string {
func GetServerMetrics() database.ServerMetric {
v, _ := mem.VirtualMemory()
c, _ := cpu.Percent(0, false)
c, _ := cpu.Percent(time.Second, false)
cpuInfo, _ := cpu.Info()
diskInfo, _ := disk.Usage("/")
netInfo, _ := net.IOCounters(false)
@@ -130,7 +130,8 @@ func GetServerMetrics() database.ServerMetric {
}
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
var networkIn, networkOut float64