mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay
This commit is contained in:
@@ -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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
```bash
|
||||
@@ -145,11 +147,9 @@ curl -sSL https://railpack.com/install.sh | sh
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
- 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.
|
||||
|
||||
|
||||
### Recommendations
|
||||
|
||||
- Use the same name of the folder as the id of the template.
|
||||
|
||||
@@ -49,7 +49,7 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
|
||||
# Install Nixpacks and tsx
|
||||
# | 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 \
|
||||
&& chmod +x install.sh \
|
||||
&& ./install.sh \
|
||||
|
||||
@@ -34,7 +34,9 @@ const baseApp: ApplicationNested = {
|
||||
giteaRepository: "",
|
||||
cleanCache: false,
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
applicationStatus: "done",
|
||||
triggerType: "push",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
serverId: "",
|
||||
|
||||
@@ -51,6 +51,35 @@ describe("processTemplate", () => {
|
||||
expect(result.domains).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", () => {
|
||||
|
||||
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal file
232
apps/dokploy/__test__/templates/helpers.template.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { beforeEach, expect, test, vi } from "vitest";
|
||||
|
||||
const baseAdmin: User = {
|
||||
https: false,
|
||||
enablePaidFeatures: false,
|
||||
metricsConfig: {
|
||||
containers: {
|
||||
@@ -73,7 +74,6 @@ beforeEach(() => {
|
||||
|
||||
test("Should read the configuration file", () => {
|
||||
const config: FileConfig = loadOrCreateConfig("dokploy");
|
||||
|
||||
expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe(
|
||||
"dokploy-service-app",
|
||||
);
|
||||
@@ -83,6 +83,7 @@ test("Should apply redirect-to-https", () => {
|
||||
updateServerTraefik(
|
||||
{
|
||||
...baseAdmin,
|
||||
https: true,
|
||||
certificateType: "letsencrypt",
|
||||
},
|
||||
"example.com",
|
||||
|
||||
@@ -16,6 +16,7 @@ const baseApp: ApplicationNested = {
|
||||
applicationStatus: "done",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
enableSubmodules: false,
|
||||
serverId: "",
|
||||
branch: null,
|
||||
dockerBuildStage: "",
|
||||
@@ -24,6 +25,7 @@ const baseApp: ApplicationNested = {
|
||||
buildArgs: null,
|
||||
isPreviewDeploymentsActive: false,
|
||||
previewBuildArgs: null,
|
||||
triggerType: "push",
|
||||
previewCertificateType: "none",
|
||||
previewEnv: null,
|
||||
previewHttps: false,
|
||||
|
||||
61
apps/dokploy/__test__/utils/backups.test.ts
Normal file
61
apps/dokploy/__test__/utils/backups.test.ts
Normal 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/");
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(BitbucketProviderSchema),
|
||||
});
|
||||
@@ -130,6 +133,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.bitbucketBuildPath || "/",
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -143,6 +147,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
bitbucketId: data.bitbucketId,
|
||||
applicationId,
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -467,6 +472,21 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
|
||||
</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 className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
|
||||
@@ -44,6 +45,7 @@ const GitProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -67,6 +69,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
repositoryURL: "",
|
||||
sshKey: undefined,
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -79,6 +82,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.customGitBuildPath || "/",
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -91,6 +95,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
customGitSSHKeyId: values.sshKey === "none" ? null : values.sshKey,
|
||||
applicationId,
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
@@ -294,6 +299,22 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
|
||||
</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 className="flex flex-row justify-end">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -74,6 +75,7 @@ const GiteaProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||
watchPaths: z.array(z.string()).default([]),
|
||||
enableSubmodules: z.boolean().optional(),
|
||||
});
|
||||
|
||||
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||
@@ -99,6 +101,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
giteaId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GiteaProviderSchema),
|
||||
});
|
||||
@@ -152,6 +155,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.giteaBuildPath || "/",
|
||||
giteaId: data.giteaId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -165,6 +169,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
giteaId: data.giteaId,
|
||||
applicationId,
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules || false,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
@@ -498,6 +503,21 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
|
||||
</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 className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
@@ -81,12 +84,15 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
githubId: "",
|
||||
branch: "",
|
||||
triggerType: "push",
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GithubProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
const githubId = form.watch("githubId");
|
||||
const triggerType = form.watch("triggerType");
|
||||
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
@@ -124,6 +130,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.buildPath || "/",
|
||||
githubId: data.githubId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
triggerType: data.triggerType || "push",
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -137,6 +145,8 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.buildPath,
|
||||
githubId: data.githubId,
|
||||
watchPaths: data.watchPaths || [],
|
||||
triggerType: data.triggerType,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -381,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
name="triggerType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<div className="flex items-center gap-2 ">
|
||||
<FormLabel>Trigger Type</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -393,68 +403,127 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Add paths to watch for changes. When files in these
|
||||
paths change, a new deployment will be triggered.
|
||||
Choose when to trigger deployments: on push to the
|
||||
selected branch or when a new tag is created.
|
||||
</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);
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a trigger type" />
|
||||
</SelectTrigger>
|
||||
</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 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>
|
||||
))}
|
||||
</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 = "";
|
||||
}
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder*="Enter a path"]',
|
||||
) as HTMLInputElement;
|
||||
const path = input.value.trim();
|
||||
if (path) {
|
||||
field.onChange([...(field.value || []), path]);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||
@@ -86,6 +88,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
},
|
||||
gitlabId: "",
|
||||
branch: "",
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitlabProviderSchema),
|
||||
});
|
||||
@@ -135,6 +138,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
buildPath: data.gitlabBuildPath || "/",
|
||||
gitlabId: data.gitlabId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -150,6 +154,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
gitlabProjectId: data.repository.id,
|
||||
gitlabPathNamespace: data.repository.gitlabPathNamespace,
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -483,6 +488,21 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
|
||||
</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 className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -65,7 +65,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
|
||||
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">
|
||||
<TabsTrigger
|
||||
value="github"
|
||||
|
||||
@@ -298,7 +298,11 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
|
||||
})
|
||||
.then(() => {
|
||||
refetch();
|
||||
toast.success("Preview deployments enabled");
|
||||
toast.success(
|
||||
checked
|
||||
? "Preview deployments enabled"
|
||||
: "Preview deployments disabled",
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -79,6 +79,22 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
|
||||
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 (
|
||||
<>
|
||||
<div className="w-full flex flex-col gap-4 ">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -58,6 +59,7 @@ const BitbucketProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type BitbucketProvider = z.infer<typeof BitbucketProviderSchema>;
|
||||
@@ -84,6 +86,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
bitbucketId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(BitbucketProviderSchema),
|
||||
});
|
||||
@@ -130,6 +133,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
bitbucketId: data.bitbucketId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -145,6 +149,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "bitbucket",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -469,6 +474,21 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
|
||||
</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 className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -43,6 +44,7 @@ const GitProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch required"),
|
||||
sshKey: z.string().optional(),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitProvider = z.infer<typeof GitProviderSchema>;
|
||||
@@ -65,6 +67,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: "./docker-compose.yml",
|
||||
sshKey: undefined,
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitProviderSchema),
|
||||
});
|
||||
@@ -77,6 +80,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
repositoryURL: data.customGitUrl || "",
|
||||
composePath: data.composePath,
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -91,6 +95,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: values.composePath,
|
||||
composeStatus: "idle",
|
||||
watchPaths: values.watchPaths || [],
|
||||
enableSubmodules: values.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Git Provider Saved");
|
||||
@@ -295,6 +300,21 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
|
||||
</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 className="flex flex-row justify-end">
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -59,6 +60,7 @@ const GiteaProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
giteaId: z.string().min(1, "Gitea Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GiteaProvider = z.infer<typeof GiteaProviderSchema>;
|
||||
@@ -83,6 +85,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
giteaId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GiteaProviderSchema),
|
||||
});
|
||||
@@ -136,6 +139,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath || "./docker-compose.yml",
|
||||
giteaId: data.giteaId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -151,6 +155,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "gitea",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
} as any)
|
||||
.then(async () => {
|
||||
toast.success("Service Provider Saved");
|
||||
@@ -469,6 +474,21 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
|
||||
</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 className="flex justify-end">
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
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 { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -57,6 +58,8 @@ const GithubProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
githubId: z.string().min(1, "Github Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GithubProvider = z.infer<typeof GithubProviderSchema>;
|
||||
@@ -82,13 +85,15 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
githubId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
triggerType: "push",
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GithubProviderSchema),
|
||||
});
|
||||
|
||||
const repository = form.watch("repository");
|
||||
const githubId = form.watch("githubId");
|
||||
|
||||
const triggerType = form.watch("triggerType");
|
||||
const { data: repositories, isLoading: isLoadingRepositories } =
|
||||
api.github.getGithubRepositories.useQuery(
|
||||
{
|
||||
@@ -125,6 +130,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
githubId: data.githubId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
triggerType: data.triggerType || "push",
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -140,6 +147,8 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "github",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
triggerType: data.triggerType,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -384,79 +393,140 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="watchPaths"
|
||||
name="triggerType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="md:col-span-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>Watch Paths</FormLabel>
|
||||
<FormLabel>Trigger Type</FormLabel>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
|
||||
?
|
||||
</div>
|
||||
<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.
|
||||
Choose when to trigger deployments: on push to the
|
||||
selected branch or when a new tag is created.
|
||||
</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);
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a trigger type" />
|
||||
</SelectTrigger>
|
||||
</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>
|
||||
))}
|
||||
</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;
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const input = document.querySelector(
|
||||
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
|
||||
) as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
if (value) {
|
||||
const newPaths = [...(field.value || []), value];
|
||||
form.setValue("watchPaths", newPaths);
|
||||
input.value = "";
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</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>
|
||||
<FormMessage />
|
||||
<FormLabel className="!mt-0">Enable Submodules</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -60,6 +61,7 @@ const GitlabProviderSchema = z.object({
|
||||
branch: z.string().min(1, "Branch is required"),
|
||||
gitlabId: z.string().min(1, "Gitlab Provider is required"),
|
||||
watchPaths: z.array(z.string()).optional(),
|
||||
enableSubmodules: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type GitlabProvider = z.infer<typeof GitlabProviderSchema>;
|
||||
@@ -87,6 +89,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
gitlabId: "",
|
||||
branch: "",
|
||||
watchPaths: [],
|
||||
enableSubmodules: false,
|
||||
},
|
||||
resolver: zodResolver(GitlabProviderSchema),
|
||||
});
|
||||
@@ -136,6 +139,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
composePath: data.composePath,
|
||||
gitlabId: data.gitlabId || "",
|
||||
watchPaths: data.watchPaths || [],
|
||||
enableSubmodules: data.enableSubmodules ?? false,
|
||||
});
|
||||
}
|
||||
}, [form.reset, data, form]);
|
||||
@@ -153,6 +157,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
sourceType: "gitlab",
|
||||
composeStatus: "idle",
|
||||
watchPaths: data.watchPaths,
|
||||
enableSubmodules: data.enableSubmodules,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Service Provided Saved");
|
||||
@@ -485,6 +490,21 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
|
||||
</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 className="flex w-full justify-end">
|
||||
<Button
|
||||
|
||||
@@ -77,6 +77,14 @@ const RestoreBackupSchema = z.object({
|
||||
|
||||
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 = ({
|
||||
databaseId,
|
||||
databaseType,
|
||||
@@ -84,6 +92,7 @@ export const RestoreBackup = ({
|
||||
}: Props) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
|
||||
|
||||
const { data: destinations = [] } = api.destination.all.useQuery();
|
||||
|
||||
@@ -99,13 +108,18 @@ export const RestoreBackup = ({
|
||||
const destionationId = form.watch("destinationId");
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 350);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
}, 300);
|
||||
debouncedSetSearch(value);
|
||||
};
|
||||
|
||||
const { data: files = [], isLoading } = api.backup.listBackupFiles.useQuery(
|
||||
{
|
||||
destinationId: destionationId,
|
||||
search,
|
||||
search: debouncedSearchTerm,
|
||||
serverId: serverId ?? "",
|
||||
},
|
||||
{
|
||||
@@ -265,7 +279,7 @@ export const RestoreBackup = ({
|
||||
</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
<Popover>
|
||||
<Popover modal>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
@@ -284,7 +298,8 @@ export const RestoreBackup = ({
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search backup files..."
|
||||
onValueChange={debouncedSetSearch}
|
||||
value={search}
|
||||
onValueChange={handleSearchChange}
|
||||
className="h-9"
|
||||
/>
|
||||
{isLoading ? (
|
||||
@@ -301,26 +316,51 @@ export const RestoreBackup = ({
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<CommandGroup>
|
||||
{files.map((file) => (
|
||||
<CommandGroup className="w-96">
|
||||
{files?.map((file) => (
|
||||
<CommandItem
|
||||
value={file}
|
||||
key={file}
|
||||
value={file.Path}
|
||||
key={file.Path}
|
||||
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">
|
||||
<span>{file}</span>
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<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>
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto h-4 w-4",
|
||||
file === field.value
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
@@ -31,9 +31,14 @@ import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
|
||||
const AddProjectSchema = z.object({
|
||||
name: z.string().min(1, {
|
||||
message: "Name is required",
|
||||
}),
|
||||
name: z
|
||||
.string()
|
||||
.min(1, {
|
||||
message: "Name is required",
|
||||
})
|
||||
.regex(/^[a-zA-Z]/, {
|
||||
message: "Project name cannot start with a number",
|
||||
}),
|
||||
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 (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
|
||||
@@ -115,7 +115,7 @@ export const ShowProjects = () => {
|
||||
</span>
|
||||
</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) => {
|
||||
const emptyServices =
|
||||
project?.mariadb.length === 0 &&
|
||||
|
||||
@@ -55,7 +55,7 @@ export const AiForm = () => {
|
||||
key={config.aiId}
|
||||
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>
|
||||
<span className="text-sm font-medium">
|
||||
{config.name}
|
||||
|
||||
@@ -70,7 +70,7 @@ export const ShowCertificates = () => {
|
||||
key={certificate.certificateId}
|
||||
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 gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -13,53 +13,65 @@ export const extractExpirationDate = (certData: string): Date | null => {
|
||||
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++) {
|
||||
if (bytes[i] === 0x17 || bytes[i] === 0x18) {
|
||||
const dateType = bytes[i];
|
||||
const dateLength = bytes[i + 1];
|
||||
if (typeof dateLength === "undefined") continue;
|
||||
// Look for sequence containing validity period (0x30)
|
||||
if (bytes[i] === 0x30) {
|
||||
// Check next bytes for UTCTime or GeneralizedTime
|
||||
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) {
|
||||
dateFound++;
|
||||
i += dateLength + 1;
|
||||
continue;
|
||||
if (!dateFound) {
|
||||
// Skip "not before" date
|
||||
dateFound = true;
|
||||
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;
|
||||
|
||||
@@ -54,7 +54,7 @@ export const ShowRegistry = () => {
|
||||
key={registry.registryId}
|
||||
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 gap-2 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -55,7 +55,7 @@ export const ShowDestinations = () => {
|
||||
key={destination.destinationId}
|
||||
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">
|
||||
<span className="text-sm">
|
||||
{index + 1}. {destination.name}
|
||||
|
||||
@@ -248,7 +248,9 @@ export const AddGitlabProvider = () => {
|
||||
name="groupName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Name (Optional)</FormLabel>
|
||||
<FormLabel>
|
||||
Group Name (Optional, Comma-Separated List)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
|
||||
@@ -156,7 +156,9 @@ export const EditGitlabProvider = ({ gitlabId }: Props) => {
|
||||
name="groupName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Group Name (Optional)</FormLabel>
|
||||
<FormLabel>
|
||||
Group Name (Optional, Comma-Separated List)
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="For organization/group access use the slugish name of the group eg: my-org"
|
||||
|
||||
@@ -61,7 +61,7 @@ export const ShowNotifications = () => {
|
||||
key={notification.notificationId}
|
||||
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">
|
||||
{notification.notificationType === "slack" && (
|
||||
<div className="flex items-center justify-center rounded-lg">
|
||||
|
||||
@@ -36,6 +36,7 @@ const PasswordSchema = z.object({
|
||||
password: z.string().min(8, {
|
||||
message: "Password is required",
|
||||
}),
|
||||
issuer: z.string().optional(),
|
||||
});
|
||||
|
||||
const PinSchema = z.object({
|
||||
@@ -60,12 +61,86 @@ export const Enable2FA = () => {
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [step, setStep] = useState<"password" | "verify">("password");
|
||||
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) => {
|
||||
setIsPasswordLoading(true);
|
||||
try {
|
||||
const { data: enableData, error } = await authClient.twoFactor.enable({
|
||||
password: formData.password,
|
||||
issuer: formData.issuer,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
@@ -217,6 +223,27 @@ export const Enable2FA = () => {
|
||||
</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
|
||||
type="submit"
|
||||
className="w-full"
|
||||
@@ -228,11 +255,7 @@ export const Enable2FA = () => {
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...pinForm}>
|
||||
<form
|
||||
id="pin-form"
|
||||
onSubmit={pinForm.handleSubmit(handleVerifySubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<form onSubmit={handleVerifySubmit} className="space-y-6">
|
||||
<div className="flex flex-col gap-6 justify-center items-center">
|
||||
{data?.qrCodeUrl ? (
|
||||
<>
|
||||
@@ -284,36 +307,33 @@ export const Enable2FA = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={pinForm.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-center items-center">
|
||||
<FormLabel>Verification Code</FormLabel>
|
||||
<FormControl>
|
||||
<InputOTP maxLength={6} {...field}>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<FormLabel>Verification Code</FormLabel>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpValue}
|
||||
onChange={setOtpValue}
|
||||
autoComplete="off"
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
<FormDescription>
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</FormDescription>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
isLoading={isPasswordLoading}
|
||||
disabled={otpValue.length !== 6}
|
||||
>
|
||||
Enable 2FA
|
||||
</Button>
|
||||
|
||||
@@ -56,6 +56,7 @@ const randomImages = [
|
||||
export const ProfileForm = () => {
|
||||
const _utils = api.useUtils();
|
||||
const { data, refetch, isLoading } = api.user.get.useQuery();
|
||||
|
||||
const {
|
||||
mutateAsync,
|
||||
isLoading: isUpdating,
|
||||
@@ -84,12 +85,17 @@ export const ProfileForm = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
email: data?.user?.email || "",
|
||||
password: "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: "",
|
||||
});
|
||||
form.reset(
|
||||
{
|
||||
email: data?.user?.email || "",
|
||||
password: form.getValues("password") || "",
|
||||
image: data?.user?.image || "",
|
||||
currentPassword: form.getValues("currentPassword") || "",
|
||||
},
|
||||
{
|
||||
keepValues: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (data.user.email) {
|
||||
generateSHA256Hash(data.user.email).then((hash) => {
|
||||
@@ -97,8 +103,7 @@ export const ProfileForm = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
form.reset();
|
||||
}, [form, form.reset, data]);
|
||||
}, [form, data]);
|
||||
|
||||
const onSubmit = async (values: Profile) => {
|
||||
await mutateAsync({
|
||||
@@ -110,7 +115,12 @@ export const ProfileForm = () => {
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
toast.success("Profile Updated");
|
||||
form.reset();
|
||||
form.reset({
|
||||
email: values.email,
|
||||
password: "",
|
||||
image: values.image,
|
||||
currentPassword: "",
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error updating the profile");
|
||||
|
||||
@@ -22,6 +22,9 @@ export const ShowDokployActions = () => {
|
||||
const { mutateAsync: reloadServer, isLoading } =
|
||||
api.settings.reloadServer.useMutation();
|
||||
|
||||
const { mutateAsync: cleanRedis } = api.settings.cleanRedis.useMutation();
|
||||
const { mutateAsync: reloadRedis } = api.settings.reloadRedis.useMutation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild disabled={isLoading}>
|
||||
@@ -69,6 +72,36 @@ export const ShowDokployActions = () => {
|
||||
{t("settings.server.webServer.updateServerIp")}
|
||||
</DropdownMenuItem>
|
||||
</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>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ShowDestinations = () => {
|
||||
key={sshKey.sshKeyId}
|
||||
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 flex-col">
|
||||
<span className="text-sm font-medium">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { GlobeIcon } from "lucide-react";
|
||||
@@ -33,11 +35,19 @@ import { z } from "zod";
|
||||
|
||||
const addServerDomain = z
|
||||
.object({
|
||||
domain: z.string().min(1, { message: "URL is required" }),
|
||||
domain: z.string(),
|
||||
letsEncryptEmail: z.string(),
|
||||
https: z.boolean().optional(),
|
||||
certificateType: z.enum(["letsencrypt", "none", "custom"]),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.https && !data.certificateType) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["certificateType"],
|
||||
message: "Required",
|
||||
});
|
||||
}
|
||||
if (data.certificateType === "letsencrypt" && !data.letsEncryptEmail) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
@@ -61,15 +71,18 @@ export const WebDomain = () => {
|
||||
domain: "",
|
||||
certificateType: "none",
|
||||
letsEncryptEmail: "",
|
||||
https: false,
|
||||
},
|
||||
resolver: zodResolver(addServerDomain),
|
||||
});
|
||||
const https = form.watch("https");
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
form.reset({
|
||||
domain: data?.user?.host || "",
|
||||
certificateType: data?.user?.certificateType,
|
||||
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
|
||||
https: data?.user?.https || false,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -79,6 +92,7 @@ export const WebDomain = () => {
|
||||
host: data.domain,
|
||||
letsEncryptEmail: data.letsEncryptEmail,
|
||||
certificateType: data.certificateType,
|
||||
https: data.https,
|
||||
})
|
||||
.then(async () => {
|
||||
await refetch();
|
||||
@@ -155,44 +169,67 @@ export const WebDomain = () => {
|
||||
/>
|
||||
<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>
|
||||
name="https"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm w-full col-span-2">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>HTTPS</FormLabel>
|
||||
<FormDescription>
|
||||
Automatically provision SSL Certificate.
|
||||
</FormDescription>
|
||||
<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">
|
||||
<Button isLoading={isLoading} type="submit">
|
||||
|
||||
@@ -120,17 +120,6 @@ export const UserNav = () => {
|
||||
Docker
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{data?.role === "owner" && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/settings");
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -144,17 +133,6 @@ export const UserNav = () => {
|
||||
Servers
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{data?.role === "owner" && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
router.push("/dashboard/settings");
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
|
||||
@@ -26,15 +26,20 @@ const dockerComposeServices = [
|
||||
{ label: "secrets", type: "keyword", info: "Define secrets" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (view: EditorView, completion: Completion) => {
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
const insert = `${completion.label}:`;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: view.state.selection.main.from,
|
||||
to: view.state.selection.main.to,
|
||||
from,
|
||||
to,
|
||||
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" },
|
||||
].map((opt) => ({
|
||||
...opt,
|
||||
apply: (view: EditorView, completion: Completion) => {
|
||||
apply: (
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) => {
|
||||
const insert = `${completion.label}: `;
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: view.state.selection.main.from,
|
||||
to: view.state.selection.main.to,
|
||||
from,
|
||||
to,
|
||||
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 indentation = /^\s*/.exec(line.text)?.[0].length || 0;
|
||||
|
||||
// If we're at the root level
|
||||
if (indentation === 0) {
|
||||
return {
|
||||
from: word.from,
|
||||
|
||||
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
1
apps/dokploy/drizzle/0084_thin_iron_lad.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user_temp" ADD COLUMN "https" boolean DEFAULT false NOT NULL;
|
||||
2
apps/dokploy/drizzle/0085_equal_captain_stacy.sql
Normal file
2
apps/dokploy/drizzle/0085_equal_captain_stacy.sql
Normal 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;
|
||||
2
apps/dokploy/drizzle/0086_rainy_gertrude_yorkes.sql
Normal file
2
apps/dokploy/drizzle/0086_rainy_gertrude_yorkes.sql
Normal 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;
|
||||
3
apps/dokploy/drizzle/0087_lively_risque.sql
Normal file
3
apps/dokploy/drizzle/0087_lively_risque.sql
Normal 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';
|
||||
5369
apps/dokploy/drizzle/meta/0084_snapshot.json
Normal file
5369
apps/dokploy/drizzle/meta/0084_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5383
apps/dokploy/drizzle/meta/0085_snapshot.json
Normal file
5383
apps/dokploy/drizzle/meta/0085_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5383
apps/dokploy/drizzle/meta/0086_snapshot.json
Normal file
5383
apps/dokploy/drizzle/meta/0086_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
5407
apps/dokploy/drizzle/meta/0087_snapshot.json
Normal file
5407
apps/dokploy/drizzle/meta/0087_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -589,6 +589,34 @@
|
||||
"when": 1743288371413,
|
||||
"tag": "0083_parallel_stranger",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
/**
|
||||
* Sorted list based off of population of the country / speakers of the language.
|
||||
*/
|
||||
export const Languages = {
|
||||
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" },
|
||||
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: "فارسی" },
|
||||
korean: { code: "ko", name: "한국어" },
|
||||
portuguese: { code: "pt-br", name: "Português" },
|
||||
italian: { code: "it", name: "Italiano" },
|
||||
japanese: { code: "ja", name: "日本語" },
|
||||
spanish: { code: "es", name: "Español" },
|
||||
dutch: { code: "nl", name: "Nederlands" },
|
||||
indonesian: { code: "id", name: "Bahasa Indonesia" },
|
||||
kazakh: { code: "kz", name: "Қазақ" },
|
||||
norwegian: { code: "no", name: "Norsk" },
|
||||
azerbaijani: { code: "az", name: "Azərbaycan" },
|
||||
indonesian: { code: "id", name: "Bahasa Indonesia" },
|
||||
malayalam: { code: "ml", name: "മലയാളം" },
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.21.3",
|
||||
"version": "v0.21.8",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -92,7 +92,7 @@
|
||||
"adm-zip": "^0.5.14",
|
||||
"ai": "^4.0.23",
|
||||
"bcrypt": "5.1.1",
|
||||
"better-auth": "1.2.4",
|
||||
"better-auth": "1.2.6",
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
|
||||
@@ -89,6 +89,115 @@ export default async function handler(
|
||||
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") {
|
||||
try {
|
||||
const branchName = githubBody?.ref?.replace("refs/heads/", "");
|
||||
@@ -105,6 +214,7 @@ export default async function handler(
|
||||
where: and(
|
||||
eq(applications.sourceType, "github"),
|
||||
eq(applications.autoDeploy, true),
|
||||
eq(applications.triggerType, "push"),
|
||||
eq(applications.branch, branchName),
|
||||
eq(applications.repository, repository),
|
||||
eq(applications.owner, owner),
|
||||
@@ -150,6 +260,7 @@ export default async function handler(
|
||||
where: and(
|
||||
eq(compose.sourceType, "github"),
|
||||
eq(compose.autoDeploy, true),
|
||||
eq(compose.triggerType, "push"),
|
||||
eq(compose.branch, branchName),
|
||||
eq(compose.repository, repository),
|
||||
eq(compose.owner, owner),
|
||||
|
||||
@@ -314,31 +314,43 @@ const Project = (
|
||||
};
|
||||
|
||||
const applicationActions = {
|
||||
start: api.application.start.useMutation(),
|
||||
stop: api.application.stop.useMutation(),
|
||||
move: api.application.move.useMutation(),
|
||||
delete: api.application.delete.useMutation(),
|
||||
};
|
||||
|
||||
const postgresActions = {
|
||||
start: api.postgres.start.useMutation(),
|
||||
stop: api.postgres.stop.useMutation(),
|
||||
move: api.postgres.move.useMutation(),
|
||||
delete: api.postgres.remove.useMutation(),
|
||||
};
|
||||
|
||||
const mysqlActions = {
|
||||
start: api.mysql.start.useMutation(),
|
||||
stop: api.mysql.stop.useMutation(),
|
||||
move: api.mysql.move.useMutation(),
|
||||
delete: api.mysql.remove.useMutation(),
|
||||
};
|
||||
|
||||
const mariadbActions = {
|
||||
start: api.mariadb.start.useMutation(),
|
||||
stop: api.mariadb.stop.useMutation(),
|
||||
move: api.mariadb.move.useMutation(),
|
||||
delete: api.mariadb.remove.useMutation(),
|
||||
};
|
||||
|
||||
const redisActions = {
|
||||
start: api.redis.start.useMutation(),
|
||||
stop: api.redis.stop.useMutation(),
|
||||
move: api.redis.move.useMutation(),
|
||||
delete: api.redis.remove.useMutation(),
|
||||
};
|
||||
|
||||
const mongoActions = {
|
||||
start: api.mongo.start.useMutation(),
|
||||
stop: api.mongo.stop.useMutation(),
|
||||
move: api.mongo.move.useMutation(),
|
||||
delete: api.mongo.remove.useMutation(),
|
||||
};
|
||||
@@ -348,7 +360,34 @@ const Project = (
|
||||
setIsBulkActionLoading(true);
|
||||
for (const serviceId of selectedServices) {
|
||||
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++;
|
||||
} catch (_error) {
|
||||
toast.error(`Error starting service ${serviceId}`);
|
||||
@@ -368,7 +407,34 @@ const Project = (
|
||||
setIsBulkActionLoading(true);
|
||||
for (const serviceId of selectedServices) {
|
||||
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++;
|
||||
} catch (_error) {
|
||||
toast.error(`Error stopping service ${serviceId}`);
|
||||
|
||||
@@ -217,7 +217,7 @@ const Service = (
|
||||
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
|
||||
className={cn(
|
||||
"flex gap-8 justify-start max-xl:overflow-x-scroll overflow-y-hidden",
|
||||
|
||||
@@ -212,15 +212,15 @@ const Service = (
|
||||
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
|
||||
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
|
||||
? "md:grid-cols-7"
|
||||
? "lg:grid-cols-7"
|
||||
: data?.serverId
|
||||
? "md:grid-cols-6"
|
||||
: "md:grid-cols-7",
|
||||
? "lg:grid-cols-6"
|
||||
: "lg:grid-cols-7",
|
||||
)}
|
||||
>
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
|
||||
@@ -182,7 +182,7 @@ const Mariadb = (
|
||||
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
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
@@ -183,7 +183,7 @@ const Mongo = (
|
||||
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
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
@@ -183,7 +183,7 @@ const MySql = (
|
||||
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
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ",
|
||||
|
||||
@@ -182,7 +182,7 @@ const Postgresql = (
|
||||
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
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
@@ -182,7 +182,7 @@ const Redis = (
|
||||
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
|
||||
className={cn(
|
||||
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
}
|
||||
1
apps/dokploy/public/locales/nl/common.json
Normal file
1
apps/dokploy/public/locales/nl/common.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
58
apps/dokploy/public/locales/nl/settings.json
Normal file
58
apps/dokploy/public/locales/nl/settings.json
Normal 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"
|
||||
}
|
||||
@@ -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": "变量值"
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
{
|
||||
"settings.common.save": "保存",
|
||||
"settings.common.enterTerminal": "进入终端",
|
||||
"settings.server.domain.title": "域名设置",
|
||||
"settings.server.domain.description": "添加域名到服务器",
|
||||
"settings.common.enterTerminal": "终端",
|
||||
"settings.server.domain.title": "服务器域名",
|
||||
"settings.server.domain.description": "为您的服务器应用添加域名。",
|
||||
"settings.server.domain.form.domain": "域名",
|
||||
"settings.server.domain.form.letsEncryptEmail": "Let's Encrypt 邮箱",
|
||||
"settings.server.domain.form.certificate.label": "证书",
|
||||
"settings.server.domain.form.certificate.placeholder": "选择一个证书",
|
||||
"settings.server.domain.form.certificate.label": "证书提供商",
|
||||
"settings.server.domain.form.certificate.placeholder": "选择证书",
|
||||
"settings.server.domain.form.certificateOptions.none": "无",
|
||||
"settings.server.domain.form.certificateOptions.letsencrypt": "Let's Encrypt",
|
||||
|
||||
"settings.server.webServer.title": "服务器设置",
|
||||
"settings.server.webServer.description": "管理服务器",
|
||||
"settings.server.webServer.title": "Web 服务器",
|
||||
"settings.server.webServer.description": "重载或清理 Web 服务器。",
|
||||
"settings.server.webServer.actions": "操作",
|
||||
"settings.server.webServer.reload": "重新加载",
|
||||
"settings.server.webServer.watchLogs": "查看日志",
|
||||
@@ -19,40 +18,50 @@
|
||||
"settings.server.webServer.server.label": "服务器",
|
||||
"settings.server.webServer.traefik.label": "Traefik",
|
||||
"settings.server.webServer.traefik.modifyEnv": "修改环境变量",
|
||||
"settings.server.webServer.traefik.managePorts": "端口转发",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "添加或删除 Traefik 的其他端口",
|
||||
"settings.server.webServer.traefik.managePorts": "额外端口映射",
|
||||
"settings.server.webServer.traefik.managePortsDescription": "为 Traefik 添加或删除额外端口",
|
||||
"settings.server.webServer.traefik.targetPort": "目标端口",
|
||||
"settings.server.webServer.traefik.publishedPort": "对外端口",
|
||||
"settings.server.webServer.traefik.publishedPort": "发布端口",
|
||||
"settings.server.webServer.traefik.addPort": "添加端口",
|
||||
"settings.server.webServer.traefik.portsUpdated": "端口更新成功",
|
||||
"settings.server.webServer.traefik.portsUpdateError": "端口更新失败",
|
||||
"settings.server.webServer.traefik.publishMode": "端口映射",
|
||||
"settings.server.webServer.traefik.publishMode": "发布模式",
|
||||
"settings.server.webServer.storage.label": "存储空间",
|
||||
"settings.server.webServer.storage.cleanUnusedImages": "清理未使用的镜像",
|
||||
"settings.server.webServer.storage.cleanUnusedVolumes": "清理未使用的卷",
|
||||
"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.cleanAll": "清理所有内容",
|
||||
|
||||
"settings.profile.title": "账户",
|
||||
"settings.profile.description": "更改您的个人资料",
|
||||
"settings.profile.description": "在此更改您的个人资料详情。",
|
||||
"settings.profile.email": "邮箱",
|
||||
"settings.profile.password": "密码",
|
||||
"settings.profile.avatar": "头像",
|
||||
|
||||
"settings.appearance.title": "外观",
|
||||
"settings.appearance.description": "自定义面板主题",
|
||||
"settings.appearance.description": "自定义您的仪表盘主题。",
|
||||
"settings.appearance.theme": "主题",
|
||||
"settings.appearance.themeDescription": "选择面板主题",
|
||||
"settings.appearance.themeDescription": "为您的仪表盘选择主题",
|
||||
"settings.appearance.themes.light": "明亮",
|
||||
"settings.appearance.themes.dark": "黑暗",
|
||||
"settings.appearance.themes.system": "系统主题",
|
||||
"settings.appearance.themes.dark": "暗黑",
|
||||
"settings.appearance.themes.system": "跟随系统",
|
||||
"settings.appearance.language": "语言",
|
||||
"settings.appearance.languageDescription": "选择面板语言",
|
||||
|
||||
"settings.terminal.connectionSettings": "终端设置",
|
||||
"settings.terminal.ipAddress": "IP",
|
||||
"settings.appearance.languageDescription": "为您的仪表盘选择语言",
|
||||
"settings.terminal.connectionSettings": "连接设置",
|
||||
"settings.terminal.ipAddress": "IP 地址",
|
||||
"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": "网络"
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
findApplicationById,
|
||||
findProjectById,
|
||||
getApplicationStats,
|
||||
mechanizeDockerContainer,
|
||||
readConfig,
|
||||
readRemoteConfig,
|
||||
removeDeployments,
|
||||
@@ -132,28 +133,36 @@ export const applicationRouter = createTRPCRouter({
|
||||
.input(apiReloadApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
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({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to reload this application",
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
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
|
||||
@@ -346,6 +355,8 @@ export const applicationRouter = createTRPCRouter({
|
||||
applicationStatus: "idle",
|
||||
githubId: input.githubId,
|
||||
watchPaths: input.watchPaths,
|
||||
triggerType: input.triggerType,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -373,6 +384,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
gitlabProjectId: input.gitlabProjectId,
|
||||
gitlabPathNamespace: input.gitlabPathNamespace,
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -398,6 +410,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
applicationStatus: "idle",
|
||||
bitbucketId: input.bitbucketId,
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -423,6 +436,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
applicationStatus: "idle",
|
||||
giteaId: input.giteaId,
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
});
|
||||
|
||||
return true;
|
||||
@@ -470,6 +484,7 @@ export const applicationRouter = createTRPCRouter({
|
||||
sourceType: "git",
|
||||
applicationStatus: "idle",
|
||||
watchPaths: input.watchPaths,
|
||||
enableSubmodules: input.enableSubmodules,
|
||||
});
|
||||
|
||||
return true;
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
} from "@dokploy/server";
|
||||
|
||||
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 {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
@@ -47,6 +50,18 @@ import { TRPCError } from "@trpc/server";
|
||||
import { observable } from "@trpc/server/observable";
|
||||
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({
|
||||
create: protectedProcedure
|
||||
.input(apiCreateBackup)
|
||||
@@ -257,7 +272,7 @@ export const backupRouter = createTRPCRouter({
|
||||
const lastSlashIndex = input.search.lastIndexOf("/");
|
||||
const baseDir =
|
||||
lastSlashIndex !== -1
|
||||
? input.search.slice(0, lastSlashIndex + 1)
|
||||
? normalizeS3Path(input.search.slice(0, lastSlashIndex + 1))
|
||||
: "";
|
||||
const searchTerm =
|
||||
lastSlashIndex !== -1
|
||||
@@ -265,32 +280,47 @@ export const backupRouter = createTRPCRouter({
|
||||
: input.search;
|
||||
|
||||
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 = "";
|
||||
|
||||
if (input.serverId) {
|
||||
const result = await execAsyncRemote(listCommand, input.serverId);
|
||||
const result = await execAsyncRemote(input.serverId, listCommand);
|
||||
stdout = result.stdout;
|
||||
} else {
|
||||
const result = await execAsync(listCommand);
|
||||
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
|
||||
? files.map((file) => `${baseDir}${file}`)
|
||||
? files.map((file) => ({
|
||||
...file,
|
||||
Path: `${baseDir}${file.Path}`,
|
||||
}))
|
||||
: files;
|
||||
|
||||
if (searchTerm) {
|
||||
return results.filter((file) =>
|
||||
file.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
return results
|
||||
.filter((file) =>
|
||||
file.Path.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
)
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
return results;
|
||||
return results.slice(0, 100);
|
||||
} catch (error) {
|
||||
console.error("Error in listBackupFiles:", error);
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message:
|
||||
|
||||
@@ -31,7 +31,6 @@ export const mountRouter = createTRPCRouter({
|
||||
update: protectedProcedure
|
||||
.input(apiUpdateMount)
|
||||
.mutation(async ({ input }) => {
|
||||
await updateMount(input.mountId, input);
|
||||
return true;
|
||||
return await updateMount(input.mountId, input);
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import {
|
||||
IS_CLOUD,
|
||||
createRegistry,
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
execFileAsync,
|
||||
findRegistryById,
|
||||
removeRegistry,
|
||||
updateRegistry,
|
||||
@@ -83,7 +83,13 @@ export const registryRouter = createTRPCRouter({
|
||||
.input(apiTestRegistry)
|
||||
.mutation(async ({ input }) => {
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
@@ -93,9 +99,14 @@ export const registryRouter = createTRPCRouter({
|
||||
}
|
||||
|
||||
if (input.serverId && input.serverId !== "none") {
|
||||
await execAsyncRemote(input.serverId, loginCommand);
|
||||
await execAsyncRemote(
|
||||
input.serverId,
|
||||
`echo ${input.password} | docker ${args.join(" ")}`,
|
||||
);
|
||||
} else {
|
||||
await execAsync(loginCommand);
|
||||
await execFileAsync("docker", args, {
|
||||
input: Buffer.from(input.password).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -79,6 +79,33 @@ export const settingsRouter = createTRPCRouter({
|
||||
await execAsync(`docker service update --force ${stdout.trim()}`);
|
||||
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
|
||||
.input(apiServerSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
@@ -184,6 +211,7 @@ export const settingsRouter = createTRPCRouter({
|
||||
letsEncryptEmail: input.letsEncryptEmail,
|
||||
}),
|
||||
certificateType: input.certificateType,
|
||||
https: input.https,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { setupTerminalWebSocketServer } from "./wss/terminal";
|
||||
|
||||
config({ path: ".env" });
|
||||
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 app = next({ dev, turbopack: process.env.TURBOPACK === "1" });
|
||||
const handle = app.getRequestHandler();
|
||||
@@ -55,8 +56,8 @@ void app.prepare().then(async () => {
|
||||
await migration();
|
||||
}
|
||||
|
||||
server.listen(PORT);
|
||||
console.log("Server Started:", PORT);
|
||||
server.listen(PORT, HOST);
|
||||
console.log(`Server Started on: http://${HOST}:${PORT}`);
|
||||
if (!IS_CLOUD) {
|
||||
console.log("Starting Deployment Worker");
|
||||
const { deploymentWorker } = await import("./queues/deployments-queue");
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type http from "node:http";
|
||||
import { validateRequest } from "@dokploy/server/index";
|
||||
import { applyWSSHandler } from "@trpc/server/adapters/ws";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { appRouter } from "../api/root";
|
||||
import { createTRPCContext } from "../api/trpc";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
|
||||
export const setupDrawerLogsWebSocketServer = (
|
||||
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
|
||||
@@ -13,11 +13,13 @@ export const setupDrawerLogsWebSocketServer = (
|
||||
path: "/drawer-logs",
|
||||
});
|
||||
|
||||
// Set up tRPC WebSocket handler
|
||||
applyWSSHandler({
|
||||
wss: wssTerm,
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext as any,
|
||||
});
|
||||
|
||||
server.on("upgrade", (req, socket, head) => {
|
||||
const { pathname } = new URL(req.url || "", `http://${req.headers.host}`);
|
||||
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
"@ai-sdk/mistral": "^1.0.6",
|
||||
"@ai-sdk/openai": "^1.0.12",
|
||||
"@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/crypto": "1.0.1",
|
||||
"drizzle-dbml-generator": "0.10.0",
|
||||
"better-auth": "1.2.4",
|
||||
"better-auth": "1.2.6",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@octokit/auth-app": "^6.0.4",
|
||||
"@react-email/components": "^0.0.21",
|
||||
|
||||
@@ -24,7 +24,7 @@ import { redirects } from "./redirects";
|
||||
import { registry } from "./registry";
|
||||
import { security } from "./security";
|
||||
import { server } from "./server";
|
||||
import { applicationStatus, certificateType } from "./shared";
|
||||
import { applicationStatus, certificateType, triggerType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { generateAppName } from "./utils";
|
||||
|
||||
@@ -149,6 +149,7 @@ export const applications = pgTable("application", {
|
||||
owner: text("owner"),
|
||||
branch: text("branch"),
|
||||
buildPath: text("buildPath").default("/"),
|
||||
triggerType: triggerType("triggerType").default("push"),
|
||||
autoDeploy: boolean("autoDeploy").$defaultFn(() => true),
|
||||
// Gitlab
|
||||
gitlabProjectId: integer("gitlabProjectId"),
|
||||
@@ -182,6 +183,7 @@ export const applications = pgTable("application", {
|
||||
onDelete: "set null",
|
||||
},
|
||||
),
|
||||
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
|
||||
dockerfile: text("dockerfile"),
|
||||
dockerContextPath: text("dockerContextPath"),
|
||||
dockerBuildStage: text("dockerBuildStage"),
|
||||
@@ -470,8 +472,12 @@ export const apiSaveGithubProvider = createSchema
|
||||
buildPath: true,
|
||||
githubId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
.required()
|
||||
.extend({
|
||||
triggerType: z.enum(["push", "tag"]).default("push"),
|
||||
});
|
||||
|
||||
export const apiSaveGitlabProvider = createSchema
|
||||
.pick({
|
||||
@@ -484,6 +490,7 @@ export const apiSaveGitlabProvider = createSchema
|
||||
gitlabProjectId: true,
|
||||
gitlabPathNamespace: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -496,6 +503,7 @@ export const apiSaveBitbucketProvider = createSchema
|
||||
bitbucketId: true,
|
||||
applicationId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -508,6 +516,7 @@ export const apiSaveGiteaProvider = createSchema
|
||||
giteaRepository: true,
|
||||
giteaId: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -528,6 +537,7 @@ export const apiSaveGitProvider = createSchema
|
||||
customGitBuildPath: true,
|
||||
customGitUrl: true,
|
||||
watchPaths: true,
|
||||
enableSubmodules: true,
|
||||
})
|
||||
.required()
|
||||
.merge(
|
||||
|
||||
@@ -12,7 +12,7 @@ import { gitlab } from "./gitlab";
|
||||
import { mounts } from "./mount";
|
||||
import { projects } from "./project";
|
||||
import { server } from "./server";
|
||||
import { applicationStatus } from "./shared";
|
||||
import { applicationStatus, triggerType } from "./shared";
|
||||
import { sshKeys } from "./ssh-key";
|
||||
import { generateAppName } from "./utils";
|
||||
|
||||
@@ -72,10 +72,12 @@ export const compose = pgTable("compose", {
|
||||
),
|
||||
command: text("command").notNull().default(""),
|
||||
//
|
||||
enableSubmodules: boolean("enableSubmodules").notNull().default(false),
|
||||
composePath: text("composePath").notNull().default("./docker-compose.yml"),
|
||||
suffix: text("suffix").notNull().default(""),
|
||||
randomize: boolean("randomize").notNull().default(false),
|
||||
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
|
||||
triggerType: triggerType("triggerType").default("push"),
|
||||
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
|
||||
projectId: text("projectId")
|
||||
.notNull()
|
||||
|
||||
@@ -12,3 +12,5 @@ export const certificateType = pgEnum("certificateType", [
|
||||
"none",
|
||||
"custom",
|
||||
]);
|
||||
|
||||
export const triggerType = pgEnum("triggerType", ["push", "tag"]);
|
||||
|
||||
@@ -50,6 +50,7 @@ export const users_temp = pgTable("user_temp", {
|
||||
// Admin
|
||||
serverIp: text("serverIp"),
|
||||
certificateType: certificateType("certificateType").notNull().default("none"),
|
||||
https: boolean("https").notNull().default(false),
|
||||
host: text("host"),
|
||||
letsEncryptEmail: text("letsEncryptEmail"),
|
||||
sshPrivateKey: text("sshPrivateKey"),
|
||||
@@ -203,10 +204,12 @@ export const apiAssignDomain = createSchema
|
||||
host: true,
|
||||
certificateType: true,
|
||||
letsEncryptEmail: true,
|
||||
https: true,
|
||||
})
|
||||
.required()
|
||||
.partial({
|
||||
letsEncryptEmail: true,
|
||||
https: true,
|
||||
});
|
||||
|
||||
export const apiUpdateDockerCleanup = createSchema
|
||||
|
||||
@@ -201,7 +201,7 @@ const { handler, api } = betterAuth({
|
||||
const host =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:3000"
|
||||
: "https://dokploy.com";
|
||||
: "https://app.dokploy.com";
|
||||
const inviteLink = `${host}/invitation?token=${data.id}`;
|
||||
|
||||
await sendEmail({
|
||||
|
||||
@@ -356,6 +356,7 @@ export const deployRemoteCompose = async ({
|
||||
deployment.logPath,
|
||||
true,
|
||||
);
|
||||
console.log(command);
|
||||
} else if (compose.sourceType === "raw") {
|
||||
command += getCreateComposeFileCommand(compose, deployment.logPath);
|
||||
} else if (compose.sourceType === "gitea") {
|
||||
|
||||
@@ -144,7 +144,8 @@ export const updateMount = async (
|
||||
await deleteFileMount(mountId);
|
||||
await createFileMount(mountId);
|
||||
}
|
||||
return mount;
|
||||
|
||||
return await findMountById(mountId);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ CURRENT_USER=$USER
|
||||
|
||||
echo "Installing requirements for: OS: $OS_TYPE"
|
||||
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
|
||||
fi
|
||||
|
||||
@@ -263,7 +263,7 @@ const setupMainDirectory = () => `
|
||||
# Create the /etc/dokploy directory
|
||||
mkdir -p /etc/dokploy
|
||||
chmod 777 /etc/dokploy
|
||||
|
||||
|
||||
echo "Directory /etc/dokploy created ✅"
|
||||
fi
|
||||
`;
|
||||
@@ -276,16 +276,16 @@ export const setupSwarm = () => `
|
||||
# Get IP address
|
||||
get_ip() {
|
||||
local ip=""
|
||||
|
||||
|
||||
# Try IPv4 with multiple services
|
||||
# First attempt: ifconfig.io
|
||||
ip=\$(curl -4s --connect-timeout 5 https://ifconfig.io 2>/dev/null)
|
||||
|
||||
|
||||
# Second attempt: icanhazip.com
|
||||
if [ -z "\$ip" ]; then
|
||||
ip=\$(curl -4s --connect-timeout 5 https://icanhazip.com 2>/dev/null)
|
||||
fi
|
||||
|
||||
|
||||
# Third attempt: ipecho.net
|
||||
if [ -z "\$ip" ]; then
|
||||
ip=\$(curl -4s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null)
|
||||
@@ -295,12 +295,12 @@ export const setupSwarm = () => `
|
||||
if [ -z "\$ip" ]; then
|
||||
# Try IPv6 with ifconfig.io
|
||||
ip=\$(curl -6s --connect-timeout 5 https://ifconfig.io 2>/dev/null)
|
||||
|
||||
|
||||
# Try IPv6 with icanhazip.com
|
||||
if [ -z "\$ip" ]; then
|
||||
ip=\$(curl -6s --connect-timeout 5 https://icanhazip.com 2>/dev/null)
|
||||
fi
|
||||
|
||||
|
||||
# Try IPv6 with ipecho.net
|
||||
if [ -z "\$ip" ]; then
|
||||
ip=\$(curl -6s --connect-timeout 5 https://ipecho.net/plain 2>/dev/null)
|
||||
@@ -549,7 +549,7 @@ export const createTraefikInstance = () => {
|
||||
sleep 8
|
||||
echo "Traefik migrated to Standalone ✅"
|
||||
fi
|
||||
|
||||
|
||||
if docker inspect dokploy-traefik > /dev/null 2>&1; then
|
||||
echo "Traefik already exists ✅"
|
||||
else
|
||||
@@ -577,7 +577,7 @@ const installNixpacks = () => `
|
||||
if command_exists nixpacks; then
|
||||
echo "Nixpacks already installed ✅"
|
||||
else
|
||||
export NIXPACKS_VERSION=1.29.1
|
||||
export NIXPACKS_VERSION=1.35.0
|
||||
bash -c "$(curl -fsSL https://nixpacks.com/install.sh)"
|
||||
echo "Nixpacks version $NIXPACKS_VERSION installed ✅"
|
||||
fi
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, createHmac } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -24,6 +24,12 @@ export interface Template {
|
||||
domains: DomainSchema[];
|
||||
}
|
||||
|
||||
export interface GenerateJWTOptions {
|
||||
length?: number;
|
||||
secret?: string;
|
||||
payload?: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export const generateRandomDomain = ({
|
||||
serverIp,
|
||||
projectName,
|
||||
@@ -61,8 +67,48 @@ export function generateBase64(bytes = 32): string {
|
||||
return randomBytes(bytes).toString("base64");
|
||||
}
|
||||
|
||||
export function generateJwt(length = 256): string {
|
||||
return randomBytes(length).toString("hex");
|
||||
function safeBase64(str: string): string {
|
||||
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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface Template {
|
||||
/**
|
||||
* Process a string value and replace variables
|
||||
*/
|
||||
function processValue(
|
||||
export function processValue(
|
||||
value: string,
|
||||
variables: Record<string, string>,
|
||||
schema: Schema,
|
||||
@@ -84,11 +84,11 @@ function processValue(
|
||||
const length = Number.parseInt(varName.split(":")[1], 10) || 32;
|
||||
return generateBase64(length);
|
||||
}
|
||||
|
||||
if (varName.startsWith("password:")) {
|
||||
const length = Number.parseInt(varName.split(":")[1], 10) || 16;
|
||||
return generatePassword(length);
|
||||
}
|
||||
|
||||
if (varName === "password") {
|
||||
return generatePassword(16);
|
||||
}
|
||||
@@ -97,14 +97,31 @@ function processValue(
|
||||
const length = Number.parseInt(varName.split(":")[1], 10) || 8;
|
||||
return generateHash(length);
|
||||
}
|
||||
if (varName === "hash") {
|
||||
return generateHash();
|
||||
}
|
||||
|
||||
if (varName === "uuid") {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
if (varName === "timestamp") {
|
||||
if (varName === "timestamp" || varName === "timestampms") {
|
||||
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") {
|
||||
return Math.floor(Math.random() * 65535).toString();
|
||||
}
|
||||
@@ -114,8 +131,34 @@ function processValue(
|
||||
}
|
||||
|
||||
if (varName.startsWith("jwt:")) {
|
||||
const length = Number.parseInt(varName.split(":")[1], 10) || 256;
|
||||
return generateJwt(length);
|
||||
const params: string[] = varName.split(":").slice(1);
|
||||
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") {
|
||||
@@ -147,7 +190,7 @@ export function processVariables(
|
||||
): 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)) {
|
||||
if (typeof value !== "string") continue;
|
||||
|
||||
@@ -161,6 +204,8 @@ export function processVariables(
|
||||
const match = value.match(/\${password:(\d+)}/);
|
||||
const length = match?.[1] ? Number.parseInt(match[1], 10) : 16;
|
||||
variables[key] = generatePassword(length);
|
||||
} else if (value === "${hash}") {
|
||||
variables[key] = generateHash();
|
||||
} else if (value.startsWith("${hash:")) {
|
||||
const match = value.match(/\${hash:(\d+)}/);
|
||||
const length = match?.[1] ? Number.parseInt(match[1], 10) : 8;
|
||||
|
||||
@@ -106,8 +106,8 @@ export const keepLatestNBackups = async (
|
||||
backup.prefix,
|
||||
);
|
||||
|
||||
// --include "*.sql.gz" ensures nothing else other than the db backup files are touched by rclone
|
||||
const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*.sql.gz" ${backupFilesPath}`;
|
||||
// --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 "*${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
|
||||
const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`;
|
||||
// this command deletes the files
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import type { Mariadb } from "@dokploy/server/services/mariadb";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
} from "../docker/utils";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getS3Credentials } from "./utils";
|
||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runMariadbBackup = async (
|
||||
mariadb: Mariadb,
|
||||
@@ -19,7 +18,7 @@ export const runMariadbBackup = async (
|
||||
const { prefix, database } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = path.join(prefix, backupFileName);
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import type { Mongo } from "@dokploy/server/services/mongo";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
} from "../docker/utils";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
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
|
||||
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 destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.dump.gz`;
|
||||
const bucketDestination = path.join(prefix, backupFileName);
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import type { MySql } from "@dokploy/server/services/mysql";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
} from "../docker/utils";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getS3Credentials } from "./utils";
|
||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const { appName, databaseRootPassword, projectId, name } = mysql;
|
||||
@@ -16,7 +15,7 @@ export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => {
|
||||
const { prefix, database } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = path.join(prefix, backupFileName);
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import path from "node:path";
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import type { Postgres } from "@dokploy/server/services/postgres";
|
||||
import { findProjectById } from "@dokploy/server/services/project";
|
||||
@@ -8,7 +7,7 @@ import {
|
||||
} from "../docker/utils";
|
||||
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
|
||||
import { execAsync, execAsyncRemote } from "../process/execAsync";
|
||||
import { getS3Credentials } from "./utils";
|
||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
||||
|
||||
export const runPostgresBackup = async (
|
||||
postgres: Postgres,
|
||||
@@ -20,7 +19,7 @@ export const runPostgresBackup = async (
|
||||
const { prefix, database } = backup;
|
||||
const destination = backup.destination;
|
||||
const backupFileName = `${new Date().toISOString()}.sql.gz`;
|
||||
const bucketDestination = path.join(prefix, backupFileName);
|
||||
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
|
||||
try {
|
||||
const rcloneFlags = getS3Credentials(destination);
|
||||
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
|
||||
|
||||
@@ -36,6 +36,13 @@ export const removeScheduleBackup = (backupId: string) => {
|
||||
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) => {
|
||||
const { accessKey, secretAccessKey, region, endpoint, provider } =
|
||||
destination;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { BackupSchedule } from "@dokploy/server/services/backup";
|
||||
import { execAsync } from "../process/execAsync";
|
||||
import { getS3Credentials } from "./utils";
|
||||
import { getS3Credentials, normalizeS3Path } from "./utils";
|
||||
import { findDestinationById } from "@dokploy/server/services/destination";
|
||||
import { IS_CLOUD, paths } from "@dokploy/server/constants";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
@@ -18,18 +18,30 @@ export const runWebServerBackup = async (backup: BackupSchedule) => {
|
||||
const { BASE_PATH } = paths();
|
||||
const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-"));
|
||||
const backupFileName = `webserver-backup-${timestamp}.zip`;
|
||||
const s3Path = `:s3:${destination.bucket}/${backup.prefix}${backupFileName}`;
|
||||
const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
|
||||
|
||||
try {
|
||||
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(`cp -r ${BASE_PATH}/* ${tempDir}/filesystem/`);
|
||||
|
||||
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}"`;
|
||||
|
||||
@@ -84,7 +84,7 @@ export const buildRailpack = async (
|
||||
for (const envVar of envVariables) {
|
||||
const [key, value] = envVar.split("=");
|
||||
if (key && value) {
|
||||
buildArgs.push("--secret", `id=${key},env=${key}`);
|
||||
buildArgs.push("--secret", `id=${key},env='${key}'`);
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export const getRailpackCommand = (
|
||||
];
|
||||
|
||||
for (const env of envVariables) {
|
||||
prepareArgs.push("--env", env);
|
||||
prepareArgs.push("--env", `'${env}'`);
|
||||
}
|
||||
|
||||
// Calculate secrets hash for layer invalidation
|
||||
@@ -164,7 +164,7 @@ export const getRailpackCommand = (
|
||||
for (const envVar of envVariables) {
|
||||
const [key, value] = envVar.split("=");
|
||||
if (key && value) {
|
||||
buildArgs.push("--secret", `id=${key},env=${key}`);
|
||||
buildArgs.push("--secret", `id=${key},env='${key}'`);
|
||||
exportEnvs.push(`export ${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ export const buildStatic = async (
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
createFile(
|
||||
buildAppDirectory,
|
||||
".dockerignore",
|
||||
[".git", ".env", "Dockerfile", ".dockerignore"].join("\n"),
|
||||
);
|
||||
|
||||
await buildCustomDocker(
|
||||
{
|
||||
...application,
|
||||
|
||||
@@ -249,6 +249,11 @@ export const addDomainToCompose = async (
|
||||
labels.unshift("traefik.enable=true");
|
||||
}
|
||||
labels.unshift(...httpLabels);
|
||||
if (!compose.isolatedDeployment) {
|
||||
if (!labels.includes("traefik.docker.network=dokploy-network")) {
|
||||
labels.unshift("traefik.docker.network=dokploy-network");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!compose.isolatedDeployment) {
|
||||
|
||||
@@ -40,68 +40,84 @@ export const sendDokployRestartNotifications = async () => {
|
||||
const decorate = (decoration: string, text: string) =>
|
||||
`${discord.decoration ? decoration : ""} ${text}`.trim();
|
||||
|
||||
await sendDiscordNotification(discord, {
|
||||
title: decorate(">", "`✅` Dokploy Server Restarted"),
|
||||
color: 0x57f287,
|
||||
fields: [
|
||||
{
|
||||
name: decorate("`📅`", "Date"),
|
||||
value: `<t:${unixDate}:D>`,
|
||||
inline: true,
|
||||
try {
|
||||
await sendDiscordNotification(discord, {
|
||||
title: decorate(">", "`✅` Dokploy Server Restarted"),
|
||||
color: 0x57f287,
|
||||
fields: [
|
||||
{
|
||||
name: decorate("`📅`", "Date"),
|
||||
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"),
|
||||
value: `<t:${unixDate}:t>`,
|
||||
inline: true,
|
||||
},
|
||||
{
|
||||
name: decorate("`❓`", "Type"),
|
||||
value: "Successful",
|
||||
inline: true,
|
||||
},
|
||||
],
|
||||
timestamp: date.toISOString(),
|
||||
footer: {
|
||||
text: "Dokploy Restart Notification",
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (gotify) {
|
||||
const decorate = (decoration: string, text: string) =>
|
||||
`${gotify.decoration ? decoration : ""} ${text}\n`;
|
||||
await sendGotifyNotification(
|
||||
gotify,
|
||||
decorate("✅", "Dokploy Server Restarted"),
|
||||
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
|
||||
);
|
||||
try {
|
||||
await sendGotifyNotification(
|
||||
gotify,
|
||||
decorate("✅", "Dokploy Server Restarted"),
|
||||
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (telegram) {
|
||||
await sendTelegramNotification(
|
||||
telegram,
|
||||
`<b>✅ Dokploy Server Restarted</b>\n\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
|
||||
);
|
||||
try {
|
||||
await sendTelegramNotification(
|
||||
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) {
|
||||
const { channel } = slack;
|
||||
await sendSlackNotification(slack, {
|
||||
channel: channel,
|
||||
attachments: [
|
||||
{
|
||||
color: "#00FF00",
|
||||
pretext: ":white_check_mark: *Dokploy Server Restarted*",
|
||||
fields: [
|
||||
{
|
||||
title: "Time",
|
||||
value: date.toLocaleString(),
|
||||
short: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
try {
|
||||
await sendSlackNotification(slack, {
|
||||
channel: channel,
|
||||
attachments: [
|
||||
{
|
||||
color: "#00FF00",
|
||||
pretext: ":white_check_mark: *Dokploy Server Restarted*",
|
||||
fields: [
|
||||
{
|
||||
title: "Time",
|
||||
value: date.toLocaleString(),
|
||||
short: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,48 @@
|
||||
import { exec } from "node:child_process";
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import util from "node:util";
|
||||
import { findServerById } from "@dokploy/server/services/server";
|
||||
import { Client } from "ssh2";
|
||||
|
||||
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 (
|
||||
serverId: string | null,
|
||||
command: string,
|
||||
|
||||
@@ -37,6 +37,7 @@ export const cloneBitbucketRepository = async (
|
||||
bitbucketBranch,
|
||||
bitbucketId,
|
||||
bitbucket,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!bitbucketId) {
|
||||
@@ -53,25 +54,23 @@ export const cloneBitbucketRepository = async (
|
||||
const cloneUrl = `https://${bitbucket?.bitbucketUsername}:${bitbucket?.appPassword}@${repoclone}`;
|
||||
try {
|
||||
writeStream.write(`\nCloning Repo ${repoclone} to ${outputPath}: ✅\n`);
|
||||
await spawnAsync(
|
||||
"git",
|
||||
[
|
||||
"clone",
|
||||
"--branch",
|
||||
bitbucketBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
bitbucketBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
];
|
||||
|
||||
await spawnAsync("git", cloneArgs, (data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
});
|
||||
writeStream.write(`\nCloned ${repoclone} to ${outputPath}: ✅\n`);
|
||||
} catch (error) {
|
||||
writeStream.write(`ERROR Clonning: ${error}: ❌`);
|
||||
@@ -89,6 +88,7 @@ export const cloneRawBitbucketRepository = async (entity: Compose) => {
|
||||
bitbucketOwner,
|
||||
bitbucketBranch,
|
||||
bitbucketId,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!bitbucketId) {
|
||||
@@ -106,17 +106,19 @@ export const cloneRawBitbucketRepository = async (entity: Compose) => {
|
||||
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
|
||||
|
||||
try {
|
||||
await spawnAsync("git", [
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
bitbucketBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
]);
|
||||
];
|
||||
|
||||
await spawnAsync("git", cloneArgs);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -131,6 +133,7 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => {
|
||||
bitbucketBranch,
|
||||
bitbucketId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = compose;
|
||||
|
||||
if (!serverId) {
|
||||
@@ -153,11 +156,11 @@ export const cloneRawBitbucketRepositoryRemote = async (compose: Compose) => {
|
||||
const cloneUrl = `https://${bitbucketProvider?.bitbucketUsername}:${bitbucketProvider?.appPassword}@${repoclone}`;
|
||||
|
||||
try {
|
||||
const command = `
|
||||
const cloneCommand = `
|
||||
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) {
|
||||
throw error;
|
||||
}
|
||||
@@ -176,6 +179,7 @@ export const getBitbucketCloneCommand = async (
|
||||
bitbucketBranch,
|
||||
bitbucketId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!serverId) {
|
||||
@@ -207,7 +211,7 @@ export const getBitbucketCloneCommand = async (
|
||||
const cloneCommand = `
|
||||
rm -rf ${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};
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
@@ -17,12 +17,19 @@ export const cloneGitRepository = async (
|
||||
customGitUrl?: string | null;
|
||||
customGitBranch?: string | null;
|
||||
customGitSSHKeyId?: string | null;
|
||||
enableSubmodules?: boolean;
|
||||
},
|
||||
logPath: string,
|
||||
isCompose = false,
|
||||
) => {
|
||||
const { SSH_PATH, COMPOSE_PATH, APPLICATIONS_PATH } = paths();
|
||||
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity;
|
||||
const {
|
||||
appName,
|
||||
customGitUrl,
|
||||
customGitBranch,
|
||||
customGitSSHKeyId,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!customGitUrl || !customGitBranch) {
|
||||
throw new TRPCError({
|
||||
@@ -70,19 +77,21 @@ export const cloneGitRepository = async (
|
||||
}
|
||||
|
||||
const { port } = sanitizeRepoPathSSH(customGitUrl);
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
customGitBranch,
|
||||
"--depth",
|
||||
"1",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
customGitUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
];
|
||||
|
||||
await spawnAsync(
|
||||
"git",
|
||||
[
|
||||
"clone",
|
||||
"--branch",
|
||||
customGitBranch,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
customGitUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
],
|
||||
cloneArgs,
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
@@ -114,6 +123,7 @@ export const getCustomGitCloneCommand = async (
|
||||
customGitBranch?: string | null;
|
||||
customGitSSHKeyId?: string | null;
|
||||
serverId: string | null;
|
||||
enableSubmodules: boolean;
|
||||
},
|
||||
logPath: string,
|
||||
isCompose = false,
|
||||
@@ -125,6 +135,7 @@ export const getCustomGitCloneCommand = async (
|
||||
customGitBranch,
|
||||
customGitSSHKeyId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!customGitUrl || !customGitBranch) {
|
||||
@@ -181,7 +192,7 @@ export const getCustomGitCloneCommand = async (
|
||||
}
|
||||
|
||||
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};
|
||||
exit 1;
|
||||
fi
|
||||
@@ -261,8 +272,15 @@ export const cloneGitRawRepository = async (entity: {
|
||||
customGitUrl?: string | null;
|
||||
customGitBranch?: string | null;
|
||||
customGitSSHKeyId?: string | null;
|
||||
enableSubmodules?: boolean;
|
||||
}) => {
|
||||
const { appName, customGitUrl, customGitBranch, customGitSSHKeyId } = entity;
|
||||
const {
|
||||
appName,
|
||||
customGitUrl,
|
||||
customGitBranch,
|
||||
customGitSSHKeyId,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!customGitUrl || !customGitBranch) {
|
||||
throw new TRPCError({
|
||||
@@ -307,29 +325,26 @@ export const cloneGitRawRepository = async (entity: {
|
||||
}
|
||||
|
||||
const { port } = sanitizeRepoPathSSH(customGitUrl);
|
||||
await spawnAsync(
|
||||
"git",
|
||||
[
|
||||
"clone",
|
||||
"--branch",
|
||||
customGitBranch,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
customGitUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
],
|
||||
(_data) => {},
|
||||
{
|
||||
env: {
|
||||
...process.env,
|
||||
...(customGitSSHKeyId && {
|
||||
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath}${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`,
|
||||
}),
|
||||
},
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
customGitBranch,
|
||||
"--depth",
|
||||
"1",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
customGitUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
];
|
||||
|
||||
await spawnAsync("git", cloneArgs, (_data) => {}, {
|
||||
env: {
|
||||
...process.env,
|
||||
...(customGitSSHKeyId && {
|
||||
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath}${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
@@ -342,6 +357,7 @@ export const cloneRawGitRepositoryRemote = async (compose: Compose) => {
|
||||
customGitUrl,
|
||||
customGitSSHKeyId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = compose;
|
||||
|
||||
if (!serverId) {
|
||||
@@ -396,7 +412,7 @@ export const cloneRawGitRepositoryRemote = async (compose: Compose) => {
|
||||
}
|
||||
|
||||
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 ";
|
||||
exit 1;
|
||||
fi
|
||||
|
||||
@@ -119,6 +119,7 @@ export const getGiteaCloneCommand = async (
|
||||
giteaRepository,
|
||||
serverId,
|
||||
gitea,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!serverId) {
|
||||
@@ -155,7 +156,7 @@ export const getGiteaCloneCommand = async (
|
||||
rm -rf ${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};
|
||||
exit 1;
|
||||
fi
|
||||
@@ -174,7 +175,14 @@ export const cloneGiteaRepository = async (
|
||||
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths();
|
||||
|
||||
const writeStream = createWriteStream(logPath, { flags: "a" });
|
||||
const { appName, giteaBranch, giteaId, giteaOwner, giteaRepository } = entity;
|
||||
const {
|
||||
appName,
|
||||
giteaBranch,
|
||||
giteaId,
|
||||
giteaOwner,
|
||||
giteaRepository,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!giteaId) {
|
||||
throw new TRPCError({
|
||||
@@ -211,7 +219,7 @@ export const cloneGiteaRepository = async (
|
||||
giteaBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
@@ -232,7 +240,14 @@ export const cloneGiteaRepository = async (
|
||||
};
|
||||
|
||||
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();
|
||||
|
||||
if (!giteaId) {
|
||||
@@ -265,7 +280,7 @@ export const cloneRawGiteaRepository = async (entity: Compose) => {
|
||||
giteaBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
@@ -283,6 +298,7 @@ export const cloneRawGiteaRepositoryRemote = async (compose: Compose) => {
|
||||
giteaBranch,
|
||||
giteaId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = compose;
|
||||
|
||||
if (!serverId) {
|
||||
@@ -307,7 +323,7 @@ export const cloneRawGiteaRepositoryRemote = async (compose: Compose) => {
|
||||
try {
|
||||
const command = `
|
||||
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);
|
||||
} catch (error) {
|
||||
|
||||
@@ -83,6 +83,7 @@ interface CloneGithubRepository {
|
||||
repository: string | null;
|
||||
logPath: string;
|
||||
type?: "application" | "compose";
|
||||
enableSubmodules: boolean;
|
||||
}
|
||||
export const cloneGithubRepository = async ({
|
||||
logPath,
|
||||
@@ -92,7 +93,8 @@ export const cloneGithubRepository = async ({
|
||||
const isCompose = type === "compose";
|
||||
const { APPLICATIONS_PATH, COMPOSE_PATH } = paths();
|
||||
const writeStream = createWriteStream(logPath, { flags: "a" });
|
||||
const { appName, repository, owner, branch, githubId } = entity;
|
||||
const { appName, repository, owner, branch, githubId, enableSubmodules } =
|
||||
entity;
|
||||
|
||||
if (!githubId) {
|
||||
throw new TRPCError({
|
||||
@@ -128,25 +130,23 @@ export const cloneGithubRepository = async ({
|
||||
|
||||
try {
|
||||
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`);
|
||||
await spawnAsync(
|
||||
"git",
|
||||
[
|
||||
"clone",
|
||||
"--branch",
|
||||
branch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
branch!,
|
||||
"--depth",
|
||||
"1",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
];
|
||||
|
||||
await spawnAsync("git", cloneArgs, (data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
});
|
||||
writeStream.write(`\nCloned ${repoclone}: ✅\n`);
|
||||
} catch (error) {
|
||||
writeStream.write(`ERROR Clonning: ${error}: ❌`);
|
||||
@@ -161,7 +161,15 @@ export const getGithubCloneCommand = async ({
|
||||
type = "application",
|
||||
...entity
|
||||
}: CloneGithubRepository & { serverId: string }) => {
|
||||
const { appName, repository, owner, branch, githubId, serverId } = entity;
|
||||
const {
|
||||
appName,
|
||||
repository,
|
||||
owner,
|
||||
branch,
|
||||
githubId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
const isCompose = type === "compose";
|
||||
if (!serverId) {
|
||||
throw new TRPCError({
|
||||
@@ -216,7 +224,7 @@ export const getGithubCloneCommand = async ({
|
||||
const cloneCommand = `
|
||||
rm -rf ${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};
|
||||
exit 1;
|
||||
fi
|
||||
@@ -227,7 +235,8 @@ echo "Cloned ${repoclone} to ${outputPath}: ✅" >> ${logPath};
|
||||
};
|
||||
|
||||
export const cloneRawGithubRepository = async (entity: Compose) => {
|
||||
const { appName, repository, owner, branch, githubId } = entity;
|
||||
const { appName, repository, owner, branch, githubId, enableSubmodules } =
|
||||
entity;
|
||||
|
||||
if (!githubId) {
|
||||
throw new TRPCError({
|
||||
@@ -245,24 +254,33 @@ export const cloneRawGithubRepository = async (entity: Compose) => {
|
||||
await recreateDirectory(outputPath);
|
||||
const cloneUrl = `https://oauth2:${token}@${repoclone}`;
|
||||
try {
|
||||
await spawnAsync("git", [
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
branch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
]);
|
||||
];
|
||||
await spawnAsync("git", cloneArgs);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
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) {
|
||||
throw new TRPCError({
|
||||
@@ -288,7 +306,7 @@ export const cloneRawGithubRepositoryRemote = async (compose: Compose) => {
|
||||
try {
|
||||
const command = `
|
||||
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);
|
||||
} catch (error) {
|
||||
|
||||
@@ -90,8 +90,14 @@ export const cloneGitlabRepository = async (
|
||||
isCompose = false,
|
||||
) => {
|
||||
const writeStream = createWriteStream(logPath, { flags: "a" });
|
||||
const { appName, gitlabBranch, gitlabId, gitlab, gitlabPathNamespace } =
|
||||
entity;
|
||||
const {
|
||||
appName,
|
||||
gitlabBranch,
|
||||
gitlabId,
|
||||
gitlab,
|
||||
gitlabPathNamespace,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!gitlabId) {
|
||||
throw new TRPCError({
|
||||
@@ -127,25 +133,23 @@ export const cloneGitlabRepository = async (
|
||||
|
||||
try {
|
||||
writeStream.write(`\nClonning Repo ${repoclone} to ${outputPath}: ✅\n`);
|
||||
await spawnAsync(
|
||||
"git",
|
||||
[
|
||||
"clone",
|
||||
"--branch",
|
||||
gitlabBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
],
|
||||
(data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
gitlabBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
];
|
||||
|
||||
await spawnAsync("git", cloneArgs, (data) => {
|
||||
if (writeStream.writable) {
|
||||
writeStream.write(data);
|
||||
}
|
||||
});
|
||||
writeStream.write(`\nCloned ${repoclone}: ✅\n`);
|
||||
} catch (error) {
|
||||
writeStream.write(`ERROR Clonning: ${error}: ❌`);
|
||||
@@ -167,6 +171,7 @@ export const getGitlabCloneCommand = async (
|
||||
gitlabId,
|
||||
serverId,
|
||||
gitlab,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!serverId) {
|
||||
@@ -222,7 +227,7 @@ export const getGitlabCloneCommand = async (
|
||||
const cloneCommand = `
|
||||
rm -rf ${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};
|
||||
exit 1;
|
||||
fi
|
||||
@@ -264,7 +269,11 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
|
||||
const groupName = gitlabProvider.groupName?.toLowerCase();
|
||||
|
||||
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";
|
||||
});
|
||||
@@ -326,7 +335,13 @@ export const getGitlabBranches = async (input: {
|
||||
};
|
||||
|
||||
export const cloneRawGitlabRepository = async (entity: Compose) => {
|
||||
const { appName, gitlabBranch, gitlabId, gitlabPathNamespace } = entity;
|
||||
const {
|
||||
appName,
|
||||
gitlabBranch,
|
||||
gitlabId,
|
||||
gitlabPathNamespace,
|
||||
enableSubmodules,
|
||||
} = entity;
|
||||
|
||||
if (!gitlabId) {
|
||||
throw new TRPCError({
|
||||
@@ -347,24 +362,32 @@ export const cloneRawGitlabRepository = async (entity: Compose) => {
|
||||
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
|
||||
|
||||
try {
|
||||
await spawnAsync("git", [
|
||||
const cloneArgs = [
|
||||
"clone",
|
||||
"--branch",
|
||||
gitlabBranch!,
|
||||
"--depth",
|
||||
"1",
|
||||
"--recurse-submodules",
|
||||
...(enableSubmodules ? ["--recurse-submodules"] : []),
|
||||
cloneUrl,
|
||||
outputPath,
|
||||
"--progress",
|
||||
]);
|
||||
];
|
||||
await spawnAsync("git", cloneArgs);
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => {
|
||||
const { appName, gitlabPathNamespace, branch, gitlabId, serverId } = compose;
|
||||
const {
|
||||
appName,
|
||||
gitlabPathNamespace,
|
||||
branch,
|
||||
gitlabId,
|
||||
serverId,
|
||||
enableSubmodules,
|
||||
} = compose;
|
||||
|
||||
if (!serverId) {
|
||||
throw new TRPCError({
|
||||
@@ -388,7 +411,7 @@ export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => {
|
||||
try {
|
||||
const command = `
|
||||
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);
|
||||
} catch (error) {
|
||||
@@ -431,7 +454,9 @@ export const testGitlabConnection = async (
|
||||
const { full_path, kind } = repo.namespace;
|
||||
|
||||
if (groupName) {
|
||||
return full_path.toLowerCase().includes(groupName) && kind === "group";
|
||||
return groupName
|
||||
.split(",")
|
||||
.some((name) => full_path.toLowerCase().includes(name));
|
||||
}
|
||||
return kind === "user";
|
||||
});
|
||||
|
||||
@@ -45,7 +45,7 @@ export const restoreWebServerBackup = async (
|
||||
|
||||
// Extract backup
|
||||
emit("Extracting backup...");
|
||||
await execAsync(`cd ${tempDir} && unzip ${backupFile}`);
|
||||
await execAsync(`cd ${tempDir} && unzip ${backupFile} > /dev/null 2>&1`);
|
||||
|
||||
// Restore filesystem first
|
||||
emit("Restoring filesystem...");
|
||||
@@ -83,44 +83,54 @@ export const restoreWebServerBackup = async (
|
||||
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
|
||||
emit("Disconnecting all users from database...");
|
||||
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...");
|
||||
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...");
|
||||
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
|
||||
emit("Copying backup file into container...");
|
||||
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
|
||||
emit("Verifying file in container...");
|
||||
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
|
||||
emit("Running database restore...");
|
||||
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
|
||||
emit("Cleaning up container temp file...");
|
||||
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!");
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user