Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay

This commit is contained in:
Mauricio Siu
2025-05-01 21:05:46 -06:00
102 changed files with 23839 additions and 984 deletions

View File

@@ -52,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch. Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.9.0 We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
```bash ```bash
git clone https://github.com/dokploy/dokploy.git git clone https://github.com/dokploy/dokploy.git
@@ -87,6 +87,8 @@ pnpm run dokploy:dev
Go to http://localhost:3000 to see the development server Go to http://localhost:3000 to see the development server
Note: this project uses Biome. If your editor is configured to use another formatter such as Prettier, it's recommended to either change it to use Biome or turn it off.
## Build ## Build
```bash ```bash
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
```bash ```bash
# Install Buildpacks # Install Buildpacks
curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.32.1/pack-v0.32.1-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack curl -sSL "https://github.com/buildpacks/pack/releases/download/v0.35.0/pack-v0.35.0-linux.tgz" | tar -C /usr/local/bin/ --no-same-owner -xzv pack
``` ```
## Pull Request ## Pull Request
- The `main` branch is the source of truth and should always reflect the latest stable release. - The `main` branch is the source of truth and should always reflect the latest stable release.
@@ -167,7 +167,6 @@ Thank you for your contribution!
To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file. To add a new template, go to `https://github.com/Dokploy/templates` repository and read the README.md file.
### Recommendations ### Recommendations
- Use the same name of the folder as the id of the template. - Use the same name of the folder as the id of the template.

View File

@@ -49,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx # Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash # | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.29.1 ARG NIXPACKS_VERSION=1.35.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \ RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \ && chmod +x install.sh \
&& ./install.sh \ && ./install.sh \

View File

@@ -34,7 +34,9 @@ const baseApp: ApplicationNested = {
giteaRepository: "", giteaRepository: "",
cleanCache: false, cleanCache: false,
watchPaths: [], watchPaths: [],
enableSubmodules: false,
applicationStatus: "done", applicationStatus: "done",
triggerType: "push",
appName: "", appName: "",
autoDeploy: true, autoDeploy: true,
serverId: "", serverId: "",

View File

@@ -51,6 +51,35 @@ describe("processTemplate", () => {
expect(result.domains).toHaveLength(0); expect(result.domains).toHaveLength(0);
expect(result.mounts).toHaveLength(0); expect(result.mounts).toHaveLength(0);
}); });
it("should allow creation of real jwt secret", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {
jwt_secret: "cQsdycq1hDLopQonF6jUTqgQc5WEZTwWLL02J6XJ",
anon_payload: JSON.stringify({
role: "tester",
iss: "dockploy",
iat: "${timestamps:2025-01-01T00:00:00Z}",
exp: "${timestamps:2030-01-01T00:00:00Z}",
}),
anon_key: "${jwt:jwt_secret:anon_payload}",
},
config: {
domains: [],
env: {
ANON_KEY: "${anon_key}",
},
},
};
const result = processTemplate(template, mockSchema);
expect(result.envs).toHaveLength(1);
expect(result.envs).toContain(
"ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOiIxNzM1Njg5NjAwIiwiZXhwIjoiMTg5MzQ1NjAwMCIsInJvbGUiOiJ0ZXN0ZXIiLCJpc3MiOiJkb2NrcGxveSJ9.BG5JoxL2_NaTFbPgyZdm3kRWenf_O3su_HIRKGCJ_kY",
);
expect(result.mounts).toHaveLength(0);
expect(result.domains).toHaveLength(0);
});
}); });
describe("domains processing", () => { describe("domains processing", () => {

View File

@@ -0,0 +1,232 @@
import type { Schema } from "@dokploy/server/templates";
import { processValue } from "@dokploy/server/templates/processors";
import { describe, expect, it } from "vitest";
describe("helpers functions", () => {
// Mock schema for testing
const mockSchema: Schema = {
projectName: "test",
serverIp: "127.0.0.1",
};
// some helpers to test jwt
type JWTParts = [string, string, string];
const jwtMatchExp = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$/;
const jwtBase64Decode = (str: string) => {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const decoded = Buffer.from(base64 + padding, "base64").toString("utf-8");
return JSON.parse(decoded);
};
const jwtCheckHeader = (jwtHeader: string) => {
const decodedHeader = jwtBase64Decode(jwtHeader);
expect(decodedHeader).toHaveProperty("alg");
expect(decodedHeader).toHaveProperty("typ");
expect(decodedHeader.alg).toEqual("HS256");
expect(decodedHeader.typ).toEqual("JWT");
};
describe("${domain}", () => {
it("should generate a random domain", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
).toBeTruthy();
});
});
describe("${base64}", () => {
it("should generate a base64 string", () => {
const base64 = processValue("${base64}", {}, mockSchema);
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
});
it.each([
[4, 8],
[8, 12],
[16, 24],
[32, 44],
[64, 88],
[128, 172],
])(
"should generate a base64 string from parameter %d bytes length",
(length, finalLength) => {
const base64 = processValue(`\${base64:${length}}`, {}, mockSchema);
expect(base64).toMatch(/^[A-Za-z0-9+=/]+={0,2}$/);
expect(base64.length).toBe(finalLength);
},
);
});
describe("${password}", () => {
it("should generate a password string", () => {
const password = processValue("${password}", {}, mockSchema);
expect(password).toMatch(/^[A-Za-z0-9]+$/);
});
it.each([6, 8, 12, 16, 32])(
"should generate a password string respecting parameter %d length",
(length) => {
const password = processValue(`\${password:${length}}`, {}, mockSchema);
expect(password).toMatch(/^[A-Za-z0-9]+$/);
expect(password.length).toBe(length);
},
);
});
describe("${hash}", () => {
it("should generate a hash string", () => {
const hash = processValue("${hash}", {}, mockSchema);
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
});
it.each([6, 8, 12, 16, 32])(
"should generate a hash string respecting parameter %d length",
(length) => {
const hash = processValue(`\${hash:${length}}`, {}, mockSchema);
expect(hash).toMatch(/^[A-Za-z0-9]+$/);
expect(hash.length).toBe(length);
},
);
});
describe("${uuid}", () => {
it("should generate a UUID string", () => {
const uuid = processValue("${uuid}", {}, mockSchema);
expect(uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});
});
describe("${timestamp}", () => {
it("should generate a timestamp string in milliseconds", () => {
const timestamp = processValue("${timestamp}", {}, mockSchema);
const nowLength = Math.floor(Date.now()).toString().length;
expect(timestamp).toMatch(/^\d+$/);
expect(timestamp.length).toBe(nowLength);
});
});
describe("${timestampms}", () => {
it("should generate a timestamp string in milliseconds", () => {
const timestamp = processValue("${timestampms}", {}, mockSchema);
const nowLength = Date.now().toString().length;
expect(timestamp).toMatch(/^\d+$/);
expect(timestamp.length).toBe(nowLength);
});
it("should generate a timestamp string in milliseconds from parameter", () => {
const timestamp = processValue(
"${timestampms:2025-01-01}",
{},
mockSchema,
);
expect(timestamp).toEqual("1735689600000");
});
});
describe("${timestamps}", () => {
it("should generate a timestamp string in seconds", () => {
const timestamps = processValue("${timestamps}", {}, mockSchema);
const nowLength = Math.floor(Date.now() / 1000).toString().length;
expect(timestamps).toMatch(/^\d+$/);
expect(timestamps.length).toBe(nowLength);
});
it("should generate a timestamp string in seconds from parameter", () => {
const timestamps = processValue(
"${timestamps:2025-01-01}",
{},
mockSchema,
);
expect(timestamps).toEqual("1735689600");
});
});
describe("${randomPort}", () => {
it("should generate a random port string", () => {
const randomPort = processValue("${randomPort}", {}, mockSchema);
expect(randomPort).toMatch(/^\d+$/);
expect(Number(randomPort)).toBeLessThan(65536);
});
});
describe("${username}", () => {
it("should generate a username string", () => {
const username = processValue("${username}", {}, mockSchema);
expect(username).toMatch(/^[a-zA-Z0-9._-]{3,}$/);
});
});
describe("${email}", () => {
it("should generate an email string", () => {
const email = processValue("${email}", {}, mockSchema);
expect(email).toMatch(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/);
});
});
describe("${jwt}", () => {
it("should generate a JWT string", () => {
const jwt = processValue("${jwt}", {}, mockSchema);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
const decodedPayload = jwtBase64Decode(parts[1]);
jwtCheckHeader(parts[0]);
expect(decodedPayload).toHaveProperty("iat");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.iss).toEqual("dokploy");
});
it.each([6, 8, 12, 16, 32])(
"should generate a random hex string from parameter %d byte length",
(length) => {
const jwt = processValue(`\${jwt:${length}}`, {}, mockSchema);
expect(jwt).toMatch(/^[A-Za-z0-9-_.]+$/);
expect(jwt.length).toBeGreaterThanOrEqual(length); // bytes translated to hex can take up to 2x the length
expect(jwt.length).toBeLessThanOrEqual(length * 2);
},
);
});
describe("${jwt:secret}", () => {
it("should generate a JWT string respecting parameter secret from variable", () => {
const jwt = processValue(
"${jwt:secret}",
{ secret: "mysecret" },
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
const decodedPayload = jwtBase64Decode(parts[1]);
jwtCheckHeader(parts[0]);
expect(decodedPayload).toHaveProperty("iat");
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.iss).toEqual("dokploy");
});
});
describe("${jwt:secret:payload}", () => {
it("should generate a JWT string respecting parameters secret and payload from variables", () => {
const iat = Math.floor(new Date("2025-01-01T00:00:00Z").getTime() / 1000);
const expiry = iat + 3600;
const jwt = processValue(
"${jwt:secret:payload}",
{
secret: "mysecret",
payload: `{"iss": "test-issuer", "iat": ${iat}, "exp": ${expiry}, "customprop": "customvalue"}`,
},
mockSchema,
);
expect(jwt).toMatch(jwtMatchExp);
const parts = jwt.split(".") as JWTParts;
jwtCheckHeader(parts[0]);
const decodedPayload = jwtBase64Decode(parts[1]);
expect(decodedPayload).toHaveProperty("iat");
expect(decodedPayload.iat).toEqual(iat);
expect(decodedPayload).toHaveProperty("iss");
expect(decodedPayload.iss).toEqual("test-issuer");
expect(decodedPayload).toHaveProperty("exp");
expect(decodedPayload.exp).toEqual(expiry);
expect(decodedPayload).toHaveProperty("customprop");
expect(decodedPayload.customprop).toEqual("customvalue");
expect(jwt).toEqual(
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MzU2ODk2MDAsImV4cCI6MTczNTY5MzIwMCwiaXNzIjoidGVzdC1pc3N1ZXIiLCJjdXN0b21wcm9wIjoiY3VzdG9tdmFsdWUifQ.m42U7PZSUSCf7gBOJrxJir0rQmyPq4rA59Dydr_QahI",
);
});
});
});

View File

@@ -14,6 +14,7 @@ import {
import { beforeEach, expect, test, vi } from "vitest"; import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = { const baseAdmin: User = {
https: false,
enablePaidFeatures: false, enablePaidFeatures: false,
metricsConfig: { metricsConfig: {
containers: { containers: {
@@ -73,7 +74,6 @@ beforeEach(() => {
test("Should read the configuration file", () => { test("Should read the configuration file", () => {
const config: FileConfig = loadOrCreateConfig("dokploy"); const config: FileConfig = loadOrCreateConfig("dokploy");
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe( expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
"dokploy-service-app", "dokploy-service-app",
); );
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
updateServerTraefik( updateServerTraefik(
{ {
...baseAdmin, ...baseAdmin,
https: true,
certificateType: "letsencrypt", certificateType: "letsencrypt",
}, },
"example.com", "example.com",

View File

@@ -16,6 +16,7 @@ const baseApp: ApplicationNested = {
applicationStatus: "done", applicationStatus: "done",
appName: "", appName: "",
autoDeploy: true, autoDeploy: true,
enableSubmodules: false,
serverId: "", serverId: "",
branch: null, branch: null,
dockerBuildStage: "", dockerBuildStage: "",
@@ -24,6 +25,7 @@ const baseApp: ApplicationNested = {
buildArgs: null, buildArgs: null,
isPreviewDeploymentsActive: false, isPreviewDeploymentsActive: false,
previewBuildArgs: null, previewBuildArgs: null,
triggerType: "push",
previewCertificateType: "none", previewCertificateType: "none",
previewEnv: null, previewEnv: null,
previewHttps: false, previewHttps: false,

View File

@@ -0,0 +1,61 @@
import { describe, expect, test } from "vitest";
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
describe("normalizeS3Path", () => {
test("should handle empty and whitespace-only prefix", () => {
expect(normalizeS3Path("")).toBe("");
expect(normalizeS3Path("/")).toBe("");
expect(normalizeS3Path(" ")).toBe("");
expect(normalizeS3Path("\t")).toBe("");
expect(normalizeS3Path("\n")).toBe("");
expect(normalizeS3Path(" \n \t ")).toBe("");
});
test("should trim whitespace from prefix", () => {
expect(normalizeS3Path(" prefix")).toBe("prefix/");
expect(normalizeS3Path("prefix ")).toBe("prefix/");
expect(normalizeS3Path(" prefix ")).toBe("prefix/");
expect(normalizeS3Path("\tprefix\t")).toBe("prefix/");
expect(normalizeS3Path(" prefix/nested ")).toBe("prefix/nested/");
});
test("should remove leading slashes", () => {
expect(normalizeS3Path("/prefix")).toBe("prefix/");
expect(normalizeS3Path("///prefix")).toBe("prefix/");
});
test("should remove trailing slashes", () => {
expect(normalizeS3Path("prefix/")).toBe("prefix/");
expect(normalizeS3Path("prefix///")).toBe("prefix/");
});
test("should remove both leading and trailing slashes", () => {
expect(normalizeS3Path("/prefix/")).toBe("prefix/");
expect(normalizeS3Path("///prefix///")).toBe("prefix/");
});
test("should handle nested paths", () => {
expect(normalizeS3Path("prefix/nested")).toBe("prefix/nested/");
expect(normalizeS3Path("/prefix/nested/")).toBe("prefix/nested/");
expect(normalizeS3Path("///prefix/nested///")).toBe("prefix/nested/");
});
test("should preserve middle slashes", () => {
expect(normalizeS3Path("prefix/nested/deep")).toBe("prefix/nested/deep/");
expect(normalizeS3Path("/prefix/nested/deep/")).toBe("prefix/nested/deep/");
});
test("should handle special characters", () => {
expect(normalizeS3Path("prefix-with-dashes")).toBe("prefix-with-dashes/");
expect(normalizeS3Path("prefix_with_underscores")).toBe(
"prefix_with_underscores/",
);
expect(normalizeS3Path("prefix.with.dots")).toBe("prefix.with.dots/");
});
test("should handle the cases from the bug report", () => {
expect(normalizeS3Path("instance-backups/")).toBe("instance-backups/");
expect(normalizeS3Path("/instance-backups/")).toBe("instance-backups/");
expect(normalizeS3Path("instance-backups")).toBe("instance-backups/");
});
});

View File

@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),
}); });
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>; type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -84,6 +86,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketId: "", bitbucketId: "",
branch: "", branch: "",
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(BitbucketProviderSchema), resolver: zodResolver(BitbucketProviderSchema),
}); });
@@ -130,6 +133,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
buildPath: data.bitbucketBuildPath || "/", buildPath: data.bitbucketBuildPath || "/",
bitbucketId: data.bitbucketId || "", bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -143,6 +147,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
bitbucketId: data.bitbucketId, bitbucketId: data.bitbucketId,
applicationId, applicationId,
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -467,6 +472,21 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -23,6 +23,7 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react"; import { KeyRoundIcon, LockIcon, X } from "lucide-react";
@@ -44,6 +45,7 @@ const GitProviderSchema = z.object({
branch: z.string().min(1, "Branch required"), branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(), sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
}); });
type GitProvider = z.infer<typeof GitProviderSchema>; type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -67,6 +69,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
repositoryURL: "", repositoryURL: "",
sshKey: undefined, sshKey: undefined,
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(GitProviderSchema), resolver: zodResolver(GitProviderSchema),
}); });
@@ -79,6 +82,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
buildPath: data.customGitBuildPath || "/", buildPath: data.customGitBuildPath || "/",
repositoryURL: data.customGitUrl || "", repositoryURL: data.customGitUrl || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -91,6 +95,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey, customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
applicationId, applicationId,
watchPaths: values.watchPaths || [], watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
}) })
.then(async () => { .then(async () => {
toast.success("Git Provider Saved"); toast.success("Git Provider Saved");
@@ -294,6 +299,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">

View File

@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -74,6 +75,7 @@ const GiteaProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"), giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]), watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(),
}); });
type GiteaProvider = z.infer<typeof GiteaProviderSchema>; type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
@@ -99,6 +101,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
giteaId: "", giteaId: "",
branch: "", branch: "",
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(GiteaProviderSchema), resolver: zodResolver(GiteaProviderSchema),
}); });
@@ -152,6 +155,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
buildPath: data.giteaBuildPath || "/", buildPath: data.giteaBuildPath || "/",
giteaId: data.giteaId || "", giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules || false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -165,6 +169,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
giteaId: data.giteaId, giteaId: data.giteaId,
applicationId, applicationId,
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules || false,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provider Saved"); toast.success("Service Provider Saved");
@@ -498,6 +503,21 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -30,6 +30,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"), githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
enableSubmodules: z.boolean().default(false),
}); });
type GithubProvider = z.infer<typeof GithubProviderSchema>; type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -81,12 +84,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
}, },
githubId: "", githubId: "",
branch: "", branch: "",
triggerType: "push",
enableSubmodules: false,
}, },
resolver: zodResolver(GithubProviderSchema), resolver: zodResolver(GithubProviderSchema),
}); });
const repository = form.watch("repository"); const repository = form.watch("repository");
const githubId = form.watch("githubId"); const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } = const { data: repositories, isLoading: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery( api.github.getGithubRepositories.useQuery(
@@ -124,6 +130,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
buildPath: data.buildPath || "/", buildPath: data.buildPath || "/",
githubId: data.githubId || "", githubId: data.githubId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
triggerType: data.triggerType || "push",
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -137,6 +145,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
buildPath: data.buildPath, buildPath: data.buildPath,
githubId: data.githubId, githubId: data.githubId,
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
triggerType: data.triggerType,
enableSubmodules: data.enableSubmodules,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -381,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="watchPaths" name="triggerType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2"> <FormItem className="md:col-span-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 ">
<FormLabel>Watch Paths</FormLabel> <FormLabel>Trigger Type</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -393,68 +403,127 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>
Add paths to watch for changes. When files in these Choose when to trigger deployments: on push to the
paths change, a new deployment will be triggered. selected branch or when a new tag is created.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className="flex flex-wrap gap-2 mb-2"> <Select
{field.value?.map((path, index) => ( onValueChange={field.onChange}
<Badge defaultValue={field.value}
key={index} value={field.value}
variant="secondary" >
className="flex items-center gap-1" <FormControl>
> <SelectTrigger>
{path} <SelectValue placeholder="Select a trigger type" />
<X </SelectTrigger>
className="size-3 cursor-pointer hover:text-destructive" </FormControl>
onClick={() => { <SelectContent>
const newPaths = [...(field.value || [])]; <SelectItem value="push">On Push</SelectItem>
newPaths.splice(index, 1); <SelectItem value="tag">On Tag</SelectItem>
field.onChange(newPaths); </SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{triggerType === "push" && (
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in
these paths change, a new deployment will be
triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}} }}
/> />
</Badge> </FormControl>
))} <Button
</div> type="button"
<div className="flex gap-2"> variant="outline"
<FormControl> size="icon"
<Input onClick={() => {
placeholder="Enter a path to watch (e.g., src/*, dist/*)" const input = document.querySelector(
onKeyDown={(e) => { 'input[placeholder*="Enter a path"]',
if (e.key === "Enter") { ) as HTMLInputElement;
e.preventDefault(); const path = input.value.trim();
const input = e.currentTarget; if (path) {
const path = input.value.trim(); field.onChange([...(field.value || []), path]);
if (path) { input.value = "";
field.onChange([...(field.value || []), path]);
input.value = "";
}
} }
}} }}
/> >
</FormControl> <Plus className="size-4" />
<Button </Button>
type="button" </div>
variant="outline" <FormMessage />
size="icon" </FormItem>
onClick={() => { )}
const input = document.querySelector( />
'input[placeholder*="Enter a path"]', )}
) as HTMLInputElement;
const path = input.value.trim(); <FormField
if (path) { control={form.control}
field.onChange([...(field.value || []), path]); name="enableSubmodules"
input.value = ""; render={({ field }) => (
} <FormItem className="flex items-center space-x-2">
}} <FormControl>
> <Switch
<Plus className="size-4" /> checked={field.value}
</Button> onCheckedChange={field.onChange}
</div> />
<FormMessage /> </FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
}); });
type GitlabProvider = z.infer<typeof GitlabProviderSchema>; type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -86,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
}, },
gitlabId: "", gitlabId: "",
branch: "", branch: "",
enableSubmodules: false,
}, },
resolver: zodResolver(GitlabProviderSchema), resolver: zodResolver(GitlabProviderSchema),
}); });
@@ -135,6 +138,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
buildPath: data.gitlabBuildPath || "/", buildPath: data.gitlabBuildPath || "/",
gitlabId: data.gitlabId || "", gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -150,6 +154,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
gitlabProjectId: data.repository.id, gitlabProjectId: data.repository.id,
gitlabPathNamespace: data.repository.gitlabPathNamespace, gitlabPathNamespace: data.repository.gitlabPathNamespace,
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -483,6 +488,21 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -65,7 +65,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
setSab(e as TabState); setSab(e as TabState);
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4">
<TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden"> <TabsList className="md:grid md:w-fit md:grid-cols-7 max-md:overflow-x-scroll justify-start bg-transparent overflow-y-hidden">
<TabsTrigger <TabsTrigger
value="github" value="github"

View File

@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
}) })
.then(() => { .then(() => {
refetch(); refetch();
toast.success("Preview deployments enabled"); toast.success(
checked
? "Preview deployments enabled"
: "Preview deployments disabled",
);
}) })
.catch((error) => { .catch((error) => {
toast.error(error.message); toast.error(error.message);

View File

@@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
toast.error("Error updating the Compose config"); toast.error("Error updating the Compose config");
}); });
}; };
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isLoading) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [form, onSubmit, isLoading]);
return ( return (
<> <>
<div className="w-full flex flex-col gap-4 "> <div className="w-full flex flex-col gap-4 ">

View File

@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"), bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
}); });
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>; type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
@@ -84,6 +86,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
bitbucketId: "", bitbucketId: "",
branch: "", branch: "",
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(BitbucketProviderSchema), resolver: zodResolver(BitbucketProviderSchema),
}); });
@@ -130,6 +133,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath, composePath: data.composePath,
bitbucketId: data.bitbucketId || "", bitbucketId: data.bitbucketId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -145,6 +149,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
sourceType: "bitbucket", sourceType: "bitbucket",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -469,6 +474,21 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -19,6 +19,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -43,6 +44,7 @@ const GitProviderSchema = z.object({
branch: z.string().min(1, "Branch required"), branch: z.string().min(1, "Branch required"),
sshKey: z.string().optional(), sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
}); });
type GitProvider = z.infer<typeof GitProviderSchema>; type GitProvider = z.infer<typeof GitProviderSchema>;
@@ -65,6 +67,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composePath: "./docker-compose.yml", composePath: "./docker-compose.yml",
sshKey: undefined, sshKey: undefined,
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(GitProviderSchema), resolver: zodResolver(GitProviderSchema),
}); });
@@ -77,6 +80,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
repositoryURL: data.customGitUrl || "", repositoryURL: data.customGitUrl || "",
composePath: data.composePath, composePath: data.composePath,
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -91,6 +95,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
composePath: values.composePath, composePath: values.composePath,
composeStatus: "idle", composeStatus: "idle",
watchPaths: values.watchPaths || [], watchPaths: values.watchPaths || [],
enableSubmodules: values.enableSubmodules,
}) })
.then(async () => { .then(async () => {
toast.success("Git Provider Saved"); toast.success("Git Provider Saved");
@@ -295,6 +300,21 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">

View File

@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -59,6 +60,7 @@ const GiteaProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
giteaId: z.string().min(1, "Gitea Provider is required"), giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
}); });
type GiteaProvider = z.infer<typeof GiteaProviderSchema>; type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
@@ -83,6 +85,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
giteaId: "", giteaId: "",
branch: "", branch: "",
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(GiteaProviderSchema), resolver: zodResolver(GiteaProviderSchema),
}); });
@@ -136,6 +139,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath || "./docker-compose.yml", composePath: data.composePath || "./docker-compose.yml",
giteaId: data.giteaId || "", giteaId: data.giteaId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -151,6 +155,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
sourceType: "gitea", sourceType: "gitea",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
} as any) } as any)
.then(async () => { .then(async () => {
toast.success("Service Provider Saved"); toast.success("Service Provider Saved");
@@ -469,6 +474,21 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">

View File

@@ -30,6 +30,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -39,7 +40,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react"; import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useEffect } from "react"; import { useEffect } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"), githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
enableSubmodules: z.boolean().default(false),
}); });
type GithubProvider = z.infer<typeof GithubProviderSchema>; type GithubProvider = z.infer<typeof GithubProviderSchema>;
@@ -82,13 +85,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
githubId: "", githubId: "",
branch: "", branch: "",
watchPaths: [], watchPaths: [],
triggerType: "push",
enableSubmodules: false,
}, },
resolver: zodResolver(GithubProviderSchema), resolver: zodResolver(GithubProviderSchema),
}); });
const repository = form.watch("repository"); const repository = form.watch("repository");
const githubId = form.watch("githubId"); const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } = const { data: repositories, isLoading: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery( api.github.getGithubRepositories.useQuery(
{ {
@@ -125,6 +130,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath, composePath: data.composePath,
githubId: data.githubId || "", githubId: data.githubId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
triggerType: data.triggerType || "push",
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -140,6 +147,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
sourceType: "github", sourceType: "github",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -384,79 +393,140 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="watchPaths" name="triggerType"
render={({ field }) => ( render={({ field }) => (
<FormItem className="md:col-span-2"> <FormItem className="md:col-span-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel> <FormLabel>Trigger Type</FormLabel>
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger asChild>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold"> <HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
?
</div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p> <p>
Add paths to watch for changes. When files in these Choose when to trigger deployments: on push to the
paths change, a new deployment will be triggered. selected branch or when a new tag is created.
</p> </p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
<div className="flex flex-wrap gap-2 mb-2"> <Select
{field.value?.map((path, index) => ( onValueChange={field.onChange}
<Badge key={index} variant="secondary"> defaultValue={field.value}
{path} value={field.value}
<X >
className="ml-1 size-3 cursor-pointer" <FormControl>
onClick={() => { <SelectTrigger>
const newPaths = [...(field.value || [])]; <SelectValue placeholder="Select a trigger type" />
newPaths.splice(index, 1); </SelectTrigger>
form.setValue("watchPaths", newPaths); </FormControl>
<SelectContent>
<SelectItem value="push">On Push</SelectItem>
<SelectItem value="tag">On Tag</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{triggerType === "push" && (
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in
these paths change, a new deployment will be
triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [
...(field.value || []),
value,
];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}} }}
/> />
</Badge> <Button
))} type="button"
</div> variant="secondary"
<FormControl> onClick={() => {
<div className="flex gap-2"> const input = document.querySelector(
<Input 'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
placeholder="Enter a path to watch (e.g., src/*, dist/*)" ) as HTMLInputElement;
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim(); const value = input.value.trim();
if (value) { if (value) {
const newPaths = [...(field.value || []), value]; const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths); form.setValue("watchPaths", newPaths);
input.value = ""; input.value = "";
} }
} }}
}} >
/> Add
<Button </Button>
type="button" </div>
variant="secondary" </FormControl>
onClick={() => { <FormMessage />
const input = document.querySelector( </FormItem>
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]', )}
) as HTMLInputElement; />
const value = input.value.trim(); )}
if (value) { <FormField
const newPaths = [...(field.value || []), value]; control={form.control}
form.setValue("watchPaths", newPaths); name="enableSubmodules"
input.value = ""; render={({ field }) => (
} <FormItem className="flex items-center space-x-2">
}} <FormControl>
> <Switch
Add checked={field.value}
</Button> onCheckedChange={field.onChange}
</div> />
</FormControl> </FormControl>
<FormMessage /> <FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem> </FormItem>
)} )}
/> />

View File

@@ -31,6 +31,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"), branch: z.string().min(1, "Branch is required"),
gitlabId: z.string().min(1, "Gitlab Provider is required"), gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(), watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
}); });
type GitlabProvider = z.infer<typeof GitlabProviderSchema>; type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
@@ -87,6 +89,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
gitlabId: "", gitlabId: "",
branch: "", branch: "",
watchPaths: [], watchPaths: [],
enableSubmodules: false,
}, },
resolver: zodResolver(GitlabProviderSchema), resolver: zodResolver(GitlabProviderSchema),
}); });
@@ -136,6 +139,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath, composePath: data.composePath,
gitlabId: data.gitlabId || "", gitlabId: data.gitlabId || "",
watchPaths: data.watchPaths || [], watchPaths: data.watchPaths || [],
enableSubmodules: data.enableSubmodules ?? false,
}); });
} }
}, [form.reset, data, form]); }, [form.reset, data, form]);
@@ -153,6 +157,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
sourceType: "gitlab", sourceType: "gitlab",
composeStatus: "idle", composeStatus: "idle",
watchPaths: data.watchPaths, watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
}) })
.then(async () => { .then(async () => {
toast.success("Service Provided Saved"); toast.success("Service Provided Saved");
@@ -485,6 +490,21 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="enableSubmodules"
render={({ field }) => (
<FormItem className="flex items-center space-x-2">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
</FormItem>
)}
/>
</div> </div>
<div className="flex w-full justify-end"> <div className="flex w-full justify-end">
<Button <Button

View File

@@ -77,6 +77,14 @@ const RestoreBackupSchema = z.object({
type RestoreBackup = z.infer<typeof RestoreBackupSchema>; type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
};
export const RestoreBackup = ({ export const RestoreBackup = ({
databaseId, databaseId,
databaseType, databaseType,
@@ -84,6 +92,7 @@ export const RestoreBackup = ({
}: Props) => { }: Props) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const { data: destinations = [] } = api.destination.all.useQuery(); const { data: destinations = [] } = api.destination.all.useQuery();
@@ -99,13 +108,18 @@ export const RestoreBackup = ({
const destionationId = form.watch("destinationId"); const destionationId = form.watch("destinationId");
const debouncedSetSearch = debounce((value: string) => { const debouncedSetSearch = debounce((value: string) => {
setDebouncedSearchTerm(value);
}, 350);
const handleSearchChange = (value: string) => {
setSearch(value); setSearch(value);
}, 300); debouncedSetSearch(value);
};
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery( const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
{ {
destinationId: destionationId, destinationId: destionationId,
search, search: debouncedSearchTerm,
serverId: serverId ?? "", serverId: serverId ?? "",
}, },
{ {
@@ -265,7 +279,7 @@ export const RestoreBackup = ({
</Badge> </Badge>
)} )}
</FormLabel> </FormLabel>
<Popover> <Popover modal>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
<Button <Button
@@ -284,7 +298,8 @@ export const RestoreBackup = ({
<Command> <Command>
<CommandInput <CommandInput
placeholder="Search backup files..." placeholder="Search backup files..."
onValueChange={debouncedSetSearch} value={search}
onValueChange={handleSearchChange}
className="h-9" className="h-9"
/> />
{isLoading ? ( {isLoading ? (
@@ -301,26 +316,51 @@ export const RestoreBackup = ({
</div> </div>
) : ( ) : (
<ScrollArea className="h-64"> <ScrollArea className="h-64">
<CommandGroup> <CommandGroup className="w-96">
{files.map((file) => ( {files?.map((file) => (
<CommandItem <CommandItem
value={file} value={file.Path}
key={file} key={file.Path}
onSelect={() => { onSelect={() => {
form.setValue("backupFile", file); form.setValue("backupFile", file.Path);
if (file.IsDir) {
setSearch(`${file.Path}/`);
setDebouncedSearchTerm(`${file.Path}/`);
} else {
setSearch(file.Path);
setDebouncedSearchTerm(file.Path);
}
}} }}
> >
<div className="flex w-full justify-between"> <div className="flex w-full flex-col gap-1">
<span>{file}</span> <div className="flex w-full justify-between">
<span className="font-medium">
{file.Path}
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
file.Path === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span>
Size: {formatBytes(file.Size)}
</span>
{file.IsDir && (
<span className="text-blue-500">
Directory
</span>
)}
{file.Hashes?.MD5 && (
<span>MD5: {file.Hashes.MD5}</span>
)}
</div>
</div> </div>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
file === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>

View File

@@ -31,9 +31,14 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const AddProjectSchema = z.object({ const AddProjectSchema = z.object({
name: z.string().min(1, { name: z
message: "Name is required", .string()
}), .min(1, {
message: "Name is required",
})
.regex(/^[a-zA-Z]/, {
message: "Project name cannot start with a number",
}),
description: z.string().optional(), description: z.string().optional(),
}); });
@@ -97,18 +102,6 @@ export const HandleProject = ({ projectId }: Props) => {
); );
}); });
}; };
// useEffect(() => {
// const getUsers = async () => {
// const users = await authClient.admin.listUsers({
// query: {
// limit: 100,
// },
// });
// console.log(users);
// };
// getUsers();
// });
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>

View File

@@ -115,7 +115,7 @@ export const ShowProjects = () => {
</span> </span>
</div> </div>
)} )}
<div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5"> <div className="w-full grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 flex-wrap gap-5">
{filteredProjects?.map((project) => { {filteredProjects?.map((project) => {
const emptyServices = const emptyServices =
project?.mariadb.length === 0 && project?.mariadb.length === 0 &&

View File

@@ -55,7 +55,7 @@ export const AiForm = () => {
key={config.aiId} key={config.aiId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div> <div>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{config.name} {config.name}

View File

@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
key={certificate.certificateId} key={certificate.certificateId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 flex-col"> <div className="flex gap-2 flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

View File

@@ -13,53 +13,65 @@ export const extractExpirationDate = (certData: string): Date | null => {
bytes[i] = binaryStr.charCodeAt(i); bytes[i] = binaryStr.charCodeAt(i);
} }
let dateFound = 0; // ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
let dateFound = false;
for (let i = 0; i < bytes.length - 2; i++) { for (let i = 0; i < bytes.length - 2; i++) {
if (bytes[i] === 0x17 || bytes[i] === 0x18) { // Look for sequence containing validity period (0x30)
const dateType = bytes[i]; if (bytes[i] === 0x30) {
const dateLength = bytes[i + 1]; // Check next bytes for UTCTime or GeneralizedTime
if (typeof dateLength === "undefined") continue; let j = i + 1;
while (j < bytes.length - 2) {
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
const dateType = bytes[j];
const dateLength = bytes[j + 1];
if (typeof dateLength === "undefined") break;
if (dateFound === 0) { if (!dateFound) {
dateFound++; // Skip "not before" date
i += dateLength + 1; dateFound = true;
continue; j += dateLength + 2;
continue;
}
// Found "not after" date
let dateStr = "";
for (let k = 0; k < dateLength; k++) {
const charCode = bytes[j + 2 + k];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
j++;
} }
let dateStr = "";
for (let j = 0; j < dateLength; j++) {
const charCode = bytes[i + 2 + j];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
} }
} }
return null; return null;

View File

@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
key={registry.registryId} key={registry.registryId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex gap-2 flex-col"> <div className="flex gap-2 flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

View File

@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
key={destination.destinationId} key={destination.destinationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm"> <span className="text-sm">
{index + 1}. {destination.name} {index + 1}. {destination.name}

View File

@@ -248,7 +248,9 @@ export const AddGitlabProvider = () => {
name="groupName" name="groupName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Group Name (Optional)</FormLabel> <FormLabel>
Group Name (Optional, Comma-Separated List)
</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="For organization/group access use the slugish name of the group eg: my-org" placeholder="For organization/group access use the slugish name of the group eg: my-org"

View File

@@ -156,7 +156,9 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
name="groupName" name="groupName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Group Name (Optional)</FormLabel> <FormLabel>
Group Name (Optional, Comma-Separated List)
</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="For organization/group access use the slugish name of the group eg: my-org" placeholder="For organization/group access use the slugish name of the group eg: my-org"

View File

@@ -61,7 +61,7 @@ export const ShowNotifications = () => {
key={notification.notificationId} key={notification.notificationId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<span className="text-sm flex flex-row items-center gap-4"> <span className="text-sm flex flex-row items-center gap-4">
{notification.notificationType === "slack" && ( {notification.notificationType === "slack" && (
<div className="flex items-center justify-center rounded-lg"> <div className="flex items-center justify-center rounded-lg">

View File

@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
password: z.string().min(8, { password: z.string().min(8, {
message: "Password is required", message: "Password is required",
}), }),
issuer: z.string().optional(),
}); });
const PinSchema = z.object({ const PinSchema = z.object({
@@ -60,12 +61,86 @@ export const Enable2FA = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [step, setStep] = useState<"password" | "verify">("password"); const [step, setStep] = useState<"password" | "verify">("password");
const [isPasswordLoading, setIsPasswordLoading] = useState(false); const [isPasswordLoading, setIsPasswordLoading] = useState(false);
const [otpValue, setOtpValue] = useState("");
const handleVerifySubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const result = await authClient.twoFactor.verifyTotp({
code: otpValue,
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
toast.error("Invalid verification code");
return;
}
throw result.error;
}
if (!result.data) {
throw new Error("No response received from server");
}
toast.success("2FA configured successfully");
utils.user.get.invalidate();
setIsDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
const errorMessage =
error.message === "Failed to fetch"
? "Connection error. Please check your internet connection."
: error.message;
toast.error(errorMessage);
} else {
toast.error("Error verifying 2FA code", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
}
};
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const pinForm = useForm<PinForm>({
resolver: zodResolver(PinSchema),
defaultValues: {
pin: "",
},
});
useEffect(() => {
if (!isDialogOpen) {
setStep("password");
setData(null);
setBackupCodes([]);
setOtpValue("");
passwordForm.reset({
password: "",
issuer: "",
});
}
}, [isDialogOpen, passwordForm]);
useEffect(() => {
if (step === "verify") {
setOtpValue("");
}
}, [step]);
const handlePasswordSubmit = async (formData: PasswordForm) => { const handlePasswordSubmit = async (formData: PasswordForm) => {
setIsPasswordLoading(true); setIsPasswordLoading(true);
try { try {
const { data: enableData, error } = await authClient.twoFactor.enable({ const { data: enableData, error } = await authClient.twoFactor.enable({
password: formData.password, password: formData.password,
issuer: formData.issuer,
}); });
if (!enableData) { if (!enableData) {
@@ -103,75 +178,6 @@ export const Enable2FA = () => {
} }
}; };
const handleVerifySubmit = async (formData: PinForm) => {
try {
const result = await authClient.twoFactor.verifyTotp({
code: formData.pin,
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
pinForm.setError("pin", {
message: "Invalid code. Please try again.",
});
toast.error("Invalid verification code");
return;
}
throw result.error;
}
if (!result.data) {
throw new Error("No response received from server");
}
toast.success("2FA configured successfully");
utils.user.get.invalidate();
setIsDialogOpen(false);
} catch (error) {
if (error instanceof Error) {
const errorMessage =
error.message === "Failed to fetch"
? "Connection error. Please check your internet connection."
: error.message;
pinForm.setError("pin", {
message: errorMessage,
});
toast.error(errorMessage);
} else {
pinForm.setError("pin", {
message: "Error verifying code",
});
toast.error("Error verifying 2FA code");
}
}
};
const passwordForm = useForm<PasswordForm>({
resolver: zodResolver(PasswordSchema),
defaultValues: {
password: "",
},
});
const pinForm = useForm<PinForm>({
resolver: zodResolver(PinSchema),
defaultValues: {
pin: "",
},
});
useEffect(() => {
if (!isDialogOpen) {
setStep("password");
setData(null);
setBackupCodes([]);
passwordForm.reset();
pinForm.reset();
}
}, [isDialogOpen, passwordForm, pinForm]);
return ( return (
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -217,6 +223,27 @@ export const Enable2FA = () => {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={passwordForm.control}
name="issuer"
render={({ field }) => (
<FormItem>
<FormLabel>Issuer</FormLabel>
<FormControl>
<Input
type="text"
placeholder="Enter your issuer"
{...field}
/>
</FormControl>
<FormDescription>
Use a custom issuer to identify the service you're
authenticating with.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
@@ -228,11 +255,7 @@ export const Enable2FA = () => {
</Form> </Form>
) : ( ) : (
<Form {...pinForm}> <Form {...pinForm}>
<form <form onSubmit={handleVerifySubmit} className="space-y-6">
id="pin-form"
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
className="space-y-6"
>
<div className="flex flex-col gap-6 justify-center items-center"> <div className="flex flex-col gap-6 justify-center items-center">
{data?.qrCodeUrl ? ( {data?.qrCodeUrl ? (
<> <>
@@ -284,36 +307,33 @@ export const Enable2FA = () => {
)} )}
</div> </div>
<FormField <div className="flex flex-col justify-center items-center">
control={pinForm.control} <FormLabel>Verification Code</FormLabel>
name="pin" <InputOTP
render={({ field }) => ( maxLength={6}
<FormItem className="flex flex-col justify-center items-center"> value={otpValue}
<FormLabel>Verification Code</FormLabel> onChange={setOtpValue}
<FormControl> autoComplete="off"
<InputOTP maxLength={6} {...field}> >
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />
<InputOTPSlot index={1} /> <InputOTPSlot index={1} />
<InputOTPSlot index={2} /> <InputOTPSlot index={2} />
<InputOTPSlot index={3} /> <InputOTPSlot index={3} />
<InputOTPSlot index={4} /> <InputOTPSlot index={4} />
<InputOTPSlot index={5} /> <InputOTPSlot index={5} />
</InputOTPGroup> </InputOTPGroup>
</InputOTP> </InputOTP>
</FormControl> <FormDescription>
<FormDescription> Enter the 6-digit code from your authenticator app
Enter the 6-digit code from your authenticator app </FormDescription>
</FormDescription> </div>
<FormMessage />
</FormItem>
)}
/>
<Button <Button
type="submit" type="submit"
className="w-full" className="w-full"
isLoading={isPasswordLoading} isLoading={isPasswordLoading}
disabled={otpValue.length !== 6}
> >
Enable 2FA Enable 2FA
</Button> </Button>

View File

@@ -56,6 +56,7 @@ const randomImages = [
export const ProfileForm = () => { export const ProfileForm = () => {
const _utils = api.useUtils(); const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery(); const { data, refetch, isLoading } = api.user.get.useQuery();
const { const {
mutateAsync, mutateAsync,
isLoading: isUpdating, isLoading: isUpdating,
@@ -84,12 +85,17 @@ export const ProfileForm = () => {
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset(
email: data?.user?.email || "", {
password: "", email: data?.user?.email || "",
image: data?.user?.image || "", password: form.getValues("password") || "",
currentPassword: "", image: data?.user?.image || "",
}); currentPassword: form.getValues("currentPassword") || "",
},
{
keepValues: true,
},
);
if (data.user.email) { if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => { generateSHA256Hash(data.user.email).then((hash) => {
@@ -97,8 +103,7 @@ export const ProfileForm = () => {
}); });
} }
} }
form.reset(); }, [form, data]);
}, [form, form.reset, data]);
const onSubmit = async (values: Profile) => { const onSubmit = async (values: Profile) => {
await mutateAsync({ await mutateAsync({
@@ -110,7 +115,12 @@ export const ProfileForm = () => {
.then(async () => { .then(async () => {
await refetch(); await refetch();
toast.success("Profile Updated"); toast.success("Profile Updated");
form.reset(); form.reset({
email: values.email,
password: "",
image: values.image,
currentPassword: "",
});
}) })
.catch(() => { .catch(() => {
toast.error("Error updating the profile"); toast.error("Error updating the profile");

View File

@@ -22,6 +22,9 @@ export const ShowDokployActions = () => {
const { mutateAsync: reloadServer, isLoading } = const { mutateAsync: reloadServer, isLoading } =
api.settings.reloadServer.useMutation(); api.settings.reloadServer.useMutation();
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild disabled={isLoading}> <DropdownMenuTrigger asChild disabled={isLoading}>
@@ -69,6 +72,36 @@ export const ShowDokployActions = () => {
{t("settings.server.webServer.updateServerIp")} {t("settings.server.webServer.updateServerIp")}
</DropdownMenuItem> </DropdownMenuItem>
</UpdateServerIp> </UpdateServerIp>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await cleanRedis()
.then(async () => {
toast.success("Redis cleaned");
})
.catch(() => {
toast.error("Error cleaning Redis");
});
}}
>
Clean Redis
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
onClick={async () => {
await reloadRedis()
.then(async () => {
toast.success("Redis reloaded");
})
.catch(() => {
toast.error("Error reloading Redis");
});
}}
>
Reload Redis
</DropdownMenuItem>
</DropdownMenuGroup> </DropdownMenuGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
key={sshKey.sshKeyId} key={sshKey.sshKeyId}
className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg" className="flex items-center justify-between bg-sidebar p-1 w-full rounded-lg"
> >
<div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full"> <div className="flex items-center justify-between p-3.5 rounded-lg bg-background border w-full">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

View File

@@ -9,6 +9,7 @@ import {
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -22,6 +23,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { GlobeIcon } from "lucide-react"; import { GlobeIcon } from "lucide-react";
@@ -33,11 +35,19 @@ import { z } from "zod";
const addServerDomain = z const addServerDomain = z
.object({ .object({
domain: z.string().min(1, { message: "URL is required" }), domain: z.string(),
letsEncryptEmail: z.string(), letsEncryptEmail: z.string(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]), certificateType: z.enum(["letsencrypt", "none", "custom"]),
}) })
.superRefine((data, ctx) => { .superRefine((data, ctx) => {
if (data.https && !data.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) { if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
@@ -61,15 +71,18 @@ export const WebDomain = () => {
domain: "", domain: "",
certificateType: "none", certificateType: "none",
letsEncryptEmail: "", letsEncryptEmail: "",
https: false,
}, },
resolver: zodResolver(addServerDomain), resolver: zodResolver(addServerDomain),
}); });
const https = form.watch("https");
useEffect(() => { useEffect(() => {
if (data) { if (data) {
form.reset({ form.reset({
domain: data?.user?.host || "", domain: data?.user?.host || "",
certificateType: data?.user?.certificateType, certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "", letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
}); });
} }
}, [form, form.reset, data]); }, [form, form.reset, data]);
@@ -79,6 +92,7 @@ export const WebDomain = () => {
host: data.domain, host: data.domain,
letsEncryptEmail: data.letsEncryptEmail, letsEncryptEmail: data.letsEncryptEmail,
certificateType: data.certificateType, certificateType: data.certificateType,
https: data.https,
}) })
.then(async () => { .then(async () => {
await refetch(); await refetch();
@@ -155,44 +169,67 @@ export const WebDomain = () => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="certificateType" name="https"
render={({ field }) => { render={({ field }) => (
return ( <FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
<FormItem className="md:col-span-2"> <div className="space-y-0.5">
<FormLabel> <FormLabel>HTTPS</FormLabel>
{t("settings.server.domain.form.certificate.label")} <FormDescription>
</FormLabel> Automatically provision SSL Certificate.
<Select </FormDescription>
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage /> <FormMessage />
</FormItem> </div>
); <FormControl>
}} <Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/> />
{https && (
<FormField
control={form.control}
name="certificateType"
render={({ field }) => {
return (
<FormItem className="md:col-span-2">
<FormLabel>
{t("settings.server.domain.form.certificate.label")}
</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={t(
"settings.server.domain.form.certificate.placeholder",
)}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value={"none"}>
{t(
"settings.server.domain.form.certificateOptions.none",
)}
</SelectItem>
<SelectItem value={"letsencrypt"}>
{t(
"settings.server.domain.form.certificateOptions.letsencrypt",
)}
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
);
}}
/>
)}
<div className="flex w-full justify-end col-span-2"> <div className="flex w-full justify-end col-span-2">
<Button isLoading={isLoading} type="submit"> <Button isLoading={isLoading} type="submit">

View File

@@ -120,17 +120,6 @@ export const UserNav = () => {
Docker Docker
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/settings");
}}
>
Settings
</DropdownMenuItem>
)}
</> </>
) : ( ) : (
<> <>
@@ -144,17 +133,6 @@ export const UserNav = () => {
Servers Servers
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{data?.role === "owner" && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
router.push("/dashboard/settings");
}}
>
Settings
</DropdownMenuItem>
)}
</> </>
)} )}
</DropdownMenuGroup> </DropdownMenuGroup>

View File

@@ -26,15 +26,20 @@ const dockerComposeServices = [
{ label: "secrets", type: "keyword", info: "Define secrets" }, { label: "secrets", type: "keyword", info: "Define secrets" },
].map((opt) => ({ ].map((opt) => ({
...opt, ...opt,
apply: (view: EditorView, completion: Completion) => { apply: (
view: EditorView,
completion: Completion,
from: number,
to: number,
) => {
const insert = `${completion.label}:`; const insert = `${completion.label}:`;
view.dispatch({ view.dispatch({
changes: { changes: {
from: view.state.selection.main.from, from,
to: view.state.selection.main.to, to,
insert, insert,
}, },
selection: { anchor: view.state.selection.main.from + insert.length }, selection: { anchor: from + insert.length },
}); });
}, },
})); }));
@@ -74,15 +79,20 @@ const dockerComposeServiceOptions = [
{ label: "networks", type: "keyword", info: "Networks to join" }, { label: "networks", type: "keyword", info: "Networks to join" },
].map((opt) => ({ ].map((opt) => ({
...opt, ...opt,
apply: (view: EditorView, completion: Completion) => { apply: (
view: EditorView,
completion: Completion,
from: number,
to: number,
) => {
const insert = `${completion.label}: `; const insert = `${completion.label}: `;
view.dispatch({ view.dispatch({
changes: { changes: {
from: view.state.selection.main.from, from,
to: view.state.selection.main.to, to,
insert, insert,
}, },
selection: { anchor: view.state.selection.main.from + insert.length }, selection: { anchor: from + insert.length },
}); });
}, },
})); }));
@@ -99,6 +109,7 @@ function dockerComposeComplete(
const line = context.state.doc.lineAt(context.pos); const line = context.state.doc.lineAt(context.pos);
const indentation = /^\s*/.exec(line.text)?.[0].length || 0; const indentation = /^\s*/.exec(line.text)?.[0].length || 0;
// If we're at the root level
if (indentation === 0) { if (indentation === 0) {
return { return {
from: word.from, from: word.from,

View File

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ADD COLUMN "enableSubmodules" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "enableSubmodules" boolean DEFAULT false;

View File

@@ -0,0 +1,2 @@
ALTER TABLE "application" ALTER COLUMN "enableSubmodules" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "compose" ALTER COLUMN "enableSubmodules" SET NOT NULL;

View File

@@ -0,0 +1,3 @@
CREATE TYPE "public"."triggerType" AS ENUM('push', 'tag');--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -589,6 +589,34 @@
"when": 1743288371413, "when": 1743288371413,
"tag": "0083_parallel_stranger", "tag": "0083_parallel_stranger",
"breakpoints": true "breakpoints": true
},
{
"idx": 84,
"version": "7",
"when": 1743923992280,
"tag": "0084_thin_iron_lad",
"breakpoints": true
},
{
"idx": 85,
"version": "7",
"when": 1745705609181,
"tag": "0085_equal_captain_stacy",
"breakpoints": true
},
{
"idx": 86,
"version": "7",
"when": 1745706676004,
"tag": "0086_rainy_gertrude_yorkes",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1745723563822,
"tag": "0087_lively_risque",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,23 +1,27 @@
/**
* Sorted list based off of population of the country / speakers of the language.
*/
export const Languages = { export const Languages = {
english: { code: "en", name: "English" }, english: { code: "en", name: "English" },
spanish: { code: "es", name: "Español" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
portuguese: { code: "pt-br", name: "Português" },
russian: { code: "ru", name: "Русский" },
japanese: { code: "ja", name: "日本語" },
german: { code: "de", name: "Deutsch" },
korean: { code: "ko", name: "한국어" },
french: { code: "fr", name: "Français" },
turkish: { code: "tr", name: "Türkçe" },
italian: { code: "it", name: "Italiano" },
polish: { code: "pl", name: "Polski" }, polish: { code: "pl", name: "Polski" },
ukrainian: { code: "uk", name: "Українська" }, ukrainian: { code: "uk", name: "Українська" },
russian: { code: "ru", name: "Русский" },
french: { code: "fr", name: "Français" },
german: { code: "de", name: "Deutsch" },
chineseTraditional: { code: "zh-Hant", name: "繁體中文" },
chineseSimplified: { code: "zh-Hans", name: "简体中文" },
turkish: { code: "tr", name: "Türkçe" },
kazakh: { code: "kz", name: "Қазақ" },
persian: { code: "fa", name: "فارسی" }, persian: { code: "fa", name: "فارسی" },
korean: { code: "ko", name: "한국어" }, dutch: { code: "nl", name: "Nederlands" },
portuguese: { code: "pt-br", name: "Português" }, indonesian: { code: "id", name: "Bahasa Indonesia" },
italian: { code: "it", name: "Italiano" }, kazakh: { code: "kz", name: "Қазақ" },
japanese: { code: "ja", name: "日本語" },
spanish: { code: "es", name: "Español" },
norwegian: { code: "no", name: "Norsk" }, norwegian: { code: "no", name: "Norsk" },
azerbaijani: { code: "az", name: "Azərbaycan" }, azerbaijani: { code: "az", name: "Azərbaycan" },
indonesian: { code: "id", name: "Bahasa Indonesia" },
malayalam: { code: "ml", name: "മലയാളം" }, malayalam: { code: "ml", name: "മലയാളം" },
}; };

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.21.3", "version": "v0.21.8",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -92,7 +92,7 @@
"adm-zip": "^0.5.14", "adm-zip": "^0.5.14",
"ai": "^4.0.23", "ai": "^4.0.23",
"bcrypt": "5.1.1", "bcrypt": "5.1.1",
"better-auth": "1.2.4", "better-auth": "1.2.6",
"bl": "6.0.11", "bl": "6.0.11",
"boxen": "^7.1.1", "boxen": "^7.1.1",
"bullmq": "5.4.2", "bullmq": "5.4.2",

View File

@@ -89,6 +89,115 @@ export default async function handler(
return; return;
} }
// Handle tag creation event
if (
req.headers["x-github-event"] === "push" &&
githubBody?.ref?.startsWith("refs/tags/")
) {
try {
const tagName = githubBody?.ref.replace("refs/tags/", "");
const repository = githubBody?.repository?.name;
const owner = githubBody?.repository?.owner?.name;
const deploymentTitle = `Tag created: ${tagName}`;
const deploymentHash = extractHash(req.headers, githubBody);
// Find applications configured to deploy on tag
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.triggerType, "tag"),
eq(applications.repository, repository),
eq(applications.owner, owner),
eq(applications.githubId, githubResult.githubId),
),
});
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
// Find compose apps configured to deploy on tag
const composeApps = await db.query.compose.findMany({
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.triggerType, "tag"),
eq(compose.repository, repository),
eq(compose.owner, owner),
eq(compose.githubId, githubResult.githubId),
),
});
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const totalApps = apps.length + composeApps.length;
if (totalApps === 0) {
res
.status(200)
.json({ message: "No apps configured to deploy on tag" });
return;
}
res.status(200).json({
message: `Deployed ${totalApps} apps based on tag ${tagName}`,
});
return;
} catch (error) {
console.error("Error deploying applications on tag:", error);
res
.status(400)
.json({ message: "Error deploying applications on tag", error });
return;
}
}
if (req.headers["x-github-event"] === "push") { if (req.headers["x-github-event"] === "push") {
try { try {
const branchName = githubBody?.ref?.replace("refs/heads/", ""); const branchName = githubBody?.ref?.replace("refs/heads/", "");
@@ -105,6 +214,7 @@ export default async function handler(
where: and( where: and(
eq(applications.sourceType, "github"), eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true), eq(applications.autoDeploy, true),
eq(applications.triggerType, "push"),
eq(applications.branch, branchName), eq(applications.branch, branchName),
eq(applications.repository, repository), eq(applications.repository, repository),
eq(applications.owner, owner), eq(applications.owner, owner),
@@ -150,6 +260,7 @@ export default async function handler(
where: and( where: and(
eq(compose.sourceType, "github"), eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true), eq(compose.autoDeploy, true),
eq(compose.triggerType, "push"),
eq(compose.branch, branchName), eq(compose.branch, branchName),
eq(compose.repository, repository), eq(compose.repository, repository),
eq(compose.owner, owner), eq(compose.owner, owner),

View File

@@ -314,31 +314,43 @@ const Project = (
}; };
const applicationActions = { const applicationActions = {
start: api.application.start.useMutation(),
stop: api.application.stop.useMutation(),
move: api.application.move.useMutation(), move: api.application.move.useMutation(),
delete: api.application.delete.useMutation(), delete: api.application.delete.useMutation(),
}; };
const postgresActions = { const postgresActions = {
start: api.postgres.start.useMutation(),
stop: api.postgres.stop.useMutation(),
move: api.postgres.move.useMutation(), move: api.postgres.move.useMutation(),
delete: api.postgres.remove.useMutation(), delete: api.postgres.remove.useMutation(),
}; };
const mysqlActions = { const mysqlActions = {
start: api.mysql.start.useMutation(),
stop: api.mysql.stop.useMutation(),
move: api.mysql.move.useMutation(), move: api.mysql.move.useMutation(),
delete: api.mysql.remove.useMutation(), delete: api.mysql.remove.useMutation(),
}; };
const mariadbActions = { const mariadbActions = {
start: api.mariadb.start.useMutation(),
stop: api.mariadb.stop.useMutation(),
move: api.mariadb.move.useMutation(), move: api.mariadb.move.useMutation(),
delete: api.mariadb.remove.useMutation(), delete: api.mariadb.remove.useMutation(),
}; };
const redisActions = { const redisActions = {
start: api.redis.start.useMutation(),
stop: api.redis.stop.useMutation(),
move: api.redis.move.useMutation(), move: api.redis.move.useMutation(),
delete: api.redis.remove.useMutation(), delete: api.redis.remove.useMutation(),
}; };
const mongoActions = { const mongoActions = {
start: api.mongo.start.useMutation(),
stop: api.mongo.stop.useMutation(),
move: api.mongo.move.useMutation(), move: api.mongo.move.useMutation(),
delete: api.mongo.remove.useMutation(), delete: api.mongo.remove.useMutation(),
}; };
@@ -348,7 +360,34 @@ const Project = (
setIsBulkActionLoading(true); setIsBulkActionLoading(true);
for (const serviceId of selectedServices) { for (const serviceId of selectedServices) {
try { try {
await composeActions.start.mutateAsync({ composeId: serviceId }); const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.start.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.start.mutateAsync({ composeId: serviceId });
break;
case "postgres":
await postgresActions.start.mutateAsync({ postgresId: serviceId });
break;
case "mysql":
await mysqlActions.start.mutateAsync({ mysqlId: serviceId });
break;
case "mariadb":
await mariadbActions.start.mutateAsync({ mariadbId: serviceId });
break;
case "redis":
await redisActions.start.mutateAsync({ redisId: serviceId });
break;
case "mongo":
await mongoActions.start.mutateAsync({ mongoId: serviceId });
break;
}
success++; success++;
} catch (_error) { } catch (_error) {
toast.error(`Error starting service ${serviceId}`); toast.error(`Error starting service ${serviceId}`);
@@ -368,7 +407,34 @@ const Project = (
setIsBulkActionLoading(true); setIsBulkActionLoading(true);
for (const serviceId of selectedServices) { for (const serviceId of selectedServices) {
try { try {
await composeActions.stop.mutateAsync({ composeId: serviceId }); const service = filteredServices.find((s) => s.id === serviceId);
if (!service) continue;
switch (service.type) {
case "application":
await applicationActions.stop.mutateAsync({
applicationId: serviceId,
});
break;
case "compose":
await composeActions.stop.mutateAsync({ composeId: serviceId });
break;
case "postgres":
await postgresActions.stop.mutateAsync({ postgresId: serviceId });
break;
case "mysql":
await mysqlActions.stop.mutateAsync({ mysqlId: serviceId });
break;
case "mariadb":
await mariadbActions.stop.mutateAsync({ mariadbId: serviceId });
break;
case "redis":
await redisActions.stop.mutateAsync({ redisId: serviceId });
break;
case "mongo":
await mongoActions.stop.mutateAsync({ mongoId: serviceId });
break;
}
success++; success++;
} catch (_error) { } catch (_error) {
toast.error(`Error stopping service ${serviceId}`); toast.error(`Error stopping service ${serviceId}`);

View File

@@ -217,7 +217,7 @@ const Service = (
router.push(newPath); router.push(newPath);
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden", "flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",

View File

@@ -212,15 +212,15 @@ const Service = (
router.push(newPath); router.push(newPath);
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId isCloud && data?.serverId
? "md:grid-cols-7" ? "lg:grid-cols-7"
: data?.serverId : data?.serverId
? "md:grid-cols-6" ? "lg:grid-cols-6"
: "md:grid-cols-7", : "lg:grid-cols-7",
)} )}
> >
<TabsTrigger value="general">General</TabsTrigger> <TabsTrigger value="general">General</TabsTrigger>

View File

@@ -182,7 +182,7 @@ const Mariadb = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",

View File

@@ -183,7 +183,7 @@ const Mongo = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",

View File

@@ -183,7 +183,7 @@ const MySql = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ", "md:grid md:w-fit max-md:overflow-y-scroll justify-start ",

View File

@@ -182,7 +182,7 @@ const Postgresql = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",

View File

@@ -182,7 +182,7 @@ const Redis = (
router.push(newPath, undefined, { shallow: true }); router.push(newPath, undefined, { shallow: true });
}} }}
> >
<div className="flex flex-row items-center justify-between w-full gap-4"> <div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<TabsList <TabsList
className={cn( className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start", "md:grid md:w-fit max-md:overflow-y-scroll justify-start",

View File

@@ -1,219 +0,0 @@
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { DialogFooter } from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { validateRequest } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { Settings } from "lucide-react";
import type { GetServerSidePropsContext } from "next";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
const settings = z.object({
cleanCacheOnApplications: z.boolean(),
cleanCacheOnCompose: z.boolean(),
cleanCacheOnPreviews: z.boolean(),
});
type SettingsType = z.infer<typeof settings>;
const Page = () => {
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync, isLoading, isError, error } =
api.user.update.useMutation();
const form = useForm<SettingsType>({
defaultValues: {
cleanCacheOnApplications: false,
cleanCacheOnCompose: false,
cleanCacheOnPreviews: false,
},
resolver: zodResolver(settings),
});
useEffect(() => {
form.reset({
cleanCacheOnApplications: data?.user.cleanupCacheApplications || false,
cleanCacheOnCompose: data?.user.cleanupCacheOnCompose || false,
cleanCacheOnPreviews: data?.user.cleanupCacheOnPreviews || false,
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
const onSubmit = async (values: SettingsType) => {
await mutateAsync({
cleanupCacheApplications: values.cleanCacheOnApplications,
cleanupCacheOnCompose: values.cleanCacheOnCompose,
cleanupCacheOnPreviews: values.cleanCacheOnPreviews,
})
.then(() => {
toast.success("Settings updated");
refetch();
})
.catch(() => {
toast.error("Something went wrong");
});
};
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Settings className="size-6 text-muted-foreground self-center" />
Settings
</CardTitle>
<CardDescription>Manage your Dokploy settings</CardDescription>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
<Form {...form}>
<form
id="hook-form-add-security"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-2"
>
<FormField
control={form.control}
name="cleanCacheOnApplications"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Clean Cache on Applications</FormLabel>
<FormDescription>
Clean the cache after every application deployment
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cleanCacheOnPreviews"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Clean Cache on Previews</FormLabel>
<FormDescription>
Clean the cache after every preview deployment
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cleanCacheOnCompose"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Clean Cache on Compose</FormLabel>
<FormDescription>
Clean the cache after every compose deployment
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<DialogFooter>
<Button
isLoading={isLoading}
form="hook-form-add-security"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</CardContent>
</div>
</Card>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Server">{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
},
};
}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1,58 @@
{
"settings.common.save": "Opslaan",
"settings.common.enterTerminal": "Terminal",
"settings.server.domain.title": "Server Domein",
"settings.server.domain.description": "Voeg een domein toe aan jouw server applicatie.",
"settings.server.domain.form.domain": "Domein",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt Email",
"settings.server.domain.form.certificate.label": "Certificaat Aanbieder",
"settings.server.domain.form.certificate.placeholder": "Select een certificaat",
"settings.server.domain.form.certificateOptions.none": "Geen",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web Server",
"settings.server.webServer.description": "Herlaad of maak de web server schoon.",
"settings.server.webServer.actions": "Acties",
"settings.server.webServer.reload": "Herladen",
"settings.server.webServer.watchLogs": "Bekijk Logs",
"settings.server.webServer.updateServerIp": "Update de Server IP",
"settings.server.webServer.server.label": "Server",
"settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "Bewerk Omgeving",
"settings.server.webServer.traefik.managePorts": "Extra Poort Mappings",
"settings.server.webServer.traefik.managePortsDescription": "Bewerk extra Poorten voor Traefik",
"settings.server.webServer.traefik.targetPort": "Doel Poort",
"settings.server.webServer.traefik.publishedPort": "Gepubliceerde Poort",
"settings.server.webServer.traefik.addPort": "Voeg Poort toe",
"settings.server.webServer.traefik.portsUpdated": "Poorten succesvol aangepast",
"settings.server.webServer.traefik.portsUpdateError": "Poorten niet succesvol aangepast",
"settings.server.webServer.traefik.publishMode": "Publiceer Mode",
"settings.server.webServer.storage.label": "Opslag",
"settings.server.webServer.storage.cleanUnusedImages": "Maak ongebruikte images schoon",
"settings.server.webServer.storage.cleanUnusedVolumes": "Maak ongebruikte volumes schoon",
"settings.server.webServer.storage.cleanStoppedContainers": "Maak gestopte containers schoon",
"settings.server.webServer.storage.cleanDockerBuilder": "Maak Docker Builder & Systeem schoon",
"settings.server.webServer.storage.cleanMonitoring": "Maak monitoor schoon",
"settings.server.webServer.storage.cleanAll": "Maak alles schoon",
"settings.profile.title": "Account",
"settings.profile.description": "Veramder details van account.",
"settings.profile.email": "Email",
"settings.profile.password": "Wachtwoord",
"settings.profile.avatar": "Profiel Icoon",
"settings.appearance.title": "Uiterlijk",
"settings.appearance.description": "Verander het thema van je dashboard.",
"settings.appearance.theme": "Thema",
"settings.appearance.themeDescription": "Selecteer een thema voor je dashboard.",
"settings.appearance.themes.light": "Licht",
"settings.appearance.themes.dark": "Donker",
"settings.appearance.themes.system": "Systeem",
"settings.appearance.language": "Taal",
"settings.appearance.languageDescription": "Selecteer een taal voor je dashboard.",
"settings.terminal.connectionSettings": "Verbindings instellingen",
"settings.terminal.ipAddress": "IP Address",
"settings.terminal.port": "Poort",
"settings.terminal.username": "Gebruikersnaam"
}

View File

@@ -1 +1,78 @@
{} {
"dashboard.title": "仪表盘",
"dashboard.overview": "概览",
"dashboard.projects": "项目",
"dashboard.servers": "服务器",
"dashboard.docker": "Docker",
"dashboard.monitoring": "监控",
"dashboard.settings": "设置",
"dashboard.logout": "退出登录",
"dashboard.profile": "个人资料",
"dashboard.terminal": "终端",
"dashboard.containers": "容器",
"dashboard.images": "镜像",
"dashboard.volumes": "卷",
"dashboard.networks": "网络",
"button.create": "创建",
"button.edit": "编辑",
"button.delete": "删除",
"button.cancel": "取消",
"button.save": "保存",
"button.confirm": "确认",
"button.back": "返回",
"button.next": "下一步",
"button.finish": "完成",
"status.running": "运行中",
"status.stopped": "已停止",
"status.error": "错误",
"status.pending": "等待中",
"status.success": "成功",
"status.failed": "失败",
"form.required": "必填",
"form.invalid": "无效",
"form.submit": "提交",
"form.reset": "重置",
"notification.success": "操作成功",
"notification.error": "操作失败",
"notification.warning": "警告",
"notification.info": "信息",
"time.now": "刚刚",
"time.minutes": "分钟前",
"time.hours": "小时前",
"time.days": "天前",
"filter.all": "全部",
"filter.active": "活跃",
"filter.inactive": "不活跃",
"sort.asc": "升序",
"sort.desc": "降序",
"search.placeholder": "搜索...",
"search.noResults": "无结果",
"pagination.prev": "上一页",
"pagination.next": "下一页",
"pagination.of": "共 {0} 页",
"error.notFound": "未找到",
"error.serverError": "服务器错误",
"error.unauthorized": "未授权",
"error.forbidden": "禁止访问",
"loading": "加载中...",
"empty": "暂无数据",
"more": "更多",
"less": "收起",
"project.create": "创建项目",
"project.edit": "编辑项目",
"project.delete": "删除项目",
"project.name": "项目名称",
"project.description": "项目描述",
"service.create": "创建服务",
"service.edit": "编辑服务",
"service.delete": "删除服务",
"service.name": "服务名称",
"service.type": "服务类型",
"domain.add": "添加域名",
"domain.remove": "移除域名",
"environment.variables": "环境变量",
"environment.add": "添加环境变量",
"environment.edit": "编辑环境变量",
"environment.name": "变量名",
"environment.value": "变量值"
}

View File

@@ -1,17 +1,16 @@
{ {
"settings.common.save": "保存", "settings.common.save": "保存",
"settings.common.enterTerminal": "进入终端", "settings.common.enterTerminal": "终端",
"settings.server.domain.title": "域名设置", "settings.server.domain.title": "服务器域名",
"settings.server.domain.description": "添加域名到服务器", "settings.server.domain.description": "为您的服务器应用添加域名。",
"settings.server.domain.form.domain": "域名", "settings.server.domain.form.domain": "域名",
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 邮箱", "settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 邮箱",
"settings.server.domain.form.certificate.label": "证书", "settings.server.domain.form.certificate.label": "证书提供商",
"settings.server.domain.form.certificate.placeholder": "选择一个证书", "settings.server.domain.form.certificate.placeholder": "选择证书",
"settings.server.domain.form.certificateOptions.none": "无", "settings.server.domain.form.certificateOptions.none": "无",
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt", "settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
"settings.server.webServer.title": "Web 服务器",
"settings.server.webServer.title": "服务器设置", "settings.server.webServer.description": "重载或清理 Web 服务器",
"settings.server.webServer.description": "管理服务器",
"settings.server.webServer.actions": "操作", "settings.server.webServer.actions": "操作",
"settings.server.webServer.reload": "重新加载", "settings.server.webServer.reload": "重新加载",
"settings.server.webServer.watchLogs": "查看日志", "settings.server.webServer.watchLogs": "查看日志",
@@ -19,40 +18,50 @@
"settings.server.webServer.server.label": "服务器", "settings.server.webServer.server.label": "服务器",
"settings.server.webServer.traefik.label": "Traefik", "settings.server.webServer.traefik.label": "Traefik",
"settings.server.webServer.traefik.modifyEnv": "修改环境变量", "settings.server.webServer.traefik.modifyEnv": "修改环境变量",
"settings.server.webServer.traefik.managePorts": "端口转发", "settings.server.webServer.traefik.managePorts": "额外端口映射",
"settings.server.webServer.traefik.managePortsDescription": "添加或删除 Traefik 的其他端口", "settings.server.webServer.traefik.managePortsDescription": " Traefik 添加或删除额外端口",
"settings.server.webServer.traefik.targetPort": "目标端口", "settings.server.webServer.traefik.targetPort": "目标端口",
"settings.server.webServer.traefik.publishedPort": "对外端口", "settings.server.webServer.traefik.publishedPort": "发布端口",
"settings.server.webServer.traefik.addPort": "添加端口", "settings.server.webServer.traefik.addPort": "添加端口",
"settings.server.webServer.traefik.portsUpdated": "端口更新成功", "settings.server.webServer.traefik.portsUpdated": "端口更新成功",
"settings.server.webServer.traefik.portsUpdateError": "端口更新失败", "settings.server.webServer.traefik.portsUpdateError": "端口更新失败",
"settings.server.webServer.traefik.publishMode": "端口映射", "settings.server.webServer.traefik.publishMode": "发布模式",
"settings.server.webServer.storage.label": "存储空间", "settings.server.webServer.storage.label": "存储空间",
"settings.server.webServer.storage.cleanUnusedImages": "清理未使用的镜像", "settings.server.webServer.storage.cleanUnusedImages": "清理未使用的镜像",
"settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷", "settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷",
"settings.server.webServer.storage.cleanStoppedContainers": "清理已停止的容器", "settings.server.webServer.storage.cleanStoppedContainers": "清理已停止的容器",
"settings.server.webServer.storage.cleanDockerBuilder": "清理 Docker Builder 与 系统缓存", "settings.server.webServer.storage.cleanDockerBuilder": "清理 Docker Builder 和系统",
"settings.server.webServer.storage.cleanMonitoring": "清理监控数据", "settings.server.webServer.storage.cleanMonitoring": "清理监控数据",
"settings.server.webServer.storage.cleanAll": "清理所有内容", "settings.server.webServer.storage.cleanAll": "清理所有内容",
"settings.profile.title": "账户", "settings.profile.title": "账户",
"settings.profile.description": "更改您的个人资料", "settings.profile.description": "在此更改您的个人资料详情。",
"settings.profile.email": "邮箱", "settings.profile.email": "邮箱",
"settings.profile.password": "密码", "settings.profile.password": "密码",
"settings.profile.avatar": "头像", "settings.profile.avatar": "头像",
"settings.appearance.title": "外观", "settings.appearance.title": "外观",
"settings.appearance.description": "自定义面板主题", "settings.appearance.description": "自定义您的仪表盘主题",
"settings.appearance.theme": "主题", "settings.appearance.theme": "主题",
"settings.appearance.themeDescription": "选择面板主题", "settings.appearance.themeDescription": "为您的仪表盘选择主题",
"settings.appearance.themes.light": "明亮", "settings.appearance.themes.light": "明亮",
"settings.appearance.themes.dark": "暗", "settings.appearance.themes.dark": "暗",
"settings.appearance.themes.system": "系统主题", "settings.appearance.themes.system": "跟随系统",
"settings.appearance.language": "语言", "settings.appearance.language": "语言",
"settings.appearance.languageDescription": "选择面板语言", "settings.appearance.languageDescription": "为您的仪表盘选择语言",
"settings.terminal.connectionSettings": "连接设置",
"settings.terminal.connectionSettings": "终端设置", "settings.terminal.ipAddress": "IP 地址",
"settings.terminal.ipAddress": "IP",
"settings.terminal.port": "端口", "settings.terminal.port": "端口",
"settings.terminal.username": "用户名" "settings.terminal.username": "用户名",
"settings.settings": "设置",
"settings.general": "通用设置",
"settings.security": "安全",
"settings.users": "用户管理",
"settings.roles": "角色管理",
"settings.permissions": "权限",
"settings.api": "API设置",
"settings.certificates": "证书管理",
"settings.ssh": "SSH密钥",
"settings.backups": "备份",
"settings.logs": "日志",
"settings.updates": "更新",
"settings.network": "网络"
} }

View File

@@ -33,6 +33,7 @@ import {
findApplicationById, findApplicationById,
findProjectById, findProjectById,
getApplicationStats, getApplicationStats,
mechanizeDockerContainer,
readConfig, readConfig,
readRemoteConfig, readRemoteConfig,
removeDeployments, removeDeployments,
@@ -132,28 +133,36 @@ export const applicationRouter = createTRPCRouter({
.input(apiReloadApplication) .input(apiReloadApplication)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
if (
application.project.organizationId !== ctx.session.activeOrganizationId try {
) { if (
application.project.organizationId !==
ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to reload this application",
});
}
if (application.serverId) {
await stopServiceRemote(application.serverId, input.appName);
} else {
await stopService(input.appName);
}
await updateApplicationStatus(input.applicationId, "idle");
await mechanizeDockerContainer(application);
await updateApplicationStatus(input.applicationId, "done");
return true;
} catch (error) {
await updateApplicationStatus(input.applicationId, "error");
throw new TRPCError({ throw new TRPCError({
code: "UNAUTHORIZED", code: "INTERNAL_SERVER_ERROR",
message: "You are not authorized to reload this application", message: "Error reloading application",
cause: error,
}); });
} }
if (application.serverId) {
await stopServiceRemote(application.serverId, input.appName);
} else {
await stopService(input.appName);
}
await updateApplicationStatus(input.applicationId, "idle");
if (application.serverId) {
await startServiceRemote(application.serverId, input.appName);
} else {
await startService(input.appName);
}
await updateApplicationStatus(input.applicationId, "done");
return true;
}), }),
delete: protectedProcedure delete: protectedProcedure
@@ -346,6 +355,8 @@ export const applicationRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
githubId: input.githubId, githubId: input.githubId,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
triggerType: input.triggerType,
enableSubmodules: input.enableSubmodules,
}); });
return true; return true;
@@ -373,6 +384,7 @@ export const applicationRouter = createTRPCRouter({
gitlabProjectId: input.gitlabProjectId, gitlabProjectId: input.gitlabProjectId,
gitlabPathNamespace: input.gitlabPathNamespace, gitlabPathNamespace: input.gitlabPathNamespace,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
}); });
return true; return true;
@@ -398,6 +410,7 @@ export const applicationRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
bitbucketId: input.bitbucketId, bitbucketId: input.bitbucketId,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
}); });
return true; return true;
@@ -423,6 +436,7 @@ export const applicationRouter = createTRPCRouter({
applicationStatus: "idle", applicationStatus: "idle",
giteaId: input.giteaId, giteaId: input.giteaId,
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
}); });
return true; return true;
@@ -470,6 +484,7 @@ export const applicationRouter = createTRPCRouter({
sourceType: "git", sourceType: "git",
applicationStatus: "idle", applicationStatus: "idle",
watchPaths: input.watchPaths, watchPaths: input.watchPaths,
enableSubmodules: input.enableSubmodules,
}); });
return true; return true;

View File

@@ -31,7 +31,10 @@ import {
} from "@dokploy/server"; } from "@dokploy/server";
import { findDestinationById } from "@dokploy/server/services/destination"; import { findDestinationById } from "@dokploy/server/services/destination";
import { getS3Credentials } from "@dokploy/server/utils/backups/utils"; import {
getS3Credentials,
normalizeS3Path,
} from "@dokploy/server/utils/backups/utils";
import { import {
execAsync, execAsync,
execAsyncRemote, execAsyncRemote,
@@ -47,6 +50,18 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable"; import { observable } from "@trpc/server/observable";
import { z } from "zod"; import { z } from "zod";
interface RcloneFile {
Path: string;
Name: string;
Size: number;
IsDir: boolean;
Tier?: string;
Hashes?: {
MD5?: string;
SHA1?: string;
};
}
export const backupRouter = createTRPCRouter({ export const backupRouter = createTRPCRouter({
create: protectedProcedure create: protectedProcedure
.input(apiCreateBackup) .input(apiCreateBackup)
@@ -257,7 +272,7 @@ export const backupRouter = createTRPCRouter({
const lastSlashIndex = input.search.lastIndexOf("/"); const lastSlashIndex = input.search.lastIndexOf("/");
const baseDir = const baseDir =
lastSlashIndex !== -1 lastSlashIndex !== -1
? input.search.slice(0, lastSlashIndex + 1) ? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1))
: ""; : "";
const searchTerm = const searchTerm =
lastSlashIndex !== -1 lastSlashIndex !== -1
@@ -265,32 +280,47 @@ export const backupRouter = createTRPCRouter({
: input.search; : input.search;
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath; const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} "${searchPath}" | head -n 100`; const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`;
let stdout = ""; let stdout = "";
if (input.serverId) { if (input.serverId) {
const result = await execAsyncRemote(listCommand, input.serverId); const result = await execAsyncRemote(input.serverId, listCommand);
stdout = result.stdout; stdout = result.stdout;
} else { } else {
const result = await execAsync(listCommand); const result = await execAsync(listCommand);
stdout = result.stdout; stdout = result.stdout;
} }
const files = stdout.split("\n").filter(Boolean); let files: RcloneFile[] = [];
try {
files = JSON.parse(stdout) as RcloneFile[];
} catch (error) {
console.error("Error parsing JSON response:", error);
console.error("Raw stdout:", stdout);
throw new Error("Failed to parse backup files list");
}
// Limit to first 100 files
const results = baseDir const results = baseDir
? files.map((file) => `${baseDir}${file}`) ? files.map((file) => ({
...file,
Path: `${baseDir}${file.Path}`,
}))
: files; : files;
if (searchTerm) { if (searchTerm) {
return results.filter((file) => return results
file.toLowerCase().includes(searchTerm.toLowerCase()), .filter((file) =>
); file.Path.toLowerCase().includes(searchTerm.toLowerCase()),
)
.slice(0, 100);
} }
return results; return results.slice(0, 100);
} catch (error) { } catch (error) {
console.error("Error in listBackupFiles:", error);
throw new TRPCError({ throw new TRPCError({
code: "BAD_REQUEST", code: "BAD_REQUEST",
message: message:

View File

@@ -31,7 +31,6 @@ export const mountRouter = createTRPCRouter({
update: protectedProcedure update: protectedProcedure
.input(apiUpdateMount) .input(apiUpdateMount)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
await updateMount(input.mountId, input); return await updateMount(input.mountId, input);
return true;
}), }),
}); });

View File

@@ -10,8 +10,8 @@ import {
import { import {
IS_CLOUD, IS_CLOUD,
createRegistry, createRegistry,
execAsync,
execAsyncRemote, execAsyncRemote,
execFileAsync,
findRegistryById, findRegistryById,
removeRegistry, removeRegistry,
updateRegistry, updateRegistry,
@@ -83,7 +83,13 @@ export const registryRouter = createTRPCRouter({
.input(apiTestRegistry) .input(apiTestRegistry)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
try { try {
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`; const args = [
"login",
input.registryUrl,
"--username",
input.username,
"--password-stdin",
];
if (IS_CLOUD && !input.serverId) { if (IS_CLOUD && !input.serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -93,9 +99,14 @@ export const registryRouter = createTRPCRouter({
} }
if (input.serverId && input.serverId !== "none") { if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand); await execAsyncRemote(
input.serverId,
`echo ${input.password} | docker ${args.join(" ")}`,
);
} else { } else {
await execAsync(loginCommand); await execFileAsync("docker", args, {
input: Buffer.from(input.password).toString(),
});
} }
return true; return true;

View File

@@ -79,6 +79,33 @@ export const settingsRouter = createTRPCRouter({
await execAsync(`docker service update --force ${stdout.trim()}`); await execAsync(`docker service update --force ${stdout.trim()}`);
return true; return true;
}), }),
cleanRedis: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
}
const { stdout: containerId } = await execAsync(
`docker ps --filter "name=dokploy-redis" --filter "status=running" -q | head -n 1`,
);
if (!containerId) {
throw new Error("Redis container not found");
}
const redisContainerId = containerId.trim();
await execAsync(`docker exec -i ${redisContainerId} redis-cli flushall`);
return true;
}),
reloadRedis: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
}
await execAsync("docker service scale dokploy-redis=0");
await execAsync("docker service scale dokploy-redis=1");
return true;
}),
reloadTraefik: adminProcedure reloadTraefik: adminProcedure
.input(apiServerSchema) .input(apiServerSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@@ -184,6 +211,7 @@ export const settingsRouter = createTRPCRouter({
letsEncryptEmail: input.letsEncryptEmail, letsEncryptEmail: input.letsEncryptEmail,
}), }),
certificateType: input.certificateType, certificateType: input.certificateType,
https: input.https,
}); });
if (!user) { if (!user) {

View File

@@ -21,6 +21,7 @@ import { setupTerminalWebSocketServer } from "./wss/terminal";
config({ path: ".env" }); config({ path: ".env" });
const PORT = Number.parseInt(process.env.PORT || "3000", 10); const PORT = Number.parseInt(process.env.PORT || "3000", 10);
const HOST = process.env.HOST || "0.0.0.0";
const dev = process.env.NODE_ENV !== "production"; const dev = process.env.NODE_ENV !== "production";
const app = next({ dev, turbopack: process.env.TURBOPACK === "1" }); const app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
@@ -55,8 +56,8 @@ void app.prepare().then(async () => {
await migration(); await migration();
} }
server.listen(PORT); server.listen(PORT, HOST);
console.log("Server Started:", PORT); console.log(`Server Started on: http://${HOST}:${PORT}`);
if (!IS_CLOUD) { if (!IS_CLOUD) {
console.log("Starting Deployment Worker"); console.log("Starting Deployment Worker");
const { deploymentWorker } = await import("./queues/deployments-queue"); const { deploymentWorker } = await import("./queues/deployments-queue");

View File

@@ -1,9 +1,9 @@
import type http from "node:http"; import type http from "node:http";
import { validateRequest } from "@dokploy/server/index";
import { applyWSSHandler } from "@trpc/server/adapters/ws"; import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
import { appRouter } from "../api/root"; import { appRouter } from "../api/root";
import { createTRPCContext } from "../api/trpc"; import { createTRPCContext } from "../api/trpc";
import { validateRequest } from "@dokploy/server/lib/auth";
export const setupDrawerLogsWebSocketServer = ( export const setupDrawerLogsWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -13,11 +13,13 @@ export const setupDrawerLogsWebSocketServer = (
path: "/drawer-logs", path: "/drawer-logs",
}); });
// Set up tRPC WebSocket handler
applyWSSHandler({ applyWSSHandler({
wss: wssTerm, wss: wssTerm,
router: appRouter, router: appRouter,
createContext: createTRPCContext as any, createContext: createTRPCContext as any,
}); });
server.on("upgrade", (req, socket, head) => { server.on("upgrade", (req, socket, head) => {
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`); const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);

View File

@@ -36,11 +36,11 @@
"@ai-sdk/mistral": "^1.0.6", "@ai-sdk/mistral": "^1.0.6",
"@ai-sdk/openai": "^1.0.12", "@ai-sdk/openai": "^1.0.12",
"@ai-sdk/openai-compatible": "^0.0.13", "@ai-sdk/openai-compatible": "^0.0.13",
"@better-auth/utils": "0.2.3", "@better-auth/utils": "0.2.4",
"@oslojs/encoding": "1.1.0", "@oslojs/encoding": "1.1.0",
"@oslojs/crypto": "1.0.1", "@oslojs/crypto": "1.0.1",
"drizzle-dbml-generator": "0.10.0", "drizzle-dbml-generator": "0.10.0",
"better-auth": "1.2.4", "better-auth": "1.2.6",
"@faker-js/faker": "^8.4.1", "@faker-js/faker": "^8.4.1",
"@octokit/auth-app": "^6.0.4", "@octokit/auth-app": "^6.0.4",
"@react-email/components": "^0.0.21", "@react-email/components": "^0.0.21",

View File

@@ -24,7 +24,7 @@ import { redirects } from "./redirects";
import { registry } from "./registry"; import { registry } from "./registry";
import { security } from "./security"; import { security } from "./security";
import { server } from "./server"; import { server } from "./server";
import { applicationStatus, certificateType } from "./shared"; import { applicationStatus, certificateType, triggerType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
@@ -149,6 +149,7 @@ export const applications = pgTable("application", {
owner: text("owner"), owner: text("owner"),
branch: text("branch"), branch: text("branch"),
buildPath: text("buildPath").default("/"), buildPath: text("buildPath").default("/"),
triggerType: triggerType("triggerType").default("push"),
autoDeploy: boolean("autoDeploy").$defaultFn(() => true), autoDeploy: boolean("autoDeploy").$defaultFn(() => true),
// Gitlab // Gitlab
gitlabProjectId: integer("gitlabProjectId"), gitlabProjectId: integer("gitlabProjectId"),
@@ -182,6 +183,7 @@ export const applications = pgTable("application", {
onDelete: "set null", onDelete: "set null",
}, },
), ),
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
dockerfile: text("dockerfile"), dockerfile: text("dockerfile"),
dockerContextPath: text("dockerContextPath"), dockerContextPath: text("dockerContextPath"),
dockerBuildStage: text("dockerBuildStage"), dockerBuildStage: text("dockerBuildStage"),
@@ -470,8 +472,12 @@ export const apiSaveGithubProvider = createSchema
buildPath: true, buildPath: true,
githubId: true, githubId: true,
watchPaths: true, watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required()
.extend({
triggerType: z.enum(["push", "tag"]).default("push"),
});
export const apiSaveGitlabProvider = createSchema export const apiSaveGitlabProvider = createSchema
.pick({ .pick({
@@ -484,6 +490,7 @@ export const apiSaveGitlabProvider = createSchema
gitlabProjectId: true, gitlabProjectId: true,
gitlabPathNamespace: true, gitlabPathNamespace: true,
watchPaths: true, watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required();
@@ -496,6 +503,7 @@ export const apiSaveBitbucketProvider = createSchema
bitbucketId: true, bitbucketId: true,
applicationId: true, applicationId: true,
watchPaths: true, watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required();
@@ -508,6 +516,7 @@ export const apiSaveGiteaProvider = createSchema
giteaRepository: true, giteaRepository: true,
giteaId: true, giteaId: true,
watchPaths: true, watchPaths: true,
enableSubmodules: true,
}) })
.required(); .required();
@@ -528,6 +537,7 @@ export const apiSaveGitProvider = createSchema
customGitBuildPath: true, customGitBuildPath: true,
customGitUrl: true, customGitUrl: true,
watchPaths: true, watchPaths: true,
enableSubmodules: true,
}) })
.required() .required()
.merge( .merge(

View File

@@ -12,7 +12,7 @@ import { gitlab } from "./gitlab";
import { mounts } from "./mount"; import { mounts } from "./mount";
import { projects } from "./project"; import { projects } from "./project";
import { server } from "./server"; import { server } from "./server";
import { applicationStatus } from "./shared"; import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key"; import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils"; import { generateAppName } from "./utils";
@@ -72,10 +72,12 @@ export const compose = pgTable("compose", {
), ),
command: text("command").notNull().default(""), command: text("command").notNull().default(""),
// //
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
composePath: text("composePath").notNull().default("./docker-compose.yml"), composePath: text("composePath").notNull().default("./docker-compose.yml"),
suffix: text("suffix").notNull().default(""), suffix: text("suffix").notNull().default(""),
randomize: boolean("randomize").notNull().default(false), randomize: boolean("randomize").notNull().default(false),
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false), isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"), composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
projectId: text("projectId") projectId: text("projectId")
.notNull() .notNull()

View File

@@ -12,3 +12,5 @@ export const certificateType = pgEnum("certificateType", [
"none", "none",
"custom", "custom",
]); ]);
export const triggerType = pgEnum("triggerType", ["push", "tag"]);

View File

@@ -50,6 +50,7 @@ export const users_temp = pgTable("user_temp", {
// Admin // Admin
serverIp: text("serverIp"), serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"), certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
host: text("host"), host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"), letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"), sshPrivateKey: text("sshPrivateKey"),
@@ -203,10 +204,12 @@ export const apiAssignDomain = createSchema
host: true, host: true,
certificateType: true, certificateType: true,
letsEncryptEmail: true, letsEncryptEmail: true,
https: true,
}) })
.required() .required()
.partial({ .partial({
letsEncryptEmail: true, letsEncryptEmail: true,
https: true,
}); });
export const apiUpdateDockerCleanup = createSchema export const apiUpdateDockerCleanup = createSchema

View File

@@ -201,7 +201,7 @@ const { handler, api } = betterAuth({
const host = const host =
process.env.NODE_ENV === "development" process.env.NODE_ENV === "development"
? "http://localhost:3000" ? "http://localhost:3000"
: "https://dokploy.com"; : "https://app.dokploy.com";
const inviteLink = `${host}/invitation?token=${data.id}`; const inviteLink = `${host}/invitation?token=${data.id}`;
await sendEmail({ await sendEmail({

View File

@@ -356,6 +356,7 @@ export const deployRemoteCompose = async ({
deployment.logPath, deployment.logPath,
true, true,
); );
console.log(command);
} else if (compose.sourceType === "raw") { } else if (compose.sourceType === "raw") {
command += getCreateComposeFileCommand(compose, deployment.logPath); command += getCreateComposeFileCommand(compose, deployment.logPath);
} else if (compose.sourceType === "gitea") { } else if (compose.sourceType === "gitea") {

View File

@@ -144,7 +144,8 @@ export const updateMount = async (
await deleteFileMount(mountId); await deleteFileMount(mountId);
await createFileMount(mountId); await createFileMount(mountId);
} }
return mount;
return await findMountById(mountId);
}); });
}; };

View File

@@ -76,7 +76,7 @@ CURRENT_USER=$USER
echo "Installing requirements for: OS: $OS_TYPE" echo "Installing requirements for: OS: $OS_TYPE"
if [ $EUID != 0 ]; then if [ $EUID != 0 ]; then
echo "Please run this script as root or with sudo ❌" echo "Please run this script as root or with sudo ❌"
exit exit
fi fi
@@ -263,7 +263,7 @@ const setupMainDirectory = () => `
# Create the /etc/dokploy directory # Create the /etc/dokploy directory
mkdir -p /etc/dokploy mkdir -p /etc/dokploy
chmod 777 /etc/dokploy chmod 777 /etc/dokploy
echo "Directory /etc/dokploy created ✅" echo "Directory /etc/dokploy created ✅"
fi fi
`; `;
@@ -276,16 +276,16 @@ export const setupSwarm = () => `
# Get IP address # Get IP address
get_ip() { get_ip() {
local ip="" local ip=""
# Try IPv4 with multiple services # Try IPv4 with multiple services
# First attempt: ifconfig.io # First attempt: ifconfig.io
ip=\$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null) ip=\$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null)
# Second attempt: icanhazip.com # Second attempt: icanhazip.com
if [ -z "\$ip" ]; then if [ -z "\$ip" ]; then
ip=\$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null) ip=\$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null)
fi fi
# Third attempt: ipecho.net # Third attempt: ipecho.net
if [ -z "\$ip" ]; then if [ -z "\$ip" ]; then
ip=\$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) ip=\$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null)
@@ -295,12 +295,12 @@ export const setupSwarm = () => `
if [ -z "\$ip" ]; then if [ -z "\$ip" ]; then
# Try IPv6 with ifconfig.io # Try IPv6 with ifconfig.io
ip=\$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null) ip=\$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null)
# Try IPv6 with icanhazip.com # Try IPv6 with icanhazip.com
if [ -z "\$ip" ]; then if [ -z "\$ip" ]; then
ip=\$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null) ip=\$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null)
fi fi
# Try IPv6 with ipecho.net # Try IPv6 with ipecho.net
if [ -z "\$ip" ]; then if [ -z "\$ip" ]; then
ip=\$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null) ip=\$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null)
@@ -549,7 +549,7 @@ export const createTraefikInstance = () => {
sleep 8 sleep 8
echo "Traefik migrated to Standalone ✅" echo "Traefik migrated to Standalone ✅"
fi fi
if docker inspect dokploy-traefik > /dev/null 2>&1; then if docker inspect dokploy-traefik > /dev/null 2>&1; then
echo "Traefik already exists ✅" echo "Traefik already exists ✅"
else else
@@ -577,7 +577,7 @@ const installNixpacks = () => `
if command_exists nixpacks; then if command_exists nixpacks; then
echo "Nixpacks already installed ✅" echo "Nixpacks already installed ✅"
else else
export NIXPACKS_VERSION=1.29.1 export NIXPACKS_VERSION=1.35.0
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)" bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
echo "Nixpacks version $NIXPACKS_VERSION installed ✅" echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
fi fi

View File

@@ -1,4 +1,4 @@
import { randomBytes } from "node:crypto"; import { randomBytes, createHmac } from "node:crypto";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { mkdir, readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
@@ -24,6 +24,12 @@ export interface Template {
domains: DomainSchema[]; domains: DomainSchema[];
} }
export interface GenerateJWTOptions {
length?: number;
secret?: string;
payload?: Record<string, unknown> | undefined;
}
export const generateRandomDomain = ({ export const generateRandomDomain = ({
serverIp, serverIp,
projectName, projectName,
@@ -61,8 +67,48 @@ export function generateBase64(bytes = 32): string {
return randomBytes(bytes).toString("base64"); return randomBytes(bytes).toString("base64");
} }
export function generateJwt(length = 256): string { function safeBase64(str: string): string {
return randomBytes(length).toString("hex"); return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
}
function objToJWTBase64(obj: any): string {
return safeBase64(
Buffer.from(JSON.stringify(obj), "utf8").toString("base64"),
);
}
export function generateJwt(options: GenerateJWTOptions = {}): string {
let { length, secret, payload = {} } = options;
if (length) {
return randomBytes(length).toString("hex");
}
const encodedHeader = objToJWTBase64({
alg: "HS256",
typ: "JWT",
});
if (!payload.iss) {
payload.iss = "dokploy";
}
if (!payload.iat) {
payload.iat = Math.floor(Date.now() / 1000);
}
if (!payload.exp) {
payload.exp = Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000);
}
const encodedPayload = objToJWTBase64({
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(new Date("2030-01-01T00:00:00Z").getTime() / 1000),
...payload,
});
if (!secret) {
secret = randomBytes(32).toString("hex");
}
const signature = safeBase64(
createHmac("SHA256", secret)
.update(`${encodedHeader}.${encodedPayload}`)
.digest("base64"),
);
return `${encodedHeader}.${encodedPayload}.${signature}`;
} }
/** /**

View File

@@ -65,7 +65,7 @@ export interface Template {
/** /**
* Process a string value and replace variables * Process a string value and replace variables
*/ */
function processValue( export function processValue(
value: string, value: string,
variables: Record<string, string>, variables: Record<string, string>,
schema: Schema, schema: Schema,
@@ -84,11 +84,11 @@ function processValue(
const length = Number.parseInt(varName.split(":")[1], 10) || 32; const length = Number.parseInt(varName.split(":")[1], 10) || 32;
return generateBase64(length); return generateBase64(length);
} }
if (varName.startsWith("password:")) { if (varName.startsWith("password:")) {
const length = Number.parseInt(varName.split(":")[1], 10) || 16; const length = Number.parseInt(varName.split(":")[1], 10) || 16;
return generatePassword(length); return generatePassword(length);
} }
if (varName === "password") { if (varName === "password") {
return generatePassword(16); return generatePassword(16);
} }
@@ -97,14 +97,31 @@ function processValue(
const length = Number.parseInt(varName.split(":")[1], 10) || 8; const length = Number.parseInt(varName.split(":")[1], 10) || 8;
return generateHash(length); return generateHash(length);
} }
if (varName === "hash") {
return generateHash();
}
if (varName === "uuid") { if (varName === "uuid") {
return crypto.randomUUID(); return crypto.randomUUID();
} }
if (varName === "timestamp") { if (varName === "timestamp" || varName === "timestampms") {
return Date.now().toString(); return Date.now().toString();
} }
if (varName === "timestamps") {
return Math.round(Date.now() / 1000).toString();
}
if (varName.startsWith("timestampms:")) {
return new Date(varName.slice(12)).getTime().toString();
}
if (varName.startsWith("timestamps:")) {
return Math.round(
new Date(varName.slice(11)).getTime() / 1000,
).toString();
}
if (varName === "randomPort") { if (varName === "randomPort") {
return Math.floor(Math.random() * 65535).toString(); return Math.floor(Math.random() * 65535).toString();
} }
@@ -114,8 +131,34 @@ function processValue(
} }
if (varName.startsWith("jwt:")) { if (varName.startsWith("jwt:")) {
const length = Number.parseInt(varName.split(":")[1], 10) || 256; const params: string[] = varName.split(":").slice(1);
return generateJwt(length); if (params.length === 1 && params[0] && params[0].match(/^\d{1,3}$/)) {
return generateJwt({ length: Number.parseInt(params[0], 10) });
}
let [secret, payload] = params;
if (typeof payload === "string" && variables[payload]) {
payload = variables[payload];
}
if (
typeof payload === "string" &&
payload.startsWith("{") &&
payload.endsWith("}")
) {
try {
payload = JSON.parse(payload);
} catch (e) {
// If payload is not a valid JSON, invalid it
payload = undefined;
console.error("Invalid JWT payload", e);
}
}
if (typeof payload !== "object") {
payload = undefined;
}
return generateJwt({
secret: secret ? variables[secret] || secret : undefined,
payload: payload as any,
});
} }
if (varName === "username") { if (varName === "username") {
@@ -147,7 +190,7 @@ export function processVariables(
): Record<string, string> { ): Record<string, string> {
const variables: Record<string, string> = {}; const variables: Record<string, string> = {};
// First pass: Process variables that don't depend on other variables // First pass: Process some variables that don't depend on other variables
for (const [key, value] of Object.entries(template.variables)) { for (const [key, value] of Object.entries(template.variables)) {
if (typeof value !== "string") continue; if (typeof value !== "string") continue;
@@ -161,6 +204,8 @@ export function processVariables(
const match = value.match(/\${password:(\d+)}/); const match = value.match(/\${password:(\d+)}/);
const length = match?.[1] ? Number.parseInt(match[1], 10) : 16; const length = match?.[1] ? Number.parseInt(match[1], 10) : 16;
variables[key] = generatePassword(length); variables[key] = generatePassword(length);
} else if (value === "${hash}") {
variables[key] = generateHash();
} else if (value.startsWith("${hash:")) { } else if (value.startsWith("${hash:")) {
const match = value.match(/\${hash:(\d+)}/); const match = value.match(/\${hash:(\d+)}/);
const length = match?.[1] ? Number.parseInt(match[1], 10) : 8; const length = match?.[1] ? Number.parseInt(match[1], 10) : 8;

View File

@@ -106,8 +106,8 @@ export const keepLatestNBackups = async (
backup.prefix, backup.prefix,
); );
// --include "*.sql.gz" ensures nothing else other than the db backup files are touched by rclone // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*.sql.gz" ${backupFilesPath}`; const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`;
// when we pipe the above command with this one, we only get the list of files we want to delete // when we pipe the above command with this one, we only get the list of files we want to delete
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`; const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
// this command deletes the files // this command deletes the files

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Mariadb } from "@dokploy/server/services/mariadb"; import type { Mariadb } from "@dokploy/server/services/mariadb";
import { findProjectById } from "@dokploy/server/services/project"; import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils"; } from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils"; import { getS3Credentials, normalizeS3Path } from "./utils";
export const runMariadbBackup = async ( export const runMariadbBackup = async (
mariadb: Mariadb, mariadb: Mariadb,
@@ -19,7 +18,7 @@ export const runMariadbBackup = async (
const { prefix, database } = backup; const { prefix, database } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = path.join(prefix, backupFileName); const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try { try {
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Mongo } from "@dokploy/server/services/mongo"; import type { Mongo } from "@dokploy/server/services/mongo";
import { findProjectById } from "@dokploy/server/services/project"; import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils"; } from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils"; import { getS3Credentials, normalizeS3Path } from "./utils";
// mongodb://mongo:Bqh7AQl-PRbnBu@localhost:27017/?tls=false&directConnection=true // mongodb://mongo:Bqh7AQl-PRbnBu@localhost:27017/?tls=false&directConnection=true
export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
@@ -17,7 +16,7 @@ export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => {
const { prefix, database } = backup; const { prefix, database } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.dump.gz`; const backupFileName = `${new Date().toISOString()}.dump.gz`;
const bucketDestination = path.join(prefix, backupFileName); const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try { try {
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { MySql } from "@dokploy/server/services/mysql"; import type { MySql } from "@dokploy/server/services/mysql";
import { findProjectById } from "@dokploy/server/services/project"; import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils"; } from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils"; import { getS3Credentials, normalizeS3Path } from "./utils";
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { appName, databaseRootPassword, projectId, name } = mysql; const { appName, databaseRootPassword, projectId, name } = mysql;
@@ -16,7 +15,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
const { prefix, database } = backup; const { prefix, database } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = path.join(prefix, backupFileName); const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try { try {
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);

View File

@@ -1,4 +1,3 @@
import path from "node:path";
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Postgres } from "@dokploy/server/services/postgres"; import type { Postgres } from "@dokploy/server/services/postgres";
import { findProjectById } from "@dokploy/server/services/project"; import { findProjectById } from "@dokploy/server/services/project";
@@ -8,7 +7,7 @@ import {
} from "../docker/utils"; } from "../docker/utils";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup"; import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync"; import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials } from "./utils"; import { getS3Credentials, normalizeS3Path } from "./utils";
export const runPostgresBackup = async ( export const runPostgresBackup = async (
postgres: Postgres, postgres: Postgres,
@@ -20,7 +19,7 @@ export const runPostgresBackup = async (
const { prefix, database } = backup; const { prefix, database } = backup;
const destination = backup.destination; const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`; const backupFileName = `${new Date().toISOString()}.sql.gz`;
const bucketDestination = path.join(prefix, backupFileName); const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try { try {
const rcloneFlags = getS3Credentials(destination); const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -36,6 +36,13 @@ export const removeScheduleBackup = (backupId: string) => {
currentJob?.cancel(); currentJob?.cancel();
}; };
export const normalizeS3Path = (prefix: string) => {
// Trim whitespace and remove leading/trailing slashes
const normalizedPrefix = prefix.trim().replace(/^\/+|\/+$/g, "");
// Return empty string if prefix is empty, otherwise append trailing slash
return normalizedPrefix ? `${normalizedPrefix}/` : "";
};
export const getS3Credentials = (destination: Destination) => { export const getS3Credentials = (destination: Destination) => {
const { accessKey, secretAccessKey, region, endpoint, provider } = const { accessKey, secretAccessKey, region, endpoint, provider } =
destination; destination;

View File

@@ -1,6 +1,6 @@
import type { BackupSchedule } from "@dokploy/server/services/backup"; import type { BackupSchedule } from "@dokploy/server/services/backup";
import { execAsync } from "../process/execAsync"; import { execAsync } from "../process/execAsync";
import { getS3Credentials } from "./utils"; import { getS3Credentials, normalizeS3Path } from "./utils";
import { findDestinationById } from "@dokploy/server/services/destination"; import { findDestinationById } from "@dokploy/server/services/destination";
import { IS_CLOUD, paths } from "@dokploy/server/constants"; import { IS_CLOUD, paths } from "@dokploy/server/constants";
import { mkdtemp } from "node:fs/promises"; import { mkdtemp } from "node:fs/promises";
@@ -18,18 +18,30 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
const { BASE_PATH } = paths(); const { BASE_PATH } = paths();
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
const backupFileName = `webserver-backup-${timestamp}.zip`; const backupFileName = `webserver-backup-${timestamp}.zip`;
const s3Path = `:s3:${destination.bucket}/${backup.prefix}${backupFileName}`; const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
try { try {
await execAsync(`mkdir -p ${tempDir}/filesystem`); await execAsync(`mkdir -p ${tempDir}/filesystem`);
const postgresCommand = `docker exec $(docker ps --filter "name=dokploy-postgres" -q) pg_dump -v -Fc -U dokploy -d dokploy > ${tempDir}/database.sql`; // First get the container ID
const { stdout: containerId } = await execAsync(
`docker ps --filter "name=dokploy-postgres" --filter "status=running" -q | head -n 1`,
);
if (!containerId) {
throw new Error("PostgreSQL container not found");
}
const postgresContainerId = containerId.trim();
const postgresCommand = `docker exec ${postgresContainerId} pg_dump -v -Fc -U dokploy -d dokploy > '${tempDir}/database.sql'`;
await execAsync(postgresCommand); await execAsync(postgresCommand);
await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`); await execAsync(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
await execAsync( await execAsync(
`cd ${tempDir} && zip -r ${backupFileName} database.sql filesystem/`, // Zip all .sql files since we created more than one
`cd ${tempDir} && zip -r ${backupFileName} *.sql filesystem/ > /dev/null 2>&1`,
); );
const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`; const uploadCommand = `rclone copyto ${rcloneFlags.join(" ")} "${tempDir}/${backupFileName}" "${s3Path}"`;

View File

@@ -84,7 +84,7 @@ export const buildRailpack = async (
for (const envVar of envVariables) { for (const envVar of envVariables) {
const [key, value] = envVar.split("="); const [key, value] = envVar.split("=");
if (key && value) { if (key && value) {
buildArgs.push("--secret", `id=${key},env=${key}`); buildArgs.push("--secret", `id=${key},env='${key}'`);
env[key] = value; env[key] = value;
} }
} }
@@ -132,7 +132,7 @@ export const getRailpackCommand = (
]; ];
for (const env of envVariables) { for (const env of envVariables) {
prepareArgs.push("--env", env); prepareArgs.push("--env", `'${env}'`);
} }
// Calculate secrets hash for layer invalidation // Calculate secrets hash for layer invalidation
@@ -164,7 +164,7 @@ export const getRailpackCommand = (
for (const envVar of envVariables) { for (const envVar of envVariables) {
const [key, value] = envVar.split("="); const [key, value] = envVar.split("=");
if (key && value) { if (key && value) {
buildArgs.push("--secret", `id=${key},env=${key}`); buildArgs.push("--secret", `id=${key},env='${key}'`);
exportEnvs.push(`export ${key}=${value}`); exportEnvs.push(`export ${key}=${value}`);
} }
} }

View File

@@ -25,6 +25,12 @@ export const buildStatic = async (
].join("\n"), ].join("\n"),
); );
createFile(
buildAppDirectory,
".dockerignore",
[".git", ".env", "Dockerfile", ".dockerignore"].join("\n"),
);
await buildCustomDocker( await buildCustomDocker(
{ {
...application, ...application,

View File

@@ -249,6 +249,11 @@ export const addDomainToCompose = async (
labels.unshift("traefik.enable=true"); labels.unshift("traefik.enable=true");
} }
labels.unshift(...httpLabels); labels.unshift(...httpLabels);
if (!compose.isolatedDeployment) {
if (!labels.includes("traefik.docker.network=dokploy-network")) {
labels.unshift("traefik.docker.network=dokploy-network");
}
}
} }
if (!compose.isolatedDeployment) { if (!compose.isolatedDeployment) {

View File

@@ -40,68 +40,84 @@ export const sendDokployRestartNotifications = async () => {
const decorate = (decoration: string, text: string) => const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim(); `${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, { try {
title: decorate(">", "`✅` Dokploy Server Restarted"), await sendDiscordNotification(discord, {
color: 0x57f287, title: decorate(">", "`✅` Dokploy Server Restarted"),
fields: [ color: 0x57f287,
{ fields: [
name: decorate("`📅`", "Date"), {
value: `<t:${unixDate}:D>`, name: decorate("`📅`", "Date"),
inline: true, value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Restart Notification",
}, },
{ });
name: decorate("`⌚`", "Time"), } catch (error) {
value: `<t:${unixDate}:t>`, console.log(error);
inline: true, }
},
{
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
],
timestamp: date.toISOString(),
footer: {
text: "Dokploy Restart Notification",
},
});
} }
if (gotify) { if (gotify) {
const decorate = (decoration: string, text: string) => const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`; `${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification( try {
gotify, await sendGotifyNotification(
decorate("✅", "Dokploy Server Restarted"), gotify,
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`, decorate("", "Dokploy Server Restarted"),
); `${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
);
} catch (error) {
console.log(error);
}
} }
if (telegram) { if (telegram) {
await sendTelegramNotification( try {
telegram, await sendTelegramNotification(
`<b>✅ Dokploy Server Restarted</b>\n\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`, telegram,
); `<b>✅ Dokploy Server Restarted</b>\n\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
);
} catch (error) {
console.log(error);
}
} }
if (slack) { if (slack) {
const { channel } = slack; const { channel } = slack;
await sendSlackNotification(slack, { try {
channel: channel, await sendSlackNotification(slack, {
attachments: [ channel: channel,
{ attachments: [
color: "#00FF00", {
pretext: ":white_check_mark: *Dokploy Server Restarted*", color: "#00FF00",
fields: [ pretext: ":white_check_mark: *Dokploy Server Restarted*",
{ fields: [
title: "Time", {
value: date.toLocaleString(), title: "Time",
short: true, value: date.toLocaleString(),
}, short: true,
], },
}, ],
], },
}); ],
});
} catch (error) {
console.log(error);
}
} }
} }
}; };

View File

@@ -1,9 +1,48 @@
import { exec } from "node:child_process"; import { exec, execFile } from "node:child_process";
import util from "node:util"; import util from "node:util";
import { findServerById } from "@dokploy/server/services/server"; import { findServerById } from "@dokploy/server/services/server";
import { Client } from "ssh2"; import { Client } from "ssh2";
export const execAsync = util.promisify(exec); export const execAsync = util.promisify(exec);
export const execFileAsync = async (
command: string,
args: string[],
options: { input?: string } = {},
): Promise<{ stdout: string; stderr: string }> => {
const child = execFile(command, args);
if (options.input && child.stdin) {
child.stdin.write(options.input);
child.stdin.end();
}
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(
new Error(`Command failed with code ${code}. Stderr: ${stderr}`),
);
}
});
child.on("error", reject);
});
};
export const execAsyncRemote = async ( export const execAsyncRemote = async (
serverId: string | null, serverId: string | null,
command: string, command: string,

View File

@@ -37,6 +37,7 @@ export const cloneBitbucketRepository = async (
bitbucketBranch, bitbucketBranch,
bitbucketId, bitbucketId,
bitbucket, bitbucket,
enableSubmodules,
} = entity; } = entity;
if (!bitbucketId) { if (!bitbucketId) {
@@ -53,25 +54,23 @@ export const cloneBitbucketRepository = async (
const cloneUrl = `https://${bitbucket?.bitbucketUsername}:${bitbucket?.appPassword}@${repoclone}`; const cloneUrl = `https://${bitbucket?.bitbucketUsername}:${bitbucket?.appPassword}@${repoclone}`;
try { try {
writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`);
await spawnAsync( const cloneArgs = [
"git", "clone",
[ "--branch",
"clone", bitbucketBranch!,
"--branch", "--depth",
bitbucketBranch!, "1",
"--depth", ...(enableSubmodules ? ["--recurse-submodules"] : []),
"1", cloneUrl,
"--recurse-submodules", outputPath,
cloneUrl, "--progress",
outputPath, ];
"--progress",
], await spawnAsync("git", cloneArgs, (data) => {
(data) => { if (writeStream.writable) {
if (writeStream.writable) { writeStream.write(data);
writeStream.write(data); }
} });
},
);
writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Clonning: ${error}: ❌`);
@@ -89,6 +88,7 @@ export const cloneRawBitbucketRepository = async (entity: Compose) => {
bitbucketOwner, bitbucketOwner,
bitbucketBranch, bitbucketBranch,
bitbucketId, bitbucketId,
enableSubmodules,
} = entity; } = entity;
if (!bitbucketId) { if (!bitbucketId) {
@@ -106,17 +106,19 @@ export const cloneRawBitbucketRepository = async (entity: Compose) => {
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`; const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
try { try {
await spawnAsync("git", [ const cloneArgs = [
"clone", "clone",
"--branch", "--branch",
bitbucketBranch!, bitbucketBranch!,
"--depth", "--depth",
"1", "1",
"--recurse-submodules", ...(enableSubmodules ? ["--recurse-submodules"] : []),
cloneUrl, cloneUrl,
outputPath, outputPath,
"--progress", "--progress",
]); ];
await spawnAsync("git", cloneArgs);
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -131,6 +133,7 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => {
bitbucketBranch, bitbucketBranch,
bitbucketId, bitbucketId,
serverId, serverId,
enableSubmodules,
} = compose; } = compose;
if (!serverId) { if (!serverId) {
@@ -153,11 +156,11 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => {
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`; const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
try { try {
const command = ` const cloneCommand = `
rm -rf ${outputPath}; rm -rf ${outputPath};
git clone --branch ${bitbucketBranch} --depth 1 --recurse-submodules ${cloneUrl} ${outputPath} git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath}
`; `;
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, cloneCommand);
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -176,6 +179,7 @@ export const getBitbucketCloneCommand = async (
bitbucketBranch, bitbucketBranch,
bitbucketId, bitbucketId,
serverId, serverId,
enableSubmodules,
} = entity; } = entity;
if (!serverId) { if (!serverId) {
@@ -207,7 +211,7 @@ export const getBitbucketCloneCommand = async (
const cloneCommand = ` const cloneCommand = `
rm -rf ${outputPath}; rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
if ! git clone --branch ${bitbucketBranch} --depth 1 --recurse-submodules --progress ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then if ! git clone --branch ${bitbucketBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Fail to clone the repository ${repoclone}" >> ${logPath}; echo "❌ [ERROR] Fail to clone the repository ${repoclone}" >> ${logPath};
exit 1; exit 1;
fi fi

View File

@@ -17,12 +17,19 @@ export const cloneGitRepository = async (
customGitUrl?: string | null; customGitUrl?: string | null;
customGitBranch?: string | null; customGitBranch?: string | null;
customGitSSHKeyId?: string | null; customGitSSHKeyId?: string | null;
enableSubmodules?: boolean;
}, },
logPath: string, logPath: string,
isCompose = false, isCompose = false,
) => { ) => {
const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths(); const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths();
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity; const {
appName,
customGitUrl,
customGitBranch,
customGitSSHKeyId,
enableSubmodules,
} = entity;
if (!customGitUrl || !customGitBranch) { if (!customGitUrl || !customGitBranch) {
throw new TRPCError({ throw new TRPCError({
@@ -70,19 +77,21 @@ export const cloneGitRepository = async (
} }
const { port } = sanitizeRepoPathSSH(customGitUrl); const { port } = sanitizeRepoPathSSH(customGitUrl);
const cloneArgs = [
"clone",
"--branch",
customGitBranch,
"--depth",
"1",
...(enableSubmodules ? ["--recurse-submodules"] : []),
customGitUrl,
outputPath,
"--progress",
];
await spawnAsync( await spawnAsync(
"git", "git",
[ cloneArgs,
"clone",
"--branch",
customGitBranch,
"--depth",
"1",
"--recurse-submodules",
customGitUrl,
outputPath,
"--progress",
],
(data) => { (data) => {
if (writeStream.writable) { if (writeStream.writable) {
writeStream.write(data); writeStream.write(data);
@@ -114,6 +123,7 @@ export const getCustomGitCloneCommand = async (
customGitBranch?: string | null; customGitBranch?: string | null;
customGitSSHKeyId?: string | null; customGitSSHKeyId?: string | null;
serverId: string | null; serverId: string | null;
enableSubmodules: boolean;
}, },
logPath: string, logPath: string,
isCompose = false, isCompose = false,
@@ -125,6 +135,7 @@ export const getCustomGitCloneCommand = async (
customGitBranch, customGitBranch,
customGitSSHKeyId, customGitSSHKeyId,
serverId, serverId,
enableSubmodules,
} = entity; } = entity;
if (!customGitUrl || !customGitBranch) { if (!customGitUrl || !customGitBranch) {
@@ -181,7 +192,7 @@ export const getCustomGitCloneCommand = async (
} }
command.push( command.push(
`if ! git clone --branch ${customGitBranch} --depth 1 --recurse-submodules --progress ${customGitUrl} ${outputPath} >> ${logPath} 2>&1; then `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}" >> ${logPath}; echo "❌ [ERROR] Fail to clone the repository ${customGitUrl}" >> ${logPath};
exit 1; exit 1;
fi fi
@@ -261,8 +272,15 @@ export const cloneGitRawRepository = async (entity: {
customGitUrl?: string | null; customGitUrl?: string | null;
customGitBranch?: string | null; customGitBranch?: string | null;
customGitSSHKeyId?: string | null; customGitSSHKeyId?: string | null;
enableSubmodules?: boolean;
}) => { }) => {
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity; const {
appName,
customGitUrl,
customGitBranch,
customGitSSHKeyId,
enableSubmodules,
} = entity;
if (!customGitUrl || !customGitBranch) { if (!customGitUrl || !customGitBranch) {
throw new TRPCError({ throw new TRPCError({
@@ -307,29 +325,26 @@ export const cloneGitRawRepository = async (entity: {
} }
const { port } = sanitizeRepoPathSSH(customGitUrl); const { port } = sanitizeRepoPathSSH(customGitUrl);
await spawnAsync( const cloneArgs = [
"git", "clone",
[ "--branch",
"clone", customGitBranch,
"--branch", "--depth",
customGitBranch, "1",
"--depth", ...(enableSubmodules ? ["--recurse-submodules"] : []),
"1", customGitUrl,
"--recurse-submodules", outputPath,
customGitUrl, "--progress",
outputPath, ];
"--progress",
], await spawnAsync("git", cloneArgs, (_data) => {}, {
(_data) => {}, env: {
{ ...process.env,
env: { ...(customGitSSHKeyId && {
...process.env, GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath}${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`,
...(customGitSSHKeyId && { }),
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath}${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`,
}),
},
}, },
); });
} catch (error) { } catch (error) {
throw error; throw error;
} }
@@ -342,6 +357,7 @@ export const cloneRawGitRepositoryRemote = async (compose: Compose) => {
customGitUrl, customGitUrl,
customGitSSHKeyId, customGitSSHKeyId,
serverId, serverId,
enableSubmodules,
} = compose; } = compose;
if (!serverId) { if (!serverId) {
@@ -396,7 +412,7 @@ export const cloneRawGitRepositoryRemote = async (compose: Compose) => {
} }
command.push( command.push(
`if ! git clone --branch ${customGitBranch} --depth 1 --recurse-submodules --progress ${customGitUrl} ${outputPath} ; then `if ! git clone --branch ${customGitBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${customGitUrl} ${outputPath} ; then
echo "[ERROR] Fail to clone the repository "; echo "[ERROR] Fail to clone the repository ";
exit 1; exit 1;
fi fi

View File

@@ -119,6 +119,7 @@ export const getGiteaCloneCommand = async (
giteaRepository, giteaRepository,
serverId, serverId,
gitea, gitea,
enableSubmodules,
} = entity; } = entity;
if (!serverId) { if (!serverId) {
@@ -155,7 +156,7 @@ export const getGiteaCloneCommand = async (
rm -rf ${outputPath}; rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
if ! git clone --branch ${giteaBranch} --depth 1 --recurse-submodules ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then if ! git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Failed to clone the repository ${repoClone}" >> ${logPath}; echo "❌ [ERROR] Failed to clone the repository ${repoClone}" >> ${logPath};
exit 1; exit 1;
fi fi
@@ -174,7 +175,14 @@ export const cloneGiteaRepository = async (
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(); const { APPLICATIONS_PATH, COMPOSE_PATH } = paths();
const writeStream = createWriteStream(logPath, { flags: "a" }); const writeStream = createWriteStream(logPath, { flags: "a" });
const { appName, giteaBranch, giteaId, giteaOwner, giteaRepository } = entity; const {
appName,
giteaBranch,
giteaId,
giteaOwner,
giteaRepository,
enableSubmodules,
} = entity;
if (!giteaId) { if (!giteaId) {
throw new TRPCError({ throw new TRPCError({
@@ -211,7 +219,7 @@ export const cloneGiteaRepository = async (
giteaBranch!, giteaBranch!,
"--depth", "--depth",
"1", "1",
"--recurse-submodules", ...(enableSubmodules ? ["--recurse-submodules"] : []),
cloneUrl, cloneUrl,
outputPath, outputPath,
"--progress", "--progress",
@@ -232,7 +240,14 @@ export const cloneGiteaRepository = async (
}; };
export const cloneRawGiteaRepository = async (entity: Compose) => { export const cloneRawGiteaRepository = async (entity: Compose) => {
const { appName, giteaRepository, giteaOwner, giteaBranch, giteaId } = entity; const {
appName,
giteaRepository,
giteaOwner,
giteaBranch,
giteaId,
enableSubmodules,
} = entity;
const { COMPOSE_PATH } = paths(); const { COMPOSE_PATH } = paths();
if (!giteaId) { if (!giteaId) {
@@ -265,7 +280,7 @@ export const cloneRawGiteaRepository = async (entity: Compose) => {
giteaBranch!, giteaBranch!,
"--depth", "--depth",
"1", "1",
"--recurse-submodules", ...(enableSubmodules ? ["--recurse-submodules"] : []),
cloneUrl, cloneUrl,
outputPath, outputPath,
"--progress", "--progress",
@@ -283,6 +298,7 @@ export const cloneRawGiteaRepositoryRemote = async (compose: Compose) => {
giteaBranch, giteaBranch,
giteaId, giteaId,
serverId, serverId,
enableSubmodules,
} = compose; } = compose;
if (!serverId) { if (!serverId) {
@@ -307,7 +323,7 @@ export const cloneRawGiteaRepositoryRemote = async (compose: Compose) => {
try { try {
const command = ` const command = `
rm -rf ${outputPath}; rm -rf ${outputPath};
git clone --branch ${giteaBranch} --depth 1 ${cloneUrl} ${outputPath} git clone --branch ${giteaBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath}
`; `;
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} catch (error) { } catch (error) {

View File

@@ -83,6 +83,7 @@ interface CloneGithubRepository {
repository: string | null; repository: string | null;
logPath: string; logPath: string;
type?: "application" | "compose"; type?: "application" | "compose";
enableSubmodules: boolean;
} }
export const cloneGithubRepository = async ({ export const cloneGithubRepository = async ({
logPath, logPath,
@@ -92,7 +93,8 @@ export const cloneGithubRepository = async ({
const isCompose = type === "compose"; const isCompose = type === "compose";
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths(); const { APPLICATIONS_PATH, COMPOSE_PATH } = paths();
const writeStream = createWriteStream(logPath, { flags: "a" }); const writeStream = createWriteStream(logPath, { flags: "a" });
const { appName, repository, owner, branch, githubId } = entity; const { appName, repository, owner, branch, githubId, enableSubmodules } =
entity;
if (!githubId) { if (!githubId) {
throw new TRPCError({ throw new TRPCError({
@@ -128,25 +130,23 @@ export const cloneGithubRepository = async ({
try { try {
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`);
await spawnAsync( const cloneArgs = [
"git", "clone",
[ "--branch",
"clone", branch!,
"--branch", "--depth",
branch!, "1",
"--depth", ...(enableSubmodules ? ["--recurse-submodules"] : []),
"1", cloneUrl,
"--recurse-submodules", outputPath,
cloneUrl, "--progress",
outputPath, ];
"--progress",
], await spawnAsync("git", cloneArgs, (data) => {
(data) => { if (writeStream.writable) {
if (writeStream.writable) { writeStream.write(data);
writeStream.write(data); }
} });
},
);
writeStream.write(`\nCloned ${repoclone}: ✅\n`); writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Clonning: ${error}: ❌`);
@@ -161,7 +161,15 @@ export const getGithubCloneCommand = async ({
type = "application", type = "application",
...entity ...entity
}: CloneGithubRepository & { serverId: string }) => { }: CloneGithubRepository & { serverId: string }) => {
const { appName, repository, owner, branch, githubId, serverId } = entity; const {
appName,
repository,
owner,
branch,
githubId,
serverId,
enableSubmodules,
} = entity;
const isCompose = type === "compose"; const isCompose = type === "compose";
if (!serverId) { if (!serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -216,7 +224,7 @@ export const getGithubCloneCommand = async ({
const cloneCommand = ` const cloneCommand = `
rm -rf ${outputPath}; rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
if ! git clone --branch ${branch} --depth 1 --recurse-submodules --progress ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then if ! git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Fail to clone repository ${repoclone}" >> ${logPath}; echo "❌ [ERROR] Fail to clone repository ${repoclone}" >> ${logPath};
exit 1; exit 1;
fi fi
@@ -227,7 +235,8 @@ echo "Cloned ${repoclone} to ${outputPath}: ✅" >> ${logPath};
}; };
export const cloneRawGithubRepository = async (entity: Compose) => { export const cloneRawGithubRepository = async (entity: Compose) => {
const { appName, repository, owner, branch, githubId } = entity; const { appName, repository, owner, branch, githubId, enableSubmodules } =
entity;
if (!githubId) { if (!githubId) {
throw new TRPCError({ throw new TRPCError({
@@ -245,24 +254,33 @@ export const cloneRawGithubRepository = async (entity: Compose) => {
await recreateDirectory(outputPath); await recreateDirectory(outputPath);
const cloneUrl = `https://oauth2:${token}@${repoclone}`; const cloneUrl = `https://oauth2:${token}@${repoclone}`;
try { try {
await spawnAsync("git", [ const cloneArgs = [
"clone", "clone",
"--branch", "--branch",
branch!, branch!,
"--depth", "--depth",
"1", "1",
"--recurse-submodules", ...(enableSubmodules ? ["--recurse-submodules"] : []),
cloneUrl, cloneUrl,
outputPath, outputPath,
"--progress", "--progress",
]); ];
await spawnAsync("git", cloneArgs);
} catch (error) { } catch (error) {
throw error; throw error;
} }
}; };
export const cloneRawGithubRepositoryRemote = async (compose: Compose) => { export const cloneRawGithubRepositoryRemote = async (compose: Compose) => {
const { appName, repository, owner, branch, githubId, serverId } = compose; const {
appName,
repository,
owner,
branch,
githubId,
serverId,
enableSubmodules,
} = compose;
if (!serverId) { if (!serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -288,7 +306,7 @@ export const cloneRawGithubRepositoryRemote = async (compose: Compose) => {
try { try {
const command = ` const command = `
rm -rf ${outputPath}; rm -rf ${outputPath};
git clone --branch ${branch} --depth 1 ${cloneUrl} ${outputPath} git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath}
`; `;
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} catch (error) { } catch (error) {

View File

@@ -90,8 +90,14 @@ export const cloneGitlabRepository = async (
isCompose = false, isCompose = false,
) => { ) => {
const writeStream = createWriteStream(logPath, { flags: "a" }); const writeStream = createWriteStream(logPath, { flags: "a" });
const { appName, gitlabBranch, gitlabId, gitlab, gitlabPathNamespace } = const {
entity; appName,
gitlabBranch,
gitlabId,
gitlab,
gitlabPathNamespace,
enableSubmodules,
} = entity;
if (!gitlabId) { if (!gitlabId) {
throw new TRPCError({ throw new TRPCError({
@@ -127,25 +133,23 @@ export const cloneGitlabRepository = async (
try { try {
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`); writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`);
await spawnAsync( const cloneArgs = [
"git", "clone",
[ "--branch",
"clone", gitlabBranch!,
"--branch", "--depth",
gitlabBranch!, "1",
"--depth", ...(enableSubmodules ? ["--recurse-submodules"] : []),
"1", cloneUrl,
"--recurse-submodules", outputPath,
cloneUrl, "--progress",
outputPath, ];
"--progress",
], await spawnAsync("git", cloneArgs, (data) => {
(data) => { if (writeStream.writable) {
if (writeStream.writable) { writeStream.write(data);
writeStream.write(data); }
} });
},
);
writeStream.write(`\nCloned ${repoclone}: ✅\n`); writeStream.write(`\nCloned ${repoclone}: ✅\n`);
} catch (error) { } catch (error) {
writeStream.write(`ERROR Clonning: ${error}: ❌`); writeStream.write(`ERROR Clonning: ${error}: ❌`);
@@ -167,6 +171,7 @@ export const getGitlabCloneCommand = async (
gitlabId, gitlabId,
serverId, serverId,
gitlab, gitlab,
enableSubmodules,
} = entity; } = entity;
if (!serverId) { if (!serverId) {
@@ -222,7 +227,7 @@ export const getGitlabCloneCommand = async (
const cloneCommand = ` const cloneCommand = `
rm -rf ${outputPath}; rm -rf ${outputPath};
mkdir -p ${outputPath}; mkdir -p ${outputPath};
if ! git clone --branch ${gitlabBranch} --depth 1 --recurse-submodules --progress ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then if ! git clone --branch ${gitlabBranch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} --progress ${cloneUrl} ${outputPath} >> ${logPath} 2>&1; then
echo "❌ [ERROR] Fail to clone the repository ${repoclone}" >> ${logPath}; echo "❌ [ERROR] Fail to clone the repository ${repoclone}" >> ${logPath};
exit 1; exit 1;
fi fi
@@ -264,7 +269,11 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
const groupName = gitlabProvider.groupName?.toLowerCase(); const groupName = gitlabProvider.groupName?.toLowerCase();
if (groupName) { if (groupName) {
return full_path.toLowerCase().includes(groupName) && kind === "group"; const isIncluded = groupName
.split(",")
.some((name) => full_path.toLowerCase().includes(name));
return isIncluded && kind === "group";
} }
return kind === "user"; return kind === "user";
}); });
@@ -326,7 +335,13 @@ export const getGitlabBranches = async (input: {
}; };
export const cloneRawGitlabRepository = async (entity: Compose) => { export const cloneRawGitlabRepository = async (entity: Compose) => {
const { appName, gitlabBranch, gitlabId, gitlabPathNamespace } = entity; const {
appName,
gitlabBranch,
gitlabId,
gitlabPathNamespace,
enableSubmodules,
} = entity;
if (!gitlabId) { if (!gitlabId) {
throw new TRPCError({ throw new TRPCError({
@@ -347,24 +362,32 @@ export const cloneRawGitlabRepository = async (entity: Compose) => {
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`; const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
try { try {
await spawnAsync("git", [ const cloneArgs = [
"clone", "clone",
"--branch", "--branch",
gitlabBranch!, gitlabBranch!,
"--depth", "--depth",
"1", "1",
"--recurse-submodules", ...(enableSubmodules ? ["--recurse-submodules"] : []),
cloneUrl, cloneUrl,
outputPath, outputPath,
"--progress", "--progress",
]); ];
await spawnAsync("git", cloneArgs);
} catch (error) { } catch (error) {
throw error; throw error;
} }
}; };
export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => { export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => {
const { appName, gitlabPathNamespace, branch, gitlabId, serverId } = compose; const {
appName,
gitlabPathNamespace,
branch,
gitlabId,
serverId,
enableSubmodules,
} = compose;
if (!serverId) { if (!serverId) {
throw new TRPCError({ throw new TRPCError({
@@ -388,7 +411,7 @@ export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => {
try { try {
const command = ` const command = `
rm -rf ${outputPath}; rm -rf ${outputPath};
git clone --branch ${branch} --depth 1 --recurse-submodules ${cloneUrl} ${outputPath} git clone --branch ${branch} --depth 1 ${enableSubmodules ? "--recurse-submodules" : ""} ${cloneUrl} ${outputPath}
`; `;
await execAsyncRemote(serverId, command); await execAsyncRemote(serverId, command);
} catch (error) { } catch (error) {
@@ -431,7 +454,9 @@ export const testGitlabConnection = async (
const { full_path, kind } = repo.namespace; const { full_path, kind } = repo.namespace;
if (groupName) { if (groupName) {
return full_path.toLowerCase().includes(groupName) && kind === "group"; return groupName
.split(",")
.some((name) => full_path.toLowerCase().includes(name));
} }
return kind === "user"; return kind === "user";
}); });

View File

@@ -45,7 +45,7 @@ export const restoreWebServerBackup = async (
// Extract backup // Extract backup
emit("Extracting backup..."); emit("Extracting backup...");
await execAsync(`cd ${tempDir} && unzip ${backupFile}`); await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
// Restore filesystem first // Restore filesystem first
emit("Restoring filesystem..."); emit("Restoring filesystem...");
@@ -83,44 +83,54 @@ export const restoreWebServerBackup = async (
throw new Error("Database file not found after extraction"); throw new Error("Database file not found after extraction");
} }
const { stdout: postgresContainer } = await execAsync(
`docker ps --filter "name=dokploy-postgres" --filter "status=running" -q | head -n 1`,
);
if (!postgresContainer) {
throw new Error("Dokploy Postgres container not found");
}
const postgresContainerId = postgresContainer.trim();
// Drop and recreate database // Drop and recreate database
emit("Disconnecting all users from database..."); emit("Disconnecting all users from database...");
await execAsync( await execAsync(
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) psql -U dokploy postgres -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'dokploy' AND pid <> pg_backend_pid();"`, `docker exec ${postgresContainerId} psql -U dokploy postgres -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = 'dokploy' AND pid <> pg_backend_pid();"`,
); );
emit("Dropping existing database..."); emit("Dropping existing database...");
await execAsync( await execAsync(
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) psql -U dokploy postgres -c "DROP DATABASE IF EXISTS dokploy;"`, `docker exec ${postgresContainerId} psql -U dokploy postgres -c "DROP DATABASE IF EXISTS dokploy;"`,
); );
emit("Creating fresh database..."); emit("Creating fresh database...");
await execAsync( await execAsync(
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) psql -U dokploy postgres -c "CREATE DATABASE dokploy;"`, `docker exec ${postgresContainerId} psql -U dokploy postgres -c "CREATE DATABASE dokploy;"`,
); );
// Copy the backup file into the container // Copy the backup file into the container
emit("Copying backup file into container..."); emit("Copying backup file into container...");
await execAsync( await execAsync(
`docker cp ${tempDir}/database.sql $(docker ps --filter "name=dokploy-postgres" -q):/tmp/database.sql`, `docker cp ${tempDir}/database.sql ${postgresContainerId}:/tmp/database.sql`,
); );
// Verify file in container // Verify file in container
emit("Verifying file in container..."); emit("Verifying file in container...");
await execAsync( await execAsync(
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) ls -l /tmp/database.sql`, `docker exec ${postgresContainerId} ls -l /tmp/database.sql`,
); );
// Restore from the copied file // Restore from the copied file
emit("Running database restore..."); emit("Running database restore...");
await execAsync( await execAsync(
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) pg_restore -v -U dokploy -d dokploy /tmp/database.sql`, `docker exec ${postgresContainerId} pg_restore -v -U dokploy -d dokploy /tmp/database.sql`,
); );
// Cleanup the temporary file in the container // Cleanup the temporary file in the container
emit("Cleaning up container temp file..."); emit("Cleaning up container temp file...");
await execAsync( await execAsync(
`docker exec $(docker ps --filter "name=dokploy-postgres" -q) rm /tmp/database.sql`, `docker exec ${postgresContainerId} rm /tmp/database.sql`,
); );
emit("Restore completed successfully!"); emit("Restore completed successfully!");

Some files were not shown because too many files have changed in this diff Show More