mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-07-22 22:35:23 +02:00
Compare commits
20 Commits
fix/projec
...
v0.29.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5bb9486a9 | ||
|
|
c72698cc11 | ||
|
|
a1b26e8b83 | ||
|
|
b176f8f860 | ||
|
|
59b0e51ef7 | ||
|
|
8c900408bd | ||
|
|
8db1250487 | ||
|
|
71bbbb44db | ||
|
|
2440e8f803 | ||
|
|
ca2708a58a | ||
|
|
f8a3561f1e | ||
|
|
38de9ef218 | ||
|
|
cb23d726fe | ||
|
|
aa93897eae | ||
|
|
475a01c4a2 | ||
|
|
8a0e44291f | ||
|
|
3e11c0a240 | ||
|
|
b96c5e8655 | ||
|
|
b3c2e1e5af | ||
|
|
60867d0b60 |
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
50
apps/dokploy/__test__/backups/redact-credentials.test.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
describe("redactRcloneCredentials (#4621)", () => {
|
||||||
|
it("should redact access key in rclone command", () => {
|
||||||
|
const cmd =
|
||||||
|
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
|
||||||
|
const redacted = redactRcloneCredentials(cmd);
|
||||||
|
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||||
|
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redact secret access key in rclone command", () => {
|
||||||
|
const cmd =
|
||||||
|
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
|
||||||
|
const redacted = redactRcloneCredentials(cmd);
|
||||||
|
expect(redacted).not.toContain("supersecret");
|
||||||
|
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should redact both credentials simultaneously", () => {
|
||||||
|
const cmd =
|
||||||
|
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
|
||||||
|
const redacted = redactRcloneCredentials(cmd);
|
||||||
|
expect(redacted).not.toContain("AKIA123");
|
||||||
|
expect(redacted).not.toContain("secret456");
|
||||||
|
expect(redacted).toContain('--s3-region="us-east-1"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not modify non-credential flags", () => {
|
||||||
|
const cmd =
|
||||||
|
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
|
||||||
|
const redacted = redactRcloneCredentials(cmd);
|
||||||
|
expect(redacted).toBe(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle commands with no credentials", () => {
|
||||||
|
const cmd = "rclone lsf :s3:bucket/";
|
||||||
|
expect(redactRcloneCredentials(cmd)).toBe(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle error strings containing credentials", () => {
|
||||||
|
const errorStr =
|
||||||
|
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
|
||||||
|
const redacted = redactRcloneCredentials(errorStr);
|
||||||
|
expect(redacted).not.toContain("MYKEY");
|
||||||
|
expect(redacted).not.toContain("MYSECRET");
|
||||||
|
expect(redacted).toContain("[REDACTED]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -188,6 +188,9 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Bitbucket Account</FormLabel>
|
<FormLabel>Bitbucket Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -196,7 +199,6 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -245,7 +247,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -333,7 +335,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -201,6 +201,9 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Gitea Account</FormLabel>
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -208,7 +211,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -258,7 +260,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -353,7 +355,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Github Account</FormLabel>
|
<FormLabel>Github Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -189,7 +192,14 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="Select a Github Account" />
|
<SelectValue placeholder="Select a Github Account">
|
||||||
|
{
|
||||||
|
githubProviders?.find(
|
||||||
|
(githubProvider) =>
|
||||||
|
githubProvider.githubId === field.value,
|
||||||
|
)?.gitProvider.name
|
||||||
|
}
|
||||||
|
</SelectValue>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -233,7 +243,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -243,7 +253,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name ?? field.value.repo)}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,16 +330,16 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? (branches?.find(
|
||||||
(branch) => branch.name === field.value,
|
(branch) => branch.name === field.value,
|
||||||
)?.name
|
)?.name ?? field.value)
|
||||||
: "Select branch"}
|
: "Select branch"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<FormLabel>Gitlab Account</FormLabel>
|
<FormLabel>Gitlab Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -205,7 +208,6 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -254,7 +256,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -351,7 +353,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -507,11 +507,20 @@ export const HandleVolumeBackups = ({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{mounts?.map((mount) => (
|
{mounts && mounts.length > 0 ? (
|
||||||
<SelectItem key={mount.Name} value={mount.Name || ""}>
|
mounts.map((mount) => (
|
||||||
|
<SelectItem
|
||||||
|
key={mount.Name}
|
||||||
|
value={mount.Name || ""}
|
||||||
|
>
|
||||||
{mount.Name}
|
{mount.Name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<SelectItem value="none" disabled>
|
||||||
|
No volumes found
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -263,7 +263,7 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Bitbucket Account</FormLabel>
|
<FormLabel>Bitbucket Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -198,7 +201,6 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -247,7 +249,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -335,7 +337,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -188,6 +188,9 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Gitea Account</FormLabel>
|
<FormLabel>Gitea Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -195,7 +198,6 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -244,7 +246,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -331,7 +333,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
enableSubmodules: data.enableSubmodules ?? false,
|
enableSubmodules: data.enableSubmodules ?? false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [form.reset, data?.composeId, form]);
|
}, [form.reset, data]);
|
||||||
|
|
||||||
const onSubmit = async (data: GithubProvider) => {
|
const onSubmit = async (data: GithubProvider) => {
|
||||||
await mutateAsync({
|
await mutateAsync({
|
||||||
@@ -179,6 +179,9 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Github Account</FormLabel>
|
<FormLabel>Github Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -186,7 +189,6 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -234,7 +236,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -244,7 +246,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
? "Loading...."
|
? "Loading...."
|
||||||
: (repositories?.find(
|
: (repositories?.find(
|
||||||
(repo) => repo.name === field.value.repo,
|
(repo) => repo.name === field.value.repo,
|
||||||
)?.name ?? "Select repository")}
|
)?.name ?? field.value.repo)}
|
||||||
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -321,16 +323,16 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{status === "pending" && fetchStatus === "fetching"
|
{status === "pending" && fetchStatus === "fetching"
|
||||||
? "Loading...."
|
? "Loading...."
|
||||||
: field.value
|
: field.value
|
||||||
? branches?.find(
|
? (branches?.find(
|
||||||
(branch) => branch.name === field.value,
|
(branch) => branch.name === field.value,
|
||||||
)?.name
|
)?.name ?? field.value)
|
||||||
: "Select branch"}
|
: "Select branch"}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -199,6 +199,9 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<FormLabel>Gitlab Account</FormLabel>
|
<FormLabel>Gitlab Account</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
field.onChange(value);
|
field.onChange(value);
|
||||||
form.setValue("repository", {
|
form.setValue("repository", {
|
||||||
owner: "",
|
owner: "",
|
||||||
@@ -208,7 +211,6 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
});
|
});
|
||||||
form.setValue("branch", "");
|
form.setValue("branch", "");
|
||||||
}}
|
}}
|
||||||
defaultValue={field.value}
|
|
||||||
value={field.value}
|
value={field.value}
|
||||||
>
|
>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
@@ -256,7 +258,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -353,7 +355,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
" w-full justify-between bg-input!",
|
" w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ export const HandleBackup = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ export const RestoreBackup = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -427,7 +427,7 @@ export const RestoreBackup = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full justify-between bg-input!",
|
"w-full justify-between",
|
||||||
!field.value && "text-muted-foreground",
|
!field.value && "text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
|||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value={data?.databaseUser} />
|
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Sqld Node</Label>
|
<Label>Sqld Node</Label>
|
||||||
@@ -71,7 +71,7 @@ export const ShowInternalLibsqlCredentials = ({ libsqlId }: Props) => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input enableCopyButton disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Enable Namespaces</Label>
|
<Label>Enable Namespaces</Label>
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
|||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value={data?.databaseUser} />
|
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Database Name</Label>
|
<Label>Database Name</Label>
|
||||||
<Input disabled value={data?.databaseName} />
|
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
@@ -79,7 +79,7 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input enableCopyButton disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
<div className="flex flex-col gap-2 md:col-span-2">
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value={data?.databaseUser} />
|
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -55,7 +55,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input enableCopyButton disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
<div className="flex flex-col gap-2 md:col-span-2">
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
|||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value={data?.databaseUser} />
|
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Database Name</Label>
|
<Label>Database Name</Label>
|
||||||
<Input disabled value={data?.databaseName} />
|
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
@@ -79,7 +79,7 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input enableCopyButton disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
<div className="flex flex-col gap-2 md:col-span-2">
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export function AddOrganization({ organizationId }: Props) {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
toast.error(
|
toast.error(
|
||||||
|
error?.message ??
|
||||||
`Failed to ${organizationId ? "update" : "create"} organization`,
|
`Failed to ${organizationId ? "update" : "create"} organization`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,11 +25,11 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
|||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value={data?.databaseUser} />
|
<Input enableCopyButton disabled value={data?.databaseUser} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Database Name</Label>
|
<Label>Database Name</Label>
|
||||||
<Input disabled value={data?.databaseName} />
|
<Input enableCopyButton disabled value={data?.databaseName} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
@@ -57,7 +57,7 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input enableCopyButton disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ const dockerImageDefaultPlaceholder: Record<DbType, string> = {
|
|||||||
mariadb: "mariadb:11",
|
mariadb: "mariadb:11",
|
||||||
mysql: "mysql:8",
|
mysql: "mysql:8",
|
||||||
postgres: "postgres:18",
|
postgres: "postgres:18",
|
||||||
redis: "redis:7",
|
redis: "redis:8",
|
||||||
};
|
};
|
||||||
|
|
||||||
const databasesUserDefaultPlaceholder: Record<
|
const databasesUserDefaultPlaceholder: Record<
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ interface Details {
|
|||||||
envVariables: EnvVariable[];
|
envVariables: EnvVariable[];
|
||||||
shortDescription: string;
|
shortDescription: string;
|
||||||
domains: Domain[];
|
domains: Domain[];
|
||||||
configFiles?: Mount[];
|
configFiles?: Mount[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Mount {
|
interface Mount {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
|||||||
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
<div className="grid w-full md:grid-cols-2 gap-4 md:gap-8">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>User</Label>
|
<Label>User</Label>
|
||||||
<Input disabled value="default" />
|
<Input enableCopyButton disabled value="default" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
@@ -53,7 +53,7 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Label>Internal Host</Label>
|
<Label>Internal Host</Label>
|
||||||
<Input disabled value={data?.appName} />
|
<Input enableCopyButton disabled value={data?.appName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 md:col-span-2">
|
<div className="flex flex-col gap-2 md:col-span-2">
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
const apiUrl = form.watch("apiUrl");
|
const apiUrl = form.watch("apiUrl");
|
||||||
const apiKey = form.watch("apiKey");
|
const apiKey = form.watch("apiKey");
|
||||||
|
|
||||||
const isOllama = apiUrl.includes(":11434") || apiUrl.includes("ollama");
|
// Any Ollama instance on the default port 11434 is treated as no-auth
|
||||||
|
// (covers localhost and self-hosted LAN deployments). Ollama Cloud
|
||||||
|
// (ollama.com on 443) falls through and requires an API key.
|
||||||
|
const isLocalOllama = apiUrl.includes(":11434");
|
||||||
const {
|
const {
|
||||||
data: models,
|
data: models,
|
||||||
isFetching: isLoadingServerModels,
|
isFetching: isLoadingServerModels,
|
||||||
@@ -142,7 +145,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
apiKey: apiKey ?? "",
|
apiKey: apiKey ?? "",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: !!apiUrl && (isOllama || !!apiKey),
|
enabled: !!apiUrl && (isLocalOllama || !!apiKey),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -275,7 +278,7 @@ export const HandleAi = ({ aiId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!isOllama && (
|
{!isLocalOllama && (
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="apiKey"
|
name="apiKey"
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm">
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -82,7 +82,7 @@ export const ShowNodeApplications = ({ serverId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm">
|
||||||
<Layers className="h-4 w-4 mr-2" />
|
<Layers className="h-4 w-4 mr-2" />
|
||||||
Services
|
Services
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ export function NodeCard({ node, serverId }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end w-full space-x-4">
|
<div className="flex justify-end w-full gap-4">
|
||||||
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
|
<ShowNodeConfig nodeId={node.ID} serverId={serverId} />
|
||||||
<ShowNodeApplications serverId={serverId} />
|
<ShowNodeApplications serverId={serverId} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const ShowNodeConfig = ({ nodeId, serverId }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="sm" className="w-full">
|
<Button variant="outline" size="sm">
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
Config
|
Config
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -94,9 +94,10 @@ export function TagFilter({
|
|||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
<div className="flex flex-col items-center gap-2 py-1">
|
<div className="flex flex-col items-center gap-2 py-1">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
No tags found.
|
{tags.length === 0
|
||||||
|
? "No tags created yet."
|
||||||
|
: "No tags found."}
|
||||||
</span>
|
</span>
|
||||||
<HandleTag />
|
|
||||||
</div>
|
</div>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
@@ -118,6 +119,9 @@ export function TagFilter({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<div className="flex items-center justify-center p-2 border-t">
|
||||||
|
<HandleTag />
|
||||||
|
</div>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -111,19 +111,12 @@ export function TagSelector({
|
|||||||
<CommandEmpty>
|
<CommandEmpty>
|
||||||
<div className="flex flex-col items-center gap-2 py-1">
|
<div className="flex flex-col items-center gap-2 py-1">
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
No tags found.
|
{tags.length === 0
|
||||||
|
? "No tags created yet."
|
||||||
|
: "No tags found."}
|
||||||
</span>
|
</span>
|
||||||
<HandleTag />
|
|
||||||
</div>
|
</div>
|
||||||
</CommandEmpty>
|
</CommandEmpty>
|
||||||
{tags.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center gap-2 py-4">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
No tags created yet.
|
|
||||||
</span>
|
|
||||||
<HandleTag />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const isSelected = selectedTags.includes(tag.id);
|
const isSelected = selectedTags.includes(tag.id);
|
||||||
@@ -153,6 +146,9 @@ export function TagSelector({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<div className="flex items-center justify-center p-2 border-t">
|
||||||
|
<HandleTag />
|
||||||
|
</div>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context";
|
||||||
import { XIcon } from "lucide-react";
|
import { XIcon } from "lucide-react";
|
||||||
|
|
||||||
function Dialog({
|
function Dialog({
|
||||||
@@ -49,6 +50,8 @@ function DialogContent({
|
|||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
|
onPointerDownOutside,
|
||||||
|
onEscapeKeyDown,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
@@ -62,6 +65,20 @@ function DialogContent({
|
|||||||
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
"fixed top-1/2 left-1/2 z-50 flex max-h-[90vh] w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 flex-col gap-4 overflow-y-auto overscroll-contain rounded-xl bg-popover p-6 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onPointerDownOutside={(event) => {
|
||||||
|
if (wasNestedPopupJustClosed()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPointerDownOutside?.(event);
|
||||||
|
}}
|
||||||
|
onEscapeKeyDown={(event) => {
|
||||||
|
if (wasNestedPopupJustClosed()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onEscapeKeyDown?.(event);
|
||||||
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -2,12 +2,25 @@ import * as React from "react";
|
|||||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
return (
|
||||||
|
<DropdownMenuPrimitive.Root
|
||||||
|
data-slot="dropdown-menu"
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
markNestedPopupClosed();
|
||||||
|
}
|
||||||
|
onOpenChange?.(open);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
import copy from "copy-to-clipboard";
|
||||||
|
import { Clipboard, EyeIcon, EyeOffIcon, RefreshCcw } from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { generateRandomPassword } from "@/lib/password-utils";
|
import { generateRandomPassword } from "@/lib/password-utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button } from "./button";
|
||||||
|
|
||||||
export interface InputProps extends React.ComponentProps<"input"> {
|
export interface InputProps extends React.ComponentProps<"input"> {
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
enablePasswordGenerator?: boolean;
|
enablePasswordGenerator?: boolean;
|
||||||
passwordGeneratorLength?: number;
|
passwordGeneratorLength?: number;
|
||||||
|
enableCopyButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Input({
|
function Input({
|
||||||
@@ -16,6 +20,7 @@ function Input({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
enablePasswordGenerator = false,
|
enablePasswordGenerator = false,
|
||||||
passwordGeneratorLength,
|
passwordGeneratorLength,
|
||||||
|
enableCopyButton = false,
|
||||||
ref,
|
ref,
|
||||||
...props
|
...props
|
||||||
}: InputProps) {
|
}: InputProps) {
|
||||||
@@ -65,8 +70,12 @@ function Input({
|
|||||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const handleCopy = () => {
|
||||||
<>
|
copy(inputRef.current?.value || "");
|
||||||
|
toast.success("Value is copied to clipboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputElement = (
|
||||||
<div className="relative w-full">
|
<div className="relative w-full">
|
||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
@@ -108,6 +117,20 @@ function Input({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{enableCopyButton ? (
|
||||||
|
<div className="flex w-full items-center space-x-2">
|
||||||
|
{inputElement}
|
||||||
|
<Button type="button" variant={"secondary"} onClick={handleCopy}>
|
||||||
|
<Clipboard className="size-4 text-muted-foreground" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
inputElement
|
||||||
|
)}
|
||||||
{errorMessage && (
|
{errorMessage && (
|
||||||
<span className="text-sm text-red-600 text-secondary-foreground">
|
<span className="text-sm text-red-600 text-secondary-foreground">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
|
|||||||
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
9
apps/dokploy/components/ui/nested-popup-context.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
let lastNestedPopupCloseAt = 0;
|
||||||
|
|
||||||
|
export function markNestedPopupClosed() {
|
||||||
|
lastNestedPopupCloseAt = performance.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasNestedPopupJustClosed() {
|
||||||
|
return performance.now() - lastNestedPopupCloseAt < 100;
|
||||||
|
}
|
||||||
@@ -4,11 +4,24 @@ import * as React from "react";
|
|||||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { markNestedPopupClosed } from "@/components/ui/nested-popup-context";
|
||||||
|
|
||||||
function Popover({
|
function Popover({
|
||||||
|
onOpenChange,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
return (
|
||||||
|
<PopoverPrimitive.Root
|
||||||
|
data-slot="popover"
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
markNestedPopupClosed();
|
||||||
|
}
|
||||||
|
onOpenChange?.(open);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PopoverTrigger({
|
function PopoverTrigger({
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function SelectTrigger({
|
|||||||
function SelectContent({
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
position = "item-aligned",
|
position = "popper",
|
||||||
align = "center",
|
align = "center",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "dokploy",
|
"name": "dokploy",
|
||||||
"version": "v0.29.8",
|
"version": "v0.29.10",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1878,7 +1878,7 @@ export async function getServerSideProps(
|
|||||||
// Try to find default, otherwise use first accessible
|
// Try to find default, otherwise use first accessible
|
||||||
const targetEnv =
|
const targetEnv =
|
||||||
accessibleEnvironments.find((env) => env.isDefault) ||
|
accessibleEnvironments.find((env) => env.isDefault) ||
|
||||||
accessibleEnvironments[0];
|
accessibleEnvironments[0]!;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
|
|||||||
@@ -54,6 +54,20 @@ const Page = ({ isCloud }: Props) => {
|
|||||||
</EnterpriseFeatureGate>
|
</EnterpriseFeatureGate>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
<EnterpriseFeatureGate
|
||||||
|
lockedProps={{
|
||||||
|
title: "Application Authentication",
|
||||||
|
description:
|
||||||
|
"Protect deployed applications behind an OIDC SSO gate (oauth2-proxy). Part of Dokploy Enterprise.",
|
||||||
|
ctaLabel: "Go to License",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ForwardAuthServers />
|
||||||
|
</EnterpriseFeatureGate>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
{!isCloud && (
|
{!isCloud && (
|
||||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
|
||||||
<div className="rounded-xl bg-background shadow-md">
|
<div className="rounded-xl bg-background shadow-md">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createBackup,
|
createBackup,
|
||||||
findBackupById,
|
findBackupById,
|
||||||
|
findBackupsByDbId,
|
||||||
findComposeByBackupId,
|
findComposeByBackupId,
|
||||||
findComposeById,
|
findComposeById,
|
||||||
findLibsqlByBackupId,
|
findLibsqlByBackupId,
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
withPermission,
|
withPermission,
|
||||||
} from "@/server/api/trpc";
|
} from "@/server/api/trpc";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertDatabaseBackupLimit } from "@/server/api/utils/plan-limits";
|
||||||
import {
|
import {
|
||||||
apiCreateBackup,
|
apiCreateBackup,
|
||||||
apiFindOneBackup,
|
apiFindOneBackup,
|
||||||
@@ -94,6 +96,22 @@ export const backupRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
const dbType = (
|
||||||
|
["postgres", "mysql", "mariadb", "mongo", "libsql"] as const
|
||||||
|
).find((type) => input[`${type}Id`]);
|
||||||
|
if (dbType) {
|
||||||
|
const existingBackups = await findBackupsByDbId(
|
||||||
|
input[`${dbType}Id`]!,
|
||||||
|
dbType,
|
||||||
|
);
|
||||||
|
await assertDatabaseBackupLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
existingBackups.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const newBackup = await createBackup(input);
|
const newBackup = await createBackup(input);
|
||||||
const backup = await findBackupById(newBackup.backupId);
|
const backup = await findBackupById(newBackup.backupId);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
findAllDeploymentsByServerId,
|
findAllDeploymentsByServerId,
|
||||||
findAllDeploymentsCentralized,
|
findAllDeploymentsCentralized,
|
||||||
findDeploymentById,
|
findDeploymentById,
|
||||||
|
findScheduleById,
|
||||||
IS_CLOUD,
|
IS_CLOUD,
|
||||||
removeDeployment,
|
removeDeployment,
|
||||||
resolveServicePath,
|
resolveServicePath,
|
||||||
@@ -126,9 +127,29 @@ export const deploymentRouter = createTRPCRouter({
|
|||||||
allByType: protectedProcedure
|
allByType: protectedProcedure
|
||||||
.input(apiFindAllByType)
|
.input(apiFindAllByType)
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
|
if (input.type === "schedule") {
|
||||||
|
const schedule = await findScheduleById(input.id);
|
||||||
|
const serviceId = schedule.applicationId || schedule.composeId;
|
||||||
|
if (serviceId) {
|
||||||
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
|
deployment: ["read"],
|
||||||
|
});
|
||||||
|
} else if (schedule.serverId) {
|
||||||
|
const targetServer = await findServerById(schedule.serverId);
|
||||||
|
if (
|
||||||
|
targetServer.organizationId !== ctx.session.activeOrganizationId
|
||||||
|
) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "You don't have access to this schedule.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
await checkServicePermissionAndAccess(ctx, input.id, {
|
await checkServicePermissionAndAccess(ctx, input.id, {
|
||||||
deployment: ["read"],
|
deployment: ["read"],
|
||||||
});
|
});
|
||||||
|
}
|
||||||
const deploymentsList = await db.query.deployments.findMany({
|
const deploymentsList = await db.query.deployments.findMany({
|
||||||
where: eq(deployments[`${input.type}Id`], input.id),
|
where: eq(deployments[`${input.type}Id`], input.id),
|
||||||
orderBy: desc(deployments.createdAt),
|
orderBy: desc(deployments.createdAt),
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import {
|
|||||||
createEnvironment,
|
createEnvironment,
|
||||||
deleteEnvironment,
|
deleteEnvironment,
|
||||||
duplicateEnvironment,
|
duplicateEnvironment,
|
||||||
|
filterEnvironmentServices,
|
||||||
findEnvironmentById,
|
findEnvironmentById,
|
||||||
findEnvironmentsByProjectId,
|
findEnvironmentsByProjectId,
|
||||||
|
IS_CLOUD,
|
||||||
updateEnvironmentById,
|
updateEnvironmentById,
|
||||||
} from "@dokploy/server";
|
} from "@dokploy/server";
|
||||||
import { db } from "@dokploy/server/db";
|
import { db } from "@dokploy/server/db";
|
||||||
@@ -20,6 +22,7 @@ import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertEnvironmentLimit } from "@/server/api/utils/plan-limits";
|
||||||
import {
|
import {
|
||||||
apiCreateEnvironment,
|
apiCreateEnvironment,
|
||||||
apiDuplicateEnvironment,
|
apiDuplicateEnvironment,
|
||||||
@@ -30,43 +33,18 @@ import {
|
|||||||
projects,
|
projects,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
|
|
||||||
const filterEnvironmentServices = (
|
|
||||||
environment: any,
|
|
||||||
accessedServices: string[],
|
|
||||||
) => ({
|
|
||||||
...environment,
|
|
||||||
applications: environment.applications.filter((app: any) =>
|
|
||||||
accessedServices.includes(app.applicationId),
|
|
||||||
),
|
|
||||||
compose: environment.compose.filter((comp: any) =>
|
|
||||||
accessedServices.includes(comp.composeId),
|
|
||||||
),
|
|
||||||
libsql: environment.libsql.filter((db: any) =>
|
|
||||||
accessedServices.includes(db.libsqlId),
|
|
||||||
),
|
|
||||||
mariadb: environment.mariadb.filter((db: any) =>
|
|
||||||
accessedServices.includes(db.mariadbId),
|
|
||||||
),
|
|
||||||
mongo: environment.mongo.filter((db: any) =>
|
|
||||||
accessedServices.includes(db.mongoId),
|
|
||||||
),
|
|
||||||
mysql: environment.mysql.filter((db: any) =>
|
|
||||||
accessedServices.includes(db.mysqlId),
|
|
||||||
),
|
|
||||||
postgres: environment.postgres.filter((db: any) =>
|
|
||||||
accessedServices.includes(db.postgresId),
|
|
||||||
),
|
|
||||||
redis: environment.redis.filter((db: any) =>
|
|
||||||
accessedServices.includes(db.redisId),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const environmentRouter = createTRPCRouter({
|
export const environmentRouter = createTRPCRouter({
|
||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(apiCreateEnvironment)
|
.input(apiCreateEnvironment)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
try {
|
try {
|
||||||
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
await checkEnvironmentCreationPermission(ctx, input.projectId);
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertEnvironmentLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
input.projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (input.name === "production") {
|
if (input.name === "production") {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import { and, desc, eq, exists } from "drizzle-orm";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import {
|
||||||
|
assertMemberLimit,
|
||||||
|
assertOrganizationLimit,
|
||||||
|
} from "@/server/api/utils/plan-limits";
|
||||||
import {
|
import {
|
||||||
invitation,
|
invitation,
|
||||||
member,
|
member,
|
||||||
@@ -28,6 +32,11 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
message: "Only the organization owner can create an organization",
|
message: "Only the organization owner can create an organization",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertOrganizationLimit(ctx.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await db
|
const result = await db
|
||||||
.insert(organization)
|
.insert(organization)
|
||||||
.values({
|
.values({
|
||||||
@@ -258,6 +267,10 @@ export const organizationRouter = createTRPCRouter({
|
|||||||
const orgId = ctx.session.activeOrganizationId;
|
const orgId = ctx.session.activeOrganizationId;
|
||||||
const email = input.email.toLowerCase();
|
const email = input.email.toLowerCase();
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertMemberLimit(orgId);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user is already a member
|
// Check if user is already a member
|
||||||
const existingUser = await db.query.user.findFirst({
|
const existingUser = await db.query.user.findFirst({
|
||||||
where: eq(user.email, email),
|
where: eq(user.email, email),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { TRPCError } from "@trpc/server";
|
|||||||
import { asc, desc, eq } from "drizzle-orm";
|
import { asc, desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertScheduledJobLimit } from "@/server/api/utils/plan-limits";
|
||||||
import { removeJob, schedule } from "@/server/utils/backup";
|
import { removeJob, schedule } from "@/server/utils/backup";
|
||||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
@@ -35,6 +36,13 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
schedule: ["create"],
|
schedule: ["create"],
|
||||||
});
|
});
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertScheduledJobLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
input.applicationId ? "application" : "compose",
|
||||||
|
serviceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
@@ -73,6 +81,14 @@ export const scheduleRouter = createTRPCRouter({
|
|||||||
message: "You don't have access to this server.",
|
message: "You don't have access to this server.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (IS_CLOUD) {
|
||||||
|
await assertScheduledJobLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
"server",
|
||||||
|
input.serverId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const newSchedule = await createSchedule({
|
const newSchedule = await createSchedule({
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { getCurrentPlan as getCurrentPlanForOrganization } from "@/server/utils/billing";
|
||||||
import {
|
import {
|
||||||
type BillingTier,
|
type BillingTier,
|
||||||
getStripeItems,
|
getStripeItems,
|
||||||
@@ -31,44 +32,7 @@ import {
|
|||||||
export const stripeRouter = createTRPCRouter({
|
export const stripeRouter = createTRPCRouter({
|
||||||
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
|
||||||
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
|
||||||
if (!IS_CLOUD) return null;
|
return getCurrentPlanForOrganization(ctx.session.activeOrganizationId);
|
||||||
const owner = await findUserById(ctx.user.ownerId);
|
|
||||||
if (!owner?.stripeCustomerId) return null;
|
|
||||||
|
|
||||||
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
|
||||||
apiVersion: "2024-09-30.acacia",
|
|
||||||
});
|
|
||||||
const subscriptions = await stripe.subscriptions.list({
|
|
||||||
customer: owner.stripeCustomerId,
|
|
||||||
status: "active",
|
|
||||||
expand: ["data.items.data.price"],
|
|
||||||
});
|
|
||||||
const activeSub = subscriptions.data[0];
|
|
||||||
if (!activeSub) return null;
|
|
||||||
|
|
||||||
const priceIds = activeSub.items.data.map(
|
|
||||||
(item) => (item.price as Stripe.Price).id,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
priceIds.some(
|
|
||||||
(id) =>
|
|
||||||
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
|
||||||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return "startup" as const;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
priceIds.some(
|
|
||||||
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return "hobby" as const;
|
|
||||||
}
|
|
||||||
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
|
||||||
return "legacy" as const;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getProducts: adminProcedure.query(async ({ ctx }) => {
|
getProducts: adminProcedure.query(async ({ ctx }) => {
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { observable } from "@trpc/server/observable";
|
|||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { audit } from "@/server/api/utils/audit";
|
import { audit } from "@/server/api/utils/audit";
|
||||||
|
import { assertVolumeBackupLimit } from "@/server/api/utils/plan-limits";
|
||||||
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
|
||||||
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc";
|
||||||
|
|
||||||
@@ -69,20 +70,33 @@ export const volumeBackupsRouter = createTRPCRouter({
|
|||||||
create: protectedProcedure
|
create: protectedProcedure
|
||||||
.input(createVolumeBackupSchema)
|
.input(createVolumeBackupSchema)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const serviceId =
|
const serviceType = (
|
||||||
input.applicationId ||
|
[
|
||||||
input.postgresId ||
|
"application",
|
||||||
input.mysqlId ||
|
"postgres",
|
||||||
input.mariadbId ||
|
"mysql",
|
||||||
input.mongoId ||
|
"mariadb",
|
||||||
input.redisId ||
|
"mongo",
|
||||||
input.libsqlId ||
|
"redis",
|
||||||
input.composeId;
|
"libsql",
|
||||||
|
"compose",
|
||||||
|
] as const
|
||||||
|
).find((type) => input[`${type}Id`]);
|
||||||
|
const serviceId = serviceType ? input[`${serviceType}Id`] : undefined;
|
||||||
if (serviceId) {
|
if (serviceId) {
|
||||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||||
volumeBackup: ["create"],
|
volumeBackup: ["create"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (IS_CLOUD && serviceType && serviceId) {
|
||||||
|
const existingVolumeBackups = await db.query.volumeBackups.findMany({
|
||||||
|
where: eq(volumeBackups[`${serviceType}Id`], serviceId),
|
||||||
|
});
|
||||||
|
await assertVolumeBackupLimit(
|
||||||
|
ctx.session.activeOrganizationId,
|
||||||
|
existingVolumeBackups.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
const newVolumeBackup = await createVolumeBackup(input);
|
const newVolumeBackup = await createVolumeBackup(input);
|
||||||
|
|
||||||
if (newVolumeBackup?.enabled) {
|
if (newVolumeBackup?.enabled) {
|
||||||
|
|||||||
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
130
apps/dokploy/server/api/utils/plan-limits.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { db } from "@dokploy/server/db";
|
||||||
|
import {
|
||||||
|
environments,
|
||||||
|
member,
|
||||||
|
organization,
|
||||||
|
schedules,
|
||||||
|
} from "@dokploy/server/db/schema";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { getCurrentPlan, getCurrentPlanForUser } from "@/server/utils/billing";
|
||||||
|
|
||||||
|
export type PlanLimitResource =
|
||||||
|
| "organization"
|
||||||
|
| "member"
|
||||||
|
| "environment"
|
||||||
|
| "volumeBackup"
|
||||||
|
| "databaseBackup"
|
||||||
|
| "scheduledJob";
|
||||||
|
|
||||||
|
const UNLIMITED = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
export const PLAN_LIMITS: Record<
|
||||||
|
"hobby" | "startup" | "legacy",
|
||||||
|
Record<PlanLimitResource, number>
|
||||||
|
> = {
|
||||||
|
hobby: {
|
||||||
|
organization: 1,
|
||||||
|
member: 1,
|
||||||
|
environment: 2,
|
||||||
|
volumeBackup: 1,
|
||||||
|
databaseBackup: 1,
|
||||||
|
scheduledJob: 1,
|
||||||
|
},
|
||||||
|
startup: {
|
||||||
|
organization: 3,
|
||||||
|
member: UNLIMITED,
|
||||||
|
environment: UNLIMITED,
|
||||||
|
volumeBackup: UNLIMITED,
|
||||||
|
databaseBackup: UNLIMITED,
|
||||||
|
scheduledJob: UNLIMITED,
|
||||||
|
},
|
||||||
|
legacy: {
|
||||||
|
organization: UNLIMITED,
|
||||||
|
member: UNLIMITED,
|
||||||
|
environment: UNLIMITED,
|
||||||
|
volumeBackup: UNLIMITED,
|
||||||
|
databaseBackup: UNLIMITED,
|
||||||
|
scheduledJob: UNLIMITED,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const resourceLabels: Record<PlanLimitResource, string> = {
|
||||||
|
organization: "organizations",
|
||||||
|
member: "users",
|
||||||
|
environment: "environments per project",
|
||||||
|
volumeBackup: "volume backups per application",
|
||||||
|
databaseBackup: "backups per database",
|
||||||
|
scheduledJob: "scheduled jobs per service",
|
||||||
|
};
|
||||||
|
|
||||||
|
const assertLimitForPlan = (
|
||||||
|
plan: "hobby" | "startup" | "legacy" | null,
|
||||||
|
resource: PlanLimitResource,
|
||||||
|
currentCount: number,
|
||||||
|
) => {
|
||||||
|
const limit = PLAN_LIMITS[plan ?? "legacy"][resource];
|
||||||
|
|
||||||
|
if (currentCount >= limit) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: `You've reached your plan's limit of ${limit} ${resourceLabels[resource]}. Upgrade your plan to add more.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertOrganizationLimit = async (userId: string) => {
|
||||||
|
const plan = await getCurrentPlanForUser(userId);
|
||||||
|
const organizations = await db.query.organization.findMany({
|
||||||
|
where: eq(organization.ownerId, userId),
|
||||||
|
});
|
||||||
|
assertLimitForPlan(plan, "organization", organizations.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertMemberLimit = async (organizationId: string) => {
|
||||||
|
const plan = await getCurrentPlan(organizationId);
|
||||||
|
const members = await db.query.member.findMany({
|
||||||
|
where: eq(member.organizationId, organizationId),
|
||||||
|
});
|
||||||
|
assertLimitForPlan(plan, "member", members.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertEnvironmentLimit = async (
|
||||||
|
organizationId: string,
|
||||||
|
projectId: string,
|
||||||
|
) => {
|
||||||
|
const plan = await getCurrentPlan(organizationId);
|
||||||
|
const envs = await db.query.environments.findMany({
|
||||||
|
where: eq(environments.projectId, projectId),
|
||||||
|
});
|
||||||
|
assertLimitForPlan(plan, "environment", envs.length);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertVolumeBackupLimit = async (
|
||||||
|
organizationId: string,
|
||||||
|
currentCount: number,
|
||||||
|
) => {
|
||||||
|
const plan = await getCurrentPlan(organizationId);
|
||||||
|
assertLimitForPlan(plan, "volumeBackup", currentCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertDatabaseBackupLimit = async (
|
||||||
|
organizationId: string,
|
||||||
|
currentCount: number,
|
||||||
|
) => {
|
||||||
|
const plan = await getCurrentPlan(organizationId);
|
||||||
|
assertLimitForPlan(plan, "databaseBackup", currentCount);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assertScheduledJobLimit = async (
|
||||||
|
organizationId: string,
|
||||||
|
scheduleType: "application" | "compose" | "server",
|
||||||
|
serviceId: string,
|
||||||
|
) => {
|
||||||
|
const plan = await getCurrentPlan(organizationId);
|
||||||
|
const column = `${scheduleType}Id` as const;
|
||||||
|
const rows = await db.query.schedules.findMany({
|
||||||
|
where: eq(schedules[column], serviceId),
|
||||||
|
});
|
||||||
|
assertLimitForPlan(plan, "scheduledJob", rows.length);
|
||||||
|
};
|
||||||
69
apps/dokploy/server/utils/billing.ts
Normal file
69
apps/dokploy/server/utils/billing.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { findUserById, IS_CLOUD } from "@dokploy/server";
|
||||||
|
import { getOrganizationOwnerId } from "@dokploy/server/services/proprietary/sso";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
import {
|
||||||
|
HOBBY_PRICE_ANNUAL_ID,
|
||||||
|
HOBBY_PRICE_MONTHLY_ID,
|
||||||
|
LEGACY_PRICE_IDS,
|
||||||
|
STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||||
|
STARTUP_BASE_PRICE_MONTHLY_ID,
|
||||||
|
} from "@/server/utils/stripe";
|
||||||
|
|
||||||
|
export type BillingPlan = "legacy" | "hobby" | "startup";
|
||||||
|
|
||||||
|
export const getCurrentPlanForUser = async (
|
||||||
|
userId: string,
|
||||||
|
): Promise<BillingPlan | null> => {
|
||||||
|
if (!IS_CLOUD) return null;
|
||||||
|
|
||||||
|
const owner = await findUserById(userId);
|
||||||
|
if (!owner?.stripeCustomerId) return null;
|
||||||
|
|
||||||
|
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
|
||||||
|
apiVersion: "2024-09-30.acacia",
|
||||||
|
});
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: owner.stripeCustomerId,
|
||||||
|
status: "active",
|
||||||
|
expand: ["data.items.data.price"],
|
||||||
|
});
|
||||||
|
const activeSub = subscriptions.data[0];
|
||||||
|
if (!activeSub) return null;
|
||||||
|
|
||||||
|
const priceIds = activeSub.items.data.map(
|
||||||
|
(item) => (item.price as Stripe.Price).id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
priceIds.some(
|
||||||
|
(id) =>
|
||||||
|
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
|
||||||
|
id === STARTUP_BASE_PRICE_ANNUAL_ID,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "startup";
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
priceIds.some(
|
||||||
|
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return "hobby";
|
||||||
|
}
|
||||||
|
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
|
||||||
|
return "legacy";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getCurrentPlan = async (
|
||||||
|
organizationId: string,
|
||||||
|
): Promise<BillingPlan | null> => {
|
||||||
|
if (!IS_CLOUD) return null;
|
||||||
|
|
||||||
|
const ownerId = await getOrganizationOwnerId(organizationId);
|
||||||
|
if (!ownerId) return null;
|
||||||
|
|
||||||
|
return getCurrentPlanForUser(ownerId);
|
||||||
|
};
|
||||||
@@ -113,6 +113,10 @@
|
|||||||
color utility to any element that depends on these defaults.
|
color utility to any element that depends on these defaults.
|
||||||
*/
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
}
|
||||||
|
|
||||||
*,
|
*,
|
||||||
::after,
|
::after,
|
||||||
::before,
|
::before,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ interface DockerOutput {
|
|||||||
dockerCompose: string;
|
dockerCompose: string;
|
||||||
envVariables: Array<{ name: string; value: string }>;
|
envVariables: Array<{ name: string; value: string }>;
|
||||||
domains: Array<{ host: string; port: number; serviceName: string }>;
|
domains: Array<{ host: string; port: number; serviceName: string }>;
|
||||||
configFiles?: Array<{ content: string; filePath: string }>;
|
configFiles?: Array<{ content: string; filePath: string }> | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
|
||||||
@@ -136,7 +136,7 @@ export const suggestVariants = async ({
|
|||||||
filePath: z.string(),
|
filePath: z.string(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.optional(),
|
.nullable(),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -198,12 +198,12 @@ export const suggestVariants = async ({
|
|||||||
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
1. ALWAYS use 'image:' field, NEVER use 'build:' field
|
||||||
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
2. NEVER use 'build: .' or any build directive - we don't have local Dockerfiles
|
||||||
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
3. Use images from Docker Hub or other public registries (e.g., docker.io, ghcr.io, quay.io)
|
||||||
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:7, etc.)
|
4. For dependencies (databases, redis, etc.), use official images (e.g., postgres:16, redis:8, etc.)
|
||||||
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
5. Always specify image tags - avoid using 'latest' tag, use specific versions when possible
|
||||||
6. Examples of correct image usage:
|
6. Examples of correct image usage:
|
||||||
- image: sendingtk/chatwoot:develop
|
- image: sendingtk/chatwoot:develop
|
||||||
- image: postgres:16-alpine
|
- image: postgres:16-alpine
|
||||||
- image: redis:7-alpine
|
- image: redis:8-alpine
|
||||||
7. Examples of INCORRECT usage (DO NOT USE):
|
7. Examples of INCORRECT usage (DO NOT USE):
|
||||||
- build: .
|
- build: .
|
||||||
- build: ./app
|
- build: ./app
|
||||||
|
|||||||
@@ -308,6 +308,48 @@ export const duplicateEnvironment = async (
|
|||||||
return newEnvironment;
|
return newEnvironment;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface EnvironmentWithServices {
|
||||||
|
applications: { applicationId: string }[];
|
||||||
|
compose: { composeId: string }[];
|
||||||
|
libsql: { libsqlId: string }[];
|
||||||
|
mariadb: { mariadbId: string }[];
|
||||||
|
mongo: { mongoId: string }[];
|
||||||
|
mysql: { mysqlId: string }[];
|
||||||
|
postgres: { postgresId: string }[];
|
||||||
|
redis: { redisId: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filterEnvironmentServices = <T extends EnvironmentWithServices>(
|
||||||
|
environment: T,
|
||||||
|
accessedServices: string[],
|
||||||
|
): T => ({
|
||||||
|
...environment,
|
||||||
|
applications: environment.applications.filter((app) =>
|
||||||
|
accessedServices.includes(app.applicationId),
|
||||||
|
),
|
||||||
|
compose: environment.compose.filter((comp) =>
|
||||||
|
accessedServices.includes(comp.composeId),
|
||||||
|
),
|
||||||
|
libsql: environment.libsql.filter((db) =>
|
||||||
|
accessedServices.includes(db.libsqlId),
|
||||||
|
),
|
||||||
|
mariadb: environment.mariadb.filter((db) =>
|
||||||
|
accessedServices.includes(db.mariadbId),
|
||||||
|
),
|
||||||
|
mongo: environment.mongo.filter((db) =>
|
||||||
|
accessedServices.includes(db.mongoId),
|
||||||
|
),
|
||||||
|
mysql: environment.mysql.filter((db) =>
|
||||||
|
accessedServices.includes(db.mysqlId),
|
||||||
|
),
|
||||||
|
postgres: environment.postgres.filter((db) =>
|
||||||
|
accessedServices.includes(db.postgresId),
|
||||||
|
),
|
||||||
|
redis: environment.redis.filter((db) =>
|
||||||
|
accessedServices.includes(db.redisId),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
export const createProductionEnvironment = async (projectId: string) => {
|
export const createProductionEnvironment = async (projectId: string) => {
|
||||||
const newEnvironment = await db
|
const newEnvironment = await db
|
||||||
.insert(environments)
|
.insert(environments)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { docker } from "../constants";
|
|||||||
import { pullImage } from "../utils/docker/utils";
|
import { pullImage } from "../utils/docker/utils";
|
||||||
|
|
||||||
export const initializeRedis = async () => {
|
export const initializeRedis = async () => {
|
||||||
const imageName = "redis:7";
|
const imageName = "redis:8";
|
||||||
const containerName = "dokploy-redis";
|
const containerName = "dokploy-redis";
|
||||||
|
|
||||||
const settings: CreateServiceOptions = {
|
const settings: CreateServiceOptions = {
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) {
|
|||||||
});
|
});
|
||||||
case "ollama":
|
case "ollama":
|
||||||
return createOllama({
|
return createOllama({
|
||||||
// optional settings, e.g.
|
|
||||||
baseURL: config.apiUrl,
|
baseURL: config.apiUrl,
|
||||||
|
headers: config.apiKey
|
||||||
|
? { Authorization: `Bearer ${config.apiKey}` }
|
||||||
|
: undefined,
|
||||||
});
|
});
|
||||||
case "deepinfra":
|
case "deepinfra":
|
||||||
return createDeepInfra({
|
return createDeepInfra({
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { startLogCleanup } from "../access-log/handler";
|
|||||||
import { cleanupAll } from "../docker/utils";
|
import { cleanupAll } from "../docker/utils";
|
||||||
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
|
||||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||||
|
import { redactRcloneCredentials } from "./redact";
|
||||||
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils";
|
||||||
|
|
||||||
export const initCronJobs = async () => {
|
export const initCronJobs = async () => {
|
||||||
@@ -153,6 +154,6 @@ export const keepLatestNBackups = async (
|
|||||||
await execAsync(rcloneCommand);
|
await execAsync(rcloneCommand);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(redactRcloneCredentials(String(error)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
12
packages/server/src/utils/backups/redact.ts
Normal file
12
packages/server/src/utils/backups/redact.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Redacts S3 credentials from rclone command strings.
|
||||||
|
*
|
||||||
|
* Used to prevent credential leakage in structured logs and error output.
|
||||||
|
* Matches the flag format produced by `getS3Credentials()`:
|
||||||
|
* --s3-access-key-id="VALUE" and --s3-secret-access-key="VALUE"
|
||||||
|
*/
|
||||||
|
export const redactRcloneCredentials = (command: string): string => {
|
||||||
|
return command
|
||||||
|
.replace(/(--s3-access-key-id=)"[^"]*"/g, '$1"[REDACTED]"')
|
||||||
|
.replace(/(--s3-secret-access-key=)"[^"]*"/g, '$1"[REDACTED]"');
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { runMariadbBackup } from "./mariadb";
|
|||||||
import { runMongoBackup } from "./mongo";
|
import { runMongoBackup } from "./mongo";
|
||||||
import { runMySqlBackup } from "./mysql";
|
import { runMySqlBackup } from "./mysql";
|
||||||
import { runPostgresBackup } from "./postgres";
|
import { runPostgresBackup } from "./postgres";
|
||||||
|
import { redactRcloneCredentials } from "./redact";
|
||||||
import { runWebServerBackup } from "./web-server";
|
import { runWebServerBackup } from "./web-server";
|
||||||
|
|
||||||
export const scheduleBackup = (backup: BackupSchedule) => {
|
export const scheduleBackup = (backup: BackupSchedule) => {
|
||||||
@@ -262,7 +263,7 @@ export const getBackupCommand = (
|
|||||||
{
|
{
|
||||||
containerSearch,
|
containerSearch,
|
||||||
backupCommand,
|
backupCommand,
|
||||||
rcloneCommand,
|
rcloneCommand: redactRcloneCredentials(rcloneCommand),
|
||||||
logPath,
|
logPath,
|
||||||
},
|
},
|
||||||
`Executing backup command: ${backup.databaseType} ${backup.backupType}`,
|
`Executing backup command: ${backup.databaseType} ${backup.backupType}`,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||||
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
|
import { sendDokployBackupNotifications } from "../notifications/dokploy-backup";
|
||||||
import { execAsync } from "../process/execAsync";
|
import { execAsync } from "../process/execAsync";
|
||||||
|
import { redactRcloneCredentials } from "./redact";
|
||||||
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
|
import { getBackupTimestamp, getS3Credentials, normalizeS3Path } from "./utils";
|
||||||
|
|
||||||
function formatBytes(bytes?: number) {
|
function formatBytes(bytes?: number) {
|
||||||
@@ -113,20 +114,23 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
|||||||
try {
|
try {
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
} catch (cleanupError) {
|
} catch (cleanupError) {
|
||||||
console.error("Cleanup error:", cleanupError);
|
console.error(
|
||||||
|
"Cleanup error:",
|
||||||
|
redactRcloneCredentials(String(cleanupError)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Backup error:", error);
|
const safeErrorMessage = redactRcloneCredentials(
|
||||||
writeStream.write("Backup error❌\n");
|
error instanceof Error ? error.message : String(error),
|
||||||
writeStream.write(
|
|
||||||
error instanceof Error ? error.message : "Unknown error\n",
|
|
||||||
);
|
);
|
||||||
|
console.error("Backup error:", redactRcloneCredentials(String(error)));
|
||||||
|
writeStream.write("Backup error❌\n");
|
||||||
|
writeStream.write(`${safeErrorMessage}\n`);
|
||||||
writeStream.end();
|
writeStream.end();
|
||||||
await sendDokployBackupNotifications({
|
await sendDokployBackupNotifications({
|
||||||
type: "error",
|
type: "error",
|
||||||
// @ts-ignore
|
errorMessage: safeErrorMessage || "Error message not provided",
|
||||||
errorMessage: error?.message || "Error message not provided",
|
|
||||||
backupSize: formatBytes(computedBackupSize),
|
backupSize: formatBytes(computedBackupSize),
|
||||||
});
|
});
|
||||||
await updateDeploymentStatus(deployment.deploymentId, "error");
|
await updateDeploymentStatus(deployment.deploymentId, "error");
|
||||||
|
|||||||
Reference in New Issue
Block a user