mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-04 03:02:16 +00:00
Merge branch 'refs/heads/canary' into feature/custom-entrypoint
# Conflicts: # apps/dokploy/drizzle/meta/0120_snapshot.json # apps/dokploy/drizzle/meta/_journal.json
This commit is contained in:
+10
-10
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
deployRemoteApplication,
|
||||
deployRemoteCompose,
|
||||
deployRemotePreviewApplication,
|
||||
rebuildRemoteApplication,
|
||||
rebuildRemoteCompose,
|
||||
deployApplication,
|
||||
deployCompose,
|
||||
deployPreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -16,13 +16,13 @@ export const deploy = async (job: DeployJob) => {
|
||||
await updateApplicationStatus(job.applicationId, "running");
|
||||
if (job.server) {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildRemoteApplication({
|
||||
await rebuildApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Rebuild deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployRemoteApplication({
|
||||
await deployApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Manual deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
@@ -36,13 +36,13 @@ export const deploy = async (job: DeployJob) => {
|
||||
|
||||
if (job.server) {
|
||||
if (job.type === "redeploy") {
|
||||
await rebuildRemoteCompose({
|
||||
await rebuildCompose({
|
||||
composeId: job.composeId,
|
||||
titleLog: job.titleLog || "Rebuild deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
});
|
||||
} else if (job.type === "deploy") {
|
||||
await deployRemoteCompose({
|
||||
await deployCompose({
|
||||
composeId: job.composeId,
|
||||
titleLog: job.titleLog || "Manual deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
@@ -55,7 +55,7 @@ export const deploy = async (job: DeployJob) => {
|
||||
});
|
||||
if (job.server) {
|
||||
if (job.type === "deploy") {
|
||||
await deployRemotePreviewApplication({
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.applicationId,
|
||||
titleLog: job.titleLog || "Preview Deployment",
|
||||
descriptionLog: job.descriptionLog || "",
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractCommitMessage } from "@/pages/api/deploy/[refreshToken]";
|
||||
import {
|
||||
extractCommitMessage,
|
||||
extractImageName,
|
||||
extractImageTag,
|
||||
extractImageTagFromRequest,
|
||||
} from "@/pages/api/deploy/[refreshToken]";
|
||||
|
||||
describe("GitHub Webhook Skip CI", () => {
|
||||
const mockGithubHeaders = {
|
||||
@@ -96,3 +101,308 @@ describe("GitHub Webhook Skip CI", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub Packages Docker Image Tag Extraction", () => {
|
||||
it("should extract tag from container_metadata", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "v1.0.0",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:v1.0.0",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("v1.0.0");
|
||||
});
|
||||
|
||||
it("should extract tag from package_url when container_metadata tag matches version", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "sha256:abc123...",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("latest");
|
||||
});
|
||||
|
||||
it("should extract tag from package_url when container_metadata is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo:1.2.3",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("should handle different tag formats in package_url", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const testCases = [
|
||||
{ url: "ghcr.io/owner/repo:latest", expected: "latest" },
|
||||
{ url: "ghcr.io/owner/repo:v1.0.0", expected: "v1.0.0" },
|
||||
{ url: "ghcr.io/owner/repo:1.2.3", expected: "1.2.3" },
|
||||
{ url: "ghcr.io/owner/repo:dev", expected: "dev" },
|
||||
];
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: testCase.url,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null for non-registry_package events", () => {
|
||||
const headers = { "x-github-event": "push" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_version is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_url has no tag", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when package_url ends with colon (no tag)", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
package_url: "ghcr.io/owner/repo:",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when tag name is empty string", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBeNull();
|
||||
});
|
||||
|
||||
it("should ignore tag if it matches the version (digest)", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
container_metadata: {
|
||||
tag: {
|
||||
name: "sha256:abc123...",
|
||||
digest: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tag = extractImageTagFromRequest(headers, body);
|
||||
expect(tag).toBe("latest");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message with package_url", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
package_url: "ghcr.io/owner/repo:latest",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("Docker GHCR image pushed: ghcr.io/owner/repo:latest");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message when package_url is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {
|
||||
package_version: {
|
||||
version: "sha256:abc123...",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("Docker GHCR image pushed");
|
||||
});
|
||||
|
||||
it("should handle registry_package commit message when package_version is missing", () => {
|
||||
const headers = { "x-github-event": "registry_package" };
|
||||
const body = {
|
||||
registry_package: {},
|
||||
};
|
||||
|
||||
const message = extractCommitMessage(headers, body);
|
||||
expect(message).toBe("NEW COMMIT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Docker Image Name and Tag Extraction", () => {
|
||||
describe("extractImageName", () => {
|
||||
it("should return image name without tag", () => {
|
||||
expect(extractImageName("my-image:latest")).toBe("my-image");
|
||||
expect(extractImageName("my-image:1.0.0")).toBe("my-image");
|
||||
expect(extractImageName("ghcr.io/owner/repo:latest")).toBe(
|
||||
"ghcr.io/owner/repo",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return full image name when no tag is present", () => {
|
||||
expect(extractImageName("my-image")).toBe("my-image");
|
||||
expect(extractImageName("ghcr.io/owner/repo")).toBe("ghcr.io/owner/repo");
|
||||
});
|
||||
|
||||
it("should handle images with port numbers correctly", () => {
|
||||
expect(extractImageName("registry:5000/image:tag")).toBe(
|
||||
"registry:5000/image",
|
||||
);
|
||||
expect(extractImageName("localhost:5000/my-app:latest")).toBe(
|
||||
"localhost:5000/my-app",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle complex image paths", () => {
|
||||
expect(
|
||||
extractImageName("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||
).toBe("myregistryhost:5000/fedora/httpd");
|
||||
expect(extractImageName("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||
"registry.example.com:8080/ns/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid inputs", () => {
|
||||
expect(extractImageName(null)).toBeNull();
|
||||
expect(extractImageName("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle edge cases with multiple colons", () => {
|
||||
expect(extractImageName("image:tag:extra")).toBe("image:tag");
|
||||
expect(extractImageName("registry:5000:invalid")).toBe("registry:5000");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractImageTag", () => {
|
||||
it("should extract tag from image with tag", () => {
|
||||
expect(extractImageTag("my-image:latest")).toBe("latest");
|
||||
expect(extractImageTag("my-image:1.0.0")).toBe("1.0.0");
|
||||
expect(extractImageTag("ghcr.io/owner/repo:v1.2.3")).toBe("v1.2.3");
|
||||
});
|
||||
|
||||
it("should return 'latest' when no tag is present", () => {
|
||||
expect(extractImageTag("my-image")).toBe("latest");
|
||||
expect(extractImageTag("ghcr.io/owner/repo")).toBe("latest");
|
||||
});
|
||||
|
||||
it("should handle complex image paths with tags", () => {
|
||||
expect(
|
||||
extractImageTag("myregistryhost:5000/fedora/httpd:version1.0"),
|
||||
).toBe("version1.0");
|
||||
expect(extractImageTag("registry.example.com:8080/ns/app:v1.2.3")).toBe(
|
||||
"v1.2.3",
|
||||
);
|
||||
});
|
||||
|
||||
it("should return null for invalid inputs", () => {
|
||||
expect(extractImageTag(null)).toBeNull();
|
||||
expect(extractImageTag("")).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle edge cases with multiple colons", () => {
|
||||
expect(extractImageTag("image:tag:extra")).toBe("extra");
|
||||
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
|
||||
});
|
||||
|
||||
it("should handle numeric tags", () => {
|
||||
expect(extractImageTag("my-image:123")).toBe("123");
|
||||
expect(extractImageTag("my-image:1")).toBe("1");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,7 @@ const baseApp: ApplicationNested = {
|
||||
triggerType: "push",
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
endpointSpecSwarm: null,
|
||||
serverId: "",
|
||||
registryUrl: "",
|
||||
branch: null,
|
||||
|
||||
+310
-1
@@ -1,4 +1,7 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import {
|
||||
prepareEnvironmentVariables,
|
||||
prepareEnvironmentVariablesForShell,
|
||||
} from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
@@ -332,4 +335,310 @@ IS_DEV=\${{environment.DEVELOPMENT}}
|
||||
"IS_DEV=0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with single quotes in values", () => {
|
||||
const envWithSingleQuotes = `
|
||||
ENV_VARIABLE='ENVITONME'NT'
|
||||
ANOTHER_VAR='value with 'quotes' inside'
|
||||
SIMPLE_VAR=no-quotes
|
||||
`;
|
||||
|
||||
const serviceWithSingleQuotes = `
|
||||
TEST_VAR=\${{environment.ENV_VARIABLE}}
|
||||
ANOTHER_TEST=\${{environment.ANOTHER_VAR}}
|
||||
SIMPLE=\${{environment.SIMPLE_VAR}}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariables(
|
||||
serviceWithSingleQuotes,
|
||||
"",
|
||||
envWithSingleQuotes,
|
||||
);
|
||||
|
||||
expect(resolved).toEqual([
|
||||
"TEST_VAR=ENVITONME'NT",
|
||||
"ANOTHER_TEST=value with 'quotes' inside",
|
||||
"SIMPLE=no-quotes",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareEnvironmentVariablesForShell (shell escaping)", () => {
|
||||
it("escapes single quotes in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
ENV_VARIABLE='ENVITONME'NT'
|
||||
ANOTHER_VAR='value with 'quotes' inside'
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote should wrap these in double quotes
|
||||
expect(resolved).toEqual([
|
||||
`"ENV_VARIABLE=ENVITONME'NT"`,
|
||||
`"ANOTHER_VAR=value with 'quotes' inside"`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("escapes double quotes in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
MESSAGE="Hello "World""
|
||||
QUOTED_PATH="/path/to/"file""
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote wraps in single quotes when there are double quotes inside
|
||||
expect(resolved).toEqual([
|
||||
`'MESSAGE=Hello "World"'`,
|
||||
`'QUOTED_PATH=/path/to/"file"'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("escapes dollar signs in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
PRICE=$100
|
||||
VARIABLE=$HOME/path
|
||||
TEMPLATE=Hello $USER
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Dollar signs should be escaped to prevent variable expansion
|
||||
for (const env of resolved) {
|
||||
expect(env).toContain("$");
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes backticks in environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
COMMAND=\`echo "test"\`
|
||||
NESTED=value with \`backticks\` inside
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Backticks are escaped/removed by dotenv parsing, but values should be safely quoted
|
||||
expect(resolved.length).toBe(2);
|
||||
expect(resolved[0]).toContain("COMMAND");
|
||||
expect(resolved[1]).toContain("NESTED");
|
||||
});
|
||||
|
||||
it("handles environment variables with spaces", () => {
|
||||
const serviceEnv = `
|
||||
FULL_NAME="John Doe"
|
||||
MESSAGE='Hello World'
|
||||
SENTENCE=This is a test
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote uses single quotes for strings with spaces
|
||||
expect(resolved).toEqual([
|
||||
`'FULL_NAME=John Doe'`,
|
||||
`'MESSAGE=Hello World'`,
|
||||
`'SENTENCE=This is a test'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with backslashes", () => {
|
||||
const serviceEnv = `
|
||||
WINDOWS_PATH=C:\\Users\\Documents
|
||||
ESCAPED=value\\with\\backslashes
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// Backslashes should be properly escaped
|
||||
expect(resolved.length).toBe(2);
|
||||
for (const env of resolved) {
|
||||
expect(env).toContain("\\");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles simple environment variables without special characters", () => {
|
||||
const serviceEnv = `
|
||||
NODE_ENV=production
|
||||
PORT=3000
|
||||
DEBUG=true
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote escapes the = sign in some cases
|
||||
expect(resolved).toEqual([
|
||||
"NODE_ENV\\=production",
|
||||
"PORT\\=3000",
|
||||
"DEBUG\\=true",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with mixed special characters", () => {
|
||||
const serviceEnv = `
|
||||
COMPLEX='value with "double" and 'single' quotes'
|
||||
BASH_COMMAND=echo "$HOME" && echo 'test'
|
||||
WEIRD=\`echo "$VAR"\` with 'quotes' and "more"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// All should be escaped, none should throw errors
|
||||
expect(resolved.length).toBe(3);
|
||||
// Verify each can be safely used in shell
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
expect(env.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with newlines", () => {
|
||||
const serviceEnv = `
|
||||
MULTILINE="line1
|
||||
line2
|
||||
line3"
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0]).toContain("MULTILINE");
|
||||
});
|
||||
|
||||
it("handles empty environment variable values", () => {
|
||||
const serviceEnv = `
|
||||
EMPTY=
|
||||
EMPTY_QUOTED=""
|
||||
EMPTY_SINGLE=''
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
// shell-quote escapes the = sign for empty values
|
||||
expect(resolved).toEqual([
|
||||
"EMPTY\\=",
|
||||
"EMPTY_QUOTED\\=",
|
||||
"EMPTY_SINGLE\\=",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles environment variables with equals signs in values", () => {
|
||||
const serviceEnv = `
|
||||
EQUATION=a=b+c
|
||||
CONNECTION_STRING=user=admin;password=test
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(2);
|
||||
expect(resolved[0]).toContain("EQUATION");
|
||||
expect(resolved[1]).toContain("CONNECTION_STRING");
|
||||
});
|
||||
|
||||
it("resolves and escapes environment variables together", () => {
|
||||
const projectEnv = `
|
||||
BASE_URL=https://example.com
|
||||
API_KEY='secret-key-with-quotes'
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
ENV_NAME=production
|
||||
DB_PASS='pa$$word'
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
FULL_URL=\${{project.BASE_URL}}/api
|
||||
AUTH_KEY=\${{project.API_KEY}}
|
||||
ENVIRONMENT=\${{environment.ENV_NAME}}
|
||||
DB_PASSWORD=\${{environment.DB_PASS}}
|
||||
CUSTOM='value with 'quotes' inside'
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(
|
||||
serviceEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(resolved.length).toBe(5);
|
||||
// All resolved values should be properly escaped
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with semicolons and ampersands", () => {
|
||||
const serviceEnv = `
|
||||
COMMAND=echo "test" && echo "test2"
|
||||
MULTIPLE=cmd1; cmd2; cmd3
|
||||
URL_WITH_PARAMS=https://example.com?a=1&b=2&c=3
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
// These should be safely escaped to prevent command injection
|
||||
for (const env of resolved) {
|
||||
expect(typeof env).toBe("string");
|
||||
expect(env.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("handles environment variables with pipes and redirects", () => {
|
||||
const serviceEnv = `
|
||||
PIPE_COMMAND=cat file | grep test
|
||||
REDIRECT=echo "test" > output.txt
|
||||
BOTH=cat input.txt | grep pattern > output.txt
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
// Pipes and redirects should be safely quoted
|
||||
expect(resolved[0]).toContain("PIPE_COMMAND");
|
||||
expect(resolved[1]).toContain("REDIRECT");
|
||||
expect(resolved[2]).toContain("BOTH");
|
||||
// At least one should contain a pipe
|
||||
const hasPipe = resolved.some((env) => env.includes("|"));
|
||||
expect(hasPipe).toBe(true);
|
||||
});
|
||||
|
||||
it("handles environment variables with parentheses and brackets", () => {
|
||||
const serviceEnv = `
|
||||
MATH=(a+b)*c
|
||||
ARRAY=[1,2,3]
|
||||
JSON={"key":"value"}
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
expect(resolved[0]).toContain("(");
|
||||
expect(resolved[1]).toContain("[");
|
||||
expect(resolved[2]).toContain("{");
|
||||
});
|
||||
|
||||
it("handles very long environment variable values", () => {
|
||||
const longValue = "a".repeat(10000);
|
||||
const serviceEnv = `LONG_VAR=${longValue}`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(1);
|
||||
expect(resolved[0]).toContain("LONG_VAR");
|
||||
expect(resolved[0]?.length).toBeGreaterThan(10000);
|
||||
});
|
||||
|
||||
it("handles special unicode characters in environment variables", () => {
|
||||
const serviceEnv = `
|
||||
EMOJI=Hello 🌍 World 🚀
|
||||
CHINESE=你好世界
|
||||
SPECIAL=café résumé naïve
|
||||
`;
|
||||
|
||||
const resolved = prepareEnvironmentVariablesForShell(serviceEnv, "", "");
|
||||
|
||||
expect(resolved.length).toBe(3);
|
||||
expect(resolved[0]).toContain("🌍");
|
||||
expect(resolved[1]).toContain("你好");
|
||||
expect(resolved[2]).toContain("café");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ const baseApp: ApplicationNested = {
|
||||
giteaId: "",
|
||||
cleanCache: false,
|
||||
applicationStatus: "done",
|
||||
endpointSpecSwarm: null,
|
||||
appName: "",
|
||||
autoDeploy: true,
|
||||
enableSubmodules: false,
|
||||
|
||||
+85
@@ -122,6 +122,22 @@ const NetworkSwarmSchema = z.array(
|
||||
|
||||
const LabelsSwarmSchema = z.record(z.string());
|
||||
|
||||
const EndpointPortConfigSwarmSchema = z
|
||||
.object({
|
||||
Protocol: z.string().optional(),
|
||||
TargetPort: z.number().optional(),
|
||||
PublishedPort: z.number().optional(),
|
||||
PublishMode: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const EndpointSpecSwarmSchema = z
|
||||
.object({
|
||||
Mode: z.string().optional(),
|
||||
Ports: z.array(EndpointPortConfigSwarmSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const createStringToJSONSchema = (schema: z.ZodTypeAny) => {
|
||||
return z
|
||||
.string()
|
||||
@@ -178,6 +194,9 @@ const addSwarmSettings = z.object({
|
||||
labelsSwarm: createStringToJSONSchema(LabelsSwarmSchema).nullable(),
|
||||
networkSwarm: createStringToJSONSchema(NetworkSwarmSchema).nullable(),
|
||||
stopGracePeriodSwarm: z.bigint().nullable(),
|
||||
endpointSpecSwarm: createStringToJSONSchema(
|
||||
EndpointSpecSwarmSchema,
|
||||
).nullable(),
|
||||
});
|
||||
|
||||
type AddSwarmSettings = z.infer<typeof addSwarmSettings>;
|
||||
@@ -234,6 +253,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
labelsSwarm: null,
|
||||
networkSwarm: null,
|
||||
stopGracePeriodSwarm: null,
|
||||
endpointSpecSwarm: null,
|
||||
},
|
||||
resolver: zodResolver(addSwarmSettings),
|
||||
});
|
||||
@@ -275,6 +295,9 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
? JSON.stringify(data.networkSwarm, null, 2)
|
||||
: null,
|
||||
stopGracePeriodSwarm: normalizedStopGracePeriod,
|
||||
endpointSpecSwarm: data.endpointSpecSwarm
|
||||
? JSON.stringify(data.endpointSpecSwarm, null, 2)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
}, [form, form.reset, data]);
|
||||
@@ -296,6 +319,7 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
labelsSwarm: data.labelsSwarm,
|
||||
networkSwarm: data.networkSwarm,
|
||||
stopGracePeriodSwarm: data.stopGracePeriodSwarm ?? null,
|
||||
endpointSpecSwarm: data.endpointSpecSwarm,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success("Swarm settings updated");
|
||||
@@ -846,6 +870,67 @@ export const AddSwarmSettings = ({ id, type }: Props) => {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endpointSpecSwarm"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative ">
|
||||
<FormLabel>Endpoint Spec</FormLabel>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<FormDescription className="break-all w-fit flex flex-row gap-1 items-center">
|
||||
Check the interface
|
||||
<HelpCircle className="size-4 text-muted-foreground" />
|
||||
</FormDescription>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="w-full z-[999]"
|
||||
align="start"
|
||||
side="bottom"
|
||||
>
|
||||
<code>
|
||||
<pre>
|
||||
{`{
|
||||
Mode?: string | undefined;
|
||||
Ports?: Array<{
|
||||
Protocol?: string | undefined;
|
||||
TargetPort?: number | undefined;
|
||||
PublishedPort?: number | undefined;
|
||||
PublishMode?: string | undefined;
|
||||
}> | undefined;
|
||||
}`}
|
||||
</pre>
|
||||
</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<FormControl>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
placeholder={`{
|
||||
"Mode": "dnsrr",
|
||||
"Ports": [
|
||||
{
|
||||
"Protocol": "tcp",
|
||||
"TargetPort": 5432,
|
||||
"PublishedPort": 5432,
|
||||
"PublishMode": "host"
|
||||
}
|
||||
]
|
||||
}`}
|
||||
className="h-[17rem] font-mono"
|
||||
{...field}
|
||||
value={field?.value || ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<pre>
|
||||
<FormMessage />
|
||||
</pre>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="flex w-full flex-row justify-end md:col-span-2 m-0 sticky bottom-0 right-0 bg-muted border">
|
||||
<Button
|
||||
isLoading={isLoading}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Scissors } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
type: "application" | "compose";
|
||||
}
|
||||
|
||||
export const KillBuild = ({ id, type }: Props) => {
|
||||
const { mutateAsync, isLoading } =
|
||||
type === "application"
|
||||
? api.application.killBuild.useMutation()
|
||||
: api.compose.killBuild.useMutation();
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="outline" className="w-fit" isLoading={isLoading}>
|
||||
Kill Build
|
||||
<Scissors className="size-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure to kill the build?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will kill the build process
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={async () => {
|
||||
await mutateAsync({
|
||||
applicationId: id || "",
|
||||
composeId: id || "",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Build killed successfully");
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Confirm
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Loader2,
|
||||
RefreshCcw,
|
||||
RocketIcon,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
@@ -17,6 +25,7 @@ import {
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
|
||||
import { CancelQueues } from "./cancel-queues";
|
||||
import { KillBuild } from "./kill-build";
|
||||
import { RefreshToken } from "./refresh-token";
|
||||
import { ShowDeployment } from "./show-deployment";
|
||||
|
||||
@@ -80,6 +89,23 @@ export const ShowDeployments = ({
|
||||
} = api.compose.cancelDeployment.useMutation();
|
||||
|
||||
const [url, setUrl] = React.useState("");
|
||||
const [expandedDescriptions, setExpandedDescriptions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const MAX_DESCRIPTION_LENGTH = 200;
|
||||
|
||||
const truncateDescription = (description: string): string => {
|
||||
if (description.length <= MAX_DESCRIPTION_LENGTH) {
|
||||
return description;
|
||||
}
|
||||
const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);
|
||||
const lastSpace = truncated.lastIndexOf(" ");
|
||||
if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) {
|
||||
return `${truncated.slice(0, lastSpace)}...`;
|
||||
}
|
||||
return `${truncated}...`;
|
||||
};
|
||||
|
||||
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
|
||||
const stuckDeployment = useMemo(() => {
|
||||
@@ -118,6 +144,9 @@ export const ShowDeployments = ({
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{(type === "application" || type === "compose") && (
|
||||
<KillBuild id={id} type={type} />
|
||||
)}
|
||||
{(type === "application" || type === "compose") && (
|
||||
<CancelQueues id={id} type={type} />
|
||||
)}
|
||||
@@ -217,118 +246,164 @@ export const ShowDeployments = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{deployments?.map((deployment, index) => (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{deployment.title}
|
||||
</span>
|
||||
{deployment.description && (
|
||||
<span className="break-all text-sm text-muted-foreground">
|
||||
{deployment.description}
|
||||
{deployments?.map((deployment, index) => {
|
||||
const titleText = deployment?.title?.trim() || "";
|
||||
const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH;
|
||||
const isExpanded = expandedDescriptions.has(
|
||||
deployment.deploymentId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={deployment.deploymentId}
|
||||
className="flex items-center justify-between rounded-lg border p-4 gap-2"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
|
||||
{index + 1}. {deployment.status}
|
||||
<StatusTooltip
|
||||
status={deployment?.status}
|
||||
className="size-2.5"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-1 flex items-center"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatDuration(
|
||||
Math.floor(
|
||||
(new Date(deployment.finishedAt).getTime() -
|
||||
new Date(deployment.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success("Process killed successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error killing process");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isKillingProcess}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="break-words text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{isExpanded || !needsTruncation
|
||||
? titleText
|
||||
: truncateDescription(titleText)}
|
||||
</span>
|
||||
{needsTruncation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const next = new Set(expandedDescriptions);
|
||||
if (next.has(deployment.deploymentId)) {
|
||||
next.delete(deployment.deploymentId);
|
||||
} else {
|
||||
next.add(deployment.deploymentId);
|
||||
}
|
||||
setExpandedDescriptions(next);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors w-fit mt-1 cursor-pointer"
|
||||
aria-label={
|
||||
isExpanded
|
||||
? "Collapse commit message"
|
||||
: "Expand commit message"
|
||||
}
|
||||
>
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
{isExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="size-3" />
|
||||
Show less
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="size-3" />
|
||||
Show more
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Hash (from description) - shown in compact form */}
|
||||
{deployment.description?.trim() && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{deployment.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2 max-w-[300px] w-full justify-start">
|
||||
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
|
||||
<DateTooltip date={deployment.createdAt} />
|
||||
{deployment.startedAt && deployment.finishedAt && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] gap-1 flex items-center"
|
||||
>
|
||||
<Clock className="size-3" />
|
||||
{formatDuration(
|
||||
Math.floor(
|
||||
(new Date(deployment.finishedAt).getTime() -
|
||||
new Date(deployment.startedAt).getTime()) /
|
||||
1000,
|
||||
),
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{deployment.pid && deployment.status === "running" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
title="Kill Process"
|
||||
description="Are you sure you want to kill the process?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await rollback({
|
||||
rollbackId: deployment.rollback.rollbackId,
|
||||
await killProcess({
|
||||
deploymentId: deployment.deploymentId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Rollback initiated successfully",
|
||||
);
|
||||
toast.success("Process killed successfully");
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error initiating rollback");
|
||||
toast.error("Error killing process");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
isLoading={isKillingProcess}
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
Kill Process
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setActiveLog(deployment);
|
||||
}}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
|
||||
{deployment?.rollback &&
|
||||
deployment.status === "done" &&
|
||||
type === "application" && (
|
||||
<DialogAction
|
||||
title="Rollback to this deployment"
|
||||
description="Are you sure you want to rollback to this deployment?"
|
||||
type="default"
|
||||
onClick={async () => {
|
||||
await rollback({
|
||||
rollbackId: deployment.rollback.rollbackId,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(
|
||||
"Rollback initiated successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error initiating rollback");
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
isLoading={isRollingBack}
|
||||
>
|
||||
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
|
||||
Rollback
|
||||
</Button>
|
||||
</DialogAction>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<ShowDeployment
|
||||
|
||||
+10
-1
@@ -182,7 +182,16 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
|
||||
id={deployment.previewDeploymentId}
|
||||
type="previewDeployment"
|
||||
serverId={data?.serverId || ""}
|
||||
/>
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-2"
|
||||
>
|
||||
<RocketIcon className="size-4" />
|
||||
Deployments
|
||||
</Button>
|
||||
</ShowDeploymentsModal>
|
||||
|
||||
<AddPreviewDomain
|
||||
previewDeploymentId={`${deployment.previewDeploymentId}`}
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ export const ShowVolumeBackups = ({
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Schedule volume backups to run automatically at specified
|
||||
intervals.
|
||||
intervals
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import copy from "copy-to-clipboard";
|
||||
import { debounce } from "lodash";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronsUpDown,
|
||||
@@ -236,7 +236,7 @@ export const RestoreBackup = ({
|
||||
const currentDatabaseType = form.watch("databaseType");
|
||||
const metadata = form.watch("metadata");
|
||||
|
||||
const debouncedSetSearch = debounce((value: string) => {
|
||||
const debouncedSetSearch = _.debounce((value: string) => {
|
||||
setDebouncedSearchTerm(value);
|
||||
}, 350);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FancyAnsi } from "fancy-ansi";
|
||||
import { escapeRegExp } from "lodash";
|
||||
import _ from "lodash";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -47,7 +47,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
|
||||
}
|
||||
|
||||
const htmlContent = fancyAnsi.toHtml(text);
|
||||
const searchRegex = new RegExp(`(${escapeRegExp(term)})`, "gi");
|
||||
const searchRegex = new RegExp(`(${_.escapeRegExp(term)})`, "gi");
|
||||
|
||||
const modifiedContent = htmlContent.replace(
|
||||
searchRegex,
|
||||
|
||||
@@ -150,8 +150,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Frontend"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
|
||||
@@ -161,8 +161,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Frontend"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
|
||||
@@ -395,8 +395,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
|
||||
placeholder="Name"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value?.trim() || "";
|
||||
const serviceName = slugify(val);
|
||||
const val = e.target.value || "";
|
||||
const serviceName = slugify(val.trim());
|
||||
form.setValue("appName", `${slug}-${serviceName}`);
|
||||
field.onChange(val);
|
||||
}}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar";
|
||||
import { DateTooltip } from "@/components/shared/date-tooltip";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import { StatusTooltip } from "@/components/shared/status-tooltip";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -44,7 +45,6 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -52,12 +52,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { TimeBadge } from "@/components/ui/time-badge";
|
||||
import { api } from "@/utils/api";
|
||||
import { HandleProject } from "./handle-project";
|
||||
import { ProjectEnvironment } from "./project-environment";
|
||||
|
||||
export const ShowProjects = () => {
|
||||
const utils = api.useUtils();
|
||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||
const { data, isLoading } = api.project.all.useQuery();
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const { mutateAsync } = api.project.remove.useMutation();
|
||||
@@ -135,6 +137,11 @@ export const ShowProjects = () => {
|
||||
<BreadcrumbSidebar
|
||||
list={[{ name: "Projects", href: "/dashboard/projects" }]}
|
||||
/>
|
||||
{!isCloud && (
|
||||
<div className="absolute top-5 right-5">
|
||||
<TimeBadge />
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full">
|
||||
<Card className="h-full bg-sidebar p-2.5 rounded-xl ">
|
||||
<div className="rounded-xl bg-background shadow-md ">
|
||||
@@ -148,7 +155,6 @@ export const ShowProjects = () => {
|
||||
Create and manage your projects
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
{(auth?.role === "owner" || auth?.canCreateProjects) && (
|
||||
<div className="">
|
||||
<HandleProject />
|
||||
@@ -298,7 +304,13 @@ export const ShowProjects = () => {
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
href={`${
|
||||
domain.https
|
||||
? "https"
|
||||
: "http"
|
||||
}://${domain.host}${
|
||||
domain.path
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{domain.host}
|
||||
@@ -340,7 +352,13 @@ export const ShowProjects = () => {
|
||||
<Link
|
||||
className="space-x-4 text-xs cursor-pointer justify-between"
|
||||
target="_blank"
|
||||
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
|
||||
href={`${
|
||||
domain.https
|
||||
? "https"
|
||||
: "http"
|
||||
}://${domain.host}${
|
||||
domain.path
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">
|
||||
{domain.host}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { findEnvironmentById } from "@dokploy/server/index";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -27,12 +26,10 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { api } from "@/utils/api";
|
||||
import { api, type RouterOutputs } from "@/utils/api";
|
||||
|
||||
type Environment = Omit<
|
||||
Awaited<ReturnType<typeof findEnvironmentById>>,
|
||||
"project"
|
||||
>;
|
||||
type Project = RouterOutputs["project"]["all"][number];
|
||||
type Environment = Project["environments"][number];
|
||||
|
||||
export type Services = {
|
||||
appName: string;
|
||||
@@ -53,17 +50,16 @@ export type Services = {
|
||||
};
|
||||
|
||||
export const extractServices = (data: Environment | undefined) => {
|
||||
const applications: Services[] =
|
||||
data?.applications.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "application",
|
||||
id: item.applicationId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) || [];
|
||||
const applications: Services[] = (data?.applications?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "application",
|
||||
id: item.applicationId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) ?? []) as Services[];
|
||||
|
||||
const mariadb: Services[] =
|
||||
data?.mariadb.map((item) => ({
|
||||
@@ -125,17 +121,16 @@ export const extractServices = (data: Environment | undefined) => {
|
||||
serverId: item.serverId,
|
||||
})) || [];
|
||||
|
||||
const compose: Services[] =
|
||||
data?.compose.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "compose",
|
||||
id: item.composeId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.composeStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) || [];
|
||||
const compose: Services[] = (data?.compose?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "compose",
|
||||
id: item.composeId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.composeStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) ?? []) as Services[];
|
||||
|
||||
applications.push(
|
||||
...mysql,
|
||||
|
||||
@@ -83,6 +83,7 @@ import { AddOrganization } from "../dashboard/organization/handle-organization";
|
||||
import { DialogAction } from "../shared/dialog-action";
|
||||
import { Logo } from "../shared/logo";
|
||||
import { Button } from "../ui/button";
|
||||
import { TimeBadge } from "../ui/time-badge";
|
||||
import { UpdateServerButton } from "./update-server";
|
||||
import { UserNav } from "./user-nav";
|
||||
|
||||
@@ -1125,6 +1126,7 @@ export default function Page({ children }: Props) {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
{!isCloud && <TimeBadge />}
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
|
||||
@@ -67,9 +67,10 @@ export const Dropzone = React.forwardRef<HTMLDivElement, DropzoneProps>(
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className={cn("hidden", className)}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(e.target.files)
|
||||
}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.files);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "@/utils/api";
|
||||
|
||||
export function TimeBadge() {
|
||||
const { data: serverTime } = api.server.getServerTime.useQuery(undefined);
|
||||
const [time, setTime] = useState<Date | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (serverTime?.time) {
|
||||
setTime(new Date(serverTime.time));
|
||||
}
|
||||
}, [serverTime]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setTime((prevTime) => {
|
||||
if (!prevTime) return null;
|
||||
const newTime = new Date(prevTime.getTime() + 1000);
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!time || !serverTime?.timezone) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const getUtcOffset = (timeZone: string) => {
|
||||
const date = new Date();
|
||||
const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" }));
|
||||
const tzDate = new Date(date.toLocaleString("en-US", { timeZone }));
|
||||
const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60);
|
||||
const sign = offset >= 0 ? "+" : "-";
|
||||
const hours = Math.floor(Math.abs(offset));
|
||||
const minutes = (Math.abs(offset) * 60) % 60;
|
||||
return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex items-center gap-2 rounded-md border px-2 py-1 text-xs sm:text-sm whitespace-nowrap max-w-full overflow-hidden">
|
||||
<span className="hidden sm:inline">Server Time:</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{time.toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="hidden sm:inline text-muted-foreground">
|
||||
({serverTime.timezone} | {getUtcOffset(serverTime.timezone)})
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
ALTER TABLE "user_temp" RENAME TO "user";--> statement-breakpoint
|
||||
ALTER TABLE "user" DROP CONSTRAINT "user_temp_email_unique";--> statement-breakpoint
|
||||
ALTER TABLE "account" DROP CONSTRAINT "account_user_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "apikey" DROP CONSTRAINT "apikey_user_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "invitation" DROP CONSTRAINT "invitation_inviter_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "member" DROP CONSTRAINT "member_user_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "organization" DROP CONSTRAINT "organization_owner_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" DROP CONSTRAINT "two_factor_user_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "backup" DROP CONSTRAINT "backup_userId_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "git_provider" DROP CONSTRAINT "git_provider_userId_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "schedule" DROP CONSTRAINT "schedule_userId_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "session_temp" DROP CONSTRAINT "session_temp_user_id_user_temp_id_fk";
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "application" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mariadb" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mongo" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "mysql" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "postgres" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "redis" ADD COLUMN "endpointSpecSwarm" json;--> statement-breakpoint
|
||||
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "apikey" ADD CONSTRAINT "apikey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "invitation" ADD CONSTRAINT "invitation_inviter_id_user_id_fk" FOREIGN KEY ("inviter_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "member" ADD CONSTRAINT "member_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "organization" ADD CONSTRAINT "organization_owner_id_user_id_fk" FOREIGN KEY ("owner_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "two_factor" ADD CONSTRAINT "two_factor_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "backup" ADD CONSTRAINT "backup_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "session_temp" ADD CONSTRAINT "session_temp_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "user" ADD CONSTRAINT "user_email_unique" UNIQUE("email");
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Fix inconsistent date formats in environment.createdAt field
|
||||
-- Convert PostgreSQL timestamp format to ISO 8601 format
|
||||
-- This addresses issue #2992 where old environments have PostgreSQL timestamp format
|
||||
-- while new ones have ISO 8601 format
|
||||
|
||||
UPDATE "environment"
|
||||
SET "createdAt" = to_char("createdAt"::timestamptz, 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')
|
||||
WHERE "createdAt" NOT LIKE '%T%';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "c091c142-08e7-4051-8596-6ade104455f4",
|
||||
"id": "bbe005b3-d5e0-4e93-8305-ba3b9a2e3f3d",
|
||||
"prevId": "fb4a3ed9-c58b-4a79-a4ee-2a7a4d3914a7",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -120,10 +120,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_temp_id_fk": {
|
||||
"name": "account_user_id_user_temp_id_fk",
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
@@ -273,10 +273,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"apikey_user_id_user_temp_id_fk": {
|
||||
"name": "apikey_user_id_user_temp_id_fk",
|
||||
"apikey_user_id_user_id_fk": {
|
||||
"name": "apikey_user_id_user_id_fk",
|
||||
"tableFrom": "apikey",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
@@ -361,10 +361,10 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"invitation_inviter_id_user_temp_id_fk": {
|
||||
"name": "invitation_inviter_id_user_temp_id_fk",
|
||||
"invitation_inviter_id_user_id_fk": {
|
||||
"name": "invitation_inviter_id_user_id_fk",
|
||||
"tableFrom": "invitation",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"inviter_id"
|
||||
],
|
||||
@@ -542,10 +542,10 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"member_user_id_user_temp_id_fk": {
|
||||
"name": "member_user_id_user_temp_id_fk",
|
||||
"member_user_id_user_id_fk": {
|
||||
"name": "member_user_id_user_id_fk",
|
||||
"tableFrom": "member",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
@@ -611,10 +611,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"organization_owner_id_user_temp_id_fk": {
|
||||
"name": "organization_owner_id_user_temp_id_fk",
|
||||
"organization_owner_id_user_id_fk": {
|
||||
"name": "organization_owner_id_user_id_fk",
|
||||
"tableFrom": "organization",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"owner_id"
|
||||
],
|
||||
@@ -670,10 +670,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"two_factor_user_id_user_temp_id_fk": {
|
||||
"name": "two_factor_user_id_user_temp_id_fk",
|
||||
"two_factor_user_id_user_id_fk": {
|
||||
"name": "two_factor_user_id_user_id_fk",
|
||||
"tableFrom": "two_factor",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
@@ -1288,6 +1288,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endpointSpecSwarm": {
|
||||
"name": "endpointSpecSwarm",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicas": {
|
||||
"name": "replicas",
|
||||
"type": "integer",
|
||||
@@ -1703,10 +1709,10 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"backup_userId_user_temp_id_fk": {
|
||||
"name": "backup_userId_user_temp_id_fk",
|
||||
"backup_userId_user_id_fk": {
|
||||
"name": "backup_userId_user_id_fk",
|
||||
"tableFrom": "backup",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
@@ -2634,12 +2640,6 @@
|
||||
"notNull": false,
|
||||
"default": 3000
|
||||
},
|
||||
"customEntrypoint": {
|
||||
"name": "customEntrypoint",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"path": {
|
||||
"name": "path",
|
||||
"type": "text",
|
||||
@@ -2890,10 +2890,10 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"git_provider_userId_user_temp_id_fk": {
|
||||
"name": "git_provider_userId_user_temp_id_fk",
|
||||
"git_provider_userId_user_id_fk": {
|
||||
"name": "git_provider_userId_user_id_fk",
|
||||
"tableFrom": "git_provider",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
@@ -3336,6 +3336,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endpointSpecSwarm": {
|
||||
"name": "endpointSpecSwarm",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicas": {
|
||||
"name": "replicas",
|
||||
"type": "integer",
|
||||
@@ -3555,6 +3561,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endpointSpecSwarm": {
|
||||
"name": "endpointSpecSwarm",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicas": {
|
||||
"name": "replicas",
|
||||
"type": "integer",
|
||||
@@ -3991,6 +4003,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endpointSpecSwarm": {
|
||||
"name": "endpointSpecSwarm",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicas": {
|
||||
"name": "replicas",
|
||||
"type": "integer",
|
||||
@@ -4782,6 +4800,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endpointSpecSwarm": {
|
||||
"name": "endpointSpecSwarm",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicas": {
|
||||
"name": "replicas",
|
||||
"type": "integer",
|
||||
@@ -5263,6 +5287,12 @@
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"endpointSpecSwarm": {
|
||||
"name": "endpointSpecSwarm",
|
||||
"type": "json",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"replicas": {
|
||||
"name": "replicas",
|
||||
"type": "integer",
|
||||
@@ -5614,10 +5644,10 @@
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"schedule_userId_user_temp_id_fk": {
|
||||
"name": "schedule_userId_user_temp_id_fk",
|
||||
"schedule_userId_user_id_fk": {
|
||||
"name": "schedule_userId_user_id_fk",
|
||||
"tableFrom": "schedule",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
@@ -5897,10 +5927,10 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_temp_user_id_user_temp_id_fk": {
|
||||
"name": "session_temp_user_id_user_temp_id_fk",
|
||||
"session_temp_user_id_user_id_fk": {
|
||||
"name": "session_temp_user_id_user_id_fk",
|
||||
"tableFrom": "session_temp",
|
||||
"tableTo": "user_temp",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
@@ -6001,8 +6031,8 @@
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user_temp": {
|
||||
"name": "user_temp",
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
@@ -6218,8 +6248,8 @@
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_temp_email_unique": {
|
||||
"name": "user_temp_email_unique",
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -845,8 +845,22 @@
|
||||
{
|
||||
"idx": 120,
|
||||
"version": "7",
|
||||
"when": 1762370026999,
|
||||
"tag": "0120_brief_reptil",
|
||||
"when": 1762632540024,
|
||||
"tag": "0120_lame_captain_midlands",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 121,
|
||||
"version": "7",
|
||||
"when": 1763755037033,
|
||||
"tag": "0121_rainy_cargill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 122,
|
||||
"version": "7",
|
||||
"when": 1763829727264,
|
||||
"tag": "0122_sleepy_aqueduct",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dokploy",
|
||||
"version": "v0.25.6",
|
||||
"version": "v0.25.10",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
@@ -98,6 +98,7 @@
|
||||
"bl": "6.0.11",
|
||||
"boxen": "^7.1.1",
|
||||
"bullmq": "5.4.2",
|
||||
"shell-quote": "^1.8.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^0.2.1",
|
||||
@@ -120,7 +121,7 @@
|
||||
"next": "^15.3.2",
|
||||
"next-i18next": "^15.4.2",
|
||||
"next-themes": "^0.2.1",
|
||||
"node-os-utils": "1.3.7",
|
||||
"node-os-utils": "2.0.1",
|
||||
"node-pty": "1.0.0",
|
||||
"node-schedule": "2.1.1",
|
||||
"nodemailer": "6.9.14",
|
||||
@@ -157,13 +158,13 @@
|
||||
"zod-form-data": "^2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/bcrypt": "5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/lodash": "4.17.4",
|
||||
"@types/micromatch": "4.0.9",
|
||||
"@types/node": "^18.19.104",
|
||||
"@types/node-os-utils": "1.3.4",
|
||||
"@types/node-schedule": "2.1.6",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
|
||||
@@ -12,6 +12,17 @@ import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { myQueue } from "@/server/queues/queueSetup";
|
||||
import { deploy } from "@/server/utils/deploy";
|
||||
|
||||
/**
|
||||
* Helper function to get package_version from registry_package events
|
||||
*/
|
||||
const getPackageVersion = (headers: any, body: any) => {
|
||||
const event = headers["x-github-event"];
|
||||
if (event === "registry_package") {
|
||||
return body.registry_package?.package_version;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
@@ -46,21 +57,60 @@ export default async function handler(
|
||||
}
|
||||
|
||||
const deploymentTitle = extractCommitMessage(req.headers, req.body);
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
|
||||
const deploymentHash = extractHash(req.headers, req.body);
|
||||
const sourceType = application.sourceType;
|
||||
|
||||
if (sourceType === "docker") {
|
||||
const applicationImageName = extractImageName(application.dockerImage);
|
||||
const applicationDockerTag = extractImageTag(application.dockerImage);
|
||||
|
||||
const webhookImageName = extractImageNameFromRequest(
|
||||
req.headers,
|
||||
req.body,
|
||||
);
|
||||
const webhookDockerTag = extractImageTagFromRequest(
|
||||
req.headers,
|
||||
req.body,
|
||||
);
|
||||
if (
|
||||
applicationDockerTag &&
|
||||
webhookDockerTag &&
|
||||
webhookDockerTag !== applicationDockerTag
|
||||
) {
|
||||
|
||||
if (!applicationImageName) {
|
||||
res.status(301).json({
|
||||
message: "Application Docker Image Name Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhookImageName) {
|
||||
res.status(301).json({
|
||||
message: "Webhook Docker Image Name Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate image name matches
|
||||
if (webhookImageName !== applicationImageName) {
|
||||
res.status(301).json({
|
||||
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!applicationDockerTag) {
|
||||
res.status(301).json({
|
||||
message: "Application Docker Tag Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!webhookDockerTag) {
|
||||
res.status(301).json({
|
||||
message: "Webhook Docker Tag Not Found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (webhookDockerTag !== applicationDockerTag) {
|
||||
res.status(301).json({
|
||||
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
|
||||
});
|
||||
@@ -191,7 +241,7 @@ export default async function handler(
|
||||
const jobData: DeploymentJob = {
|
||||
applicationId: application.applicationId as string,
|
||||
titleLog: deploymentTitle,
|
||||
descriptionLog: `Hash: ${deploymentHash}`,
|
||||
...(deploymentHash && { descriptionLog: `Hash: ${deploymentHash}` }),
|
||||
type: "deploy",
|
||||
applicationType: "application",
|
||||
server: !!application.serverId,
|
||||
@@ -222,6 +272,39 @@ export default async function handler(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the image name without the tag
|
||||
* Example: "my-image" => "my-image"
|
||||
* Example: "my-image:latest" => "my-image"
|
||||
* Example: "my-image:1.0.0" => "my-image"
|
||||
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "myregistryhost:5000/fedora/httpd"
|
||||
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
||||
*/
|
||||
export function extractImageName(dockerImage: string | null): string | null {
|
||||
if (!dockerImage || typeof dockerImage !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle case where there's no tag (no colon or colon is part of port number)
|
||||
const lastColonIndex = dockerImage.lastIndexOf(":");
|
||||
if (lastColonIndex === -1) {
|
||||
return dockerImage;
|
||||
}
|
||||
|
||||
// Check if the part after the last colon looks like a tag (not a port number)
|
||||
// Port numbers are typically 1-5 digits, tags are usually longer or contain letters
|
||||
const afterColon = dockerImage.substring(lastColonIndex + 1);
|
||||
const isPortNumber = /^\d{1,5}$/.test(afterColon);
|
||||
|
||||
// If it's a port number (like registry:5000/image), don't split
|
||||
if (isPortNumber) {
|
||||
return dockerImage;
|
||||
}
|
||||
|
||||
// Otherwise, split at the last colon to get image name
|
||||
return dockerImage.substring(0, lastColonIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the last part of the image name, which is the tag
|
||||
* Example: "my-image" => null
|
||||
@@ -230,7 +313,7 @@ export default async function handler(
|
||||
* Example: "myregistryhost:5000/fedora/httpd:version1.0" => "version1.0"
|
||||
* @link https://docs.docker.com/reference/cli/docker/image/tag/
|
||||
*/
|
||||
function extractImageTag(dockerImage: string | null) {
|
||||
export function extractImageTag(dockerImage: string | null) {
|
||||
if (!dockerImage || typeof dockerImage !== "string") {
|
||||
return null;
|
||||
}
|
||||
@@ -240,12 +323,78 @@ function extractImageTag(dockerImage: string | null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the image name (without tag) from webhook request
|
||||
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
||||
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
||||
*/
|
||||
export const extractImageNameFromRequest = (
|
||||
headers: any,
|
||||
body: any,
|
||||
): string | null => {
|
||||
// GitHub Packages: registry_package events (container registry)
|
||||
const packageVersion = getPackageVersion(headers, body);
|
||||
if (packageVersion?.package_url) {
|
||||
const packageUrl = packageVersion.package_url;
|
||||
// Remove tag if present (everything after the last colon)
|
||||
if (packageUrl.includes(":")) {
|
||||
const lastColonIndex = packageUrl.lastIndexOf(":");
|
||||
// Check if it's a port number (like registry:5000/image)
|
||||
const afterColon = packageUrl.substring(lastColonIndex + 1);
|
||||
const isPortNumber = /^\d{1,5}$/.test(afterColon);
|
||||
if (isPortNumber) {
|
||||
return packageUrl;
|
||||
}
|
||||
return packageUrl.substring(0, lastColonIndex);
|
||||
}
|
||||
return packageUrl;
|
||||
}
|
||||
|
||||
// Docker Hub
|
||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||
if (body.repository) {
|
||||
const repoName = body.repository.repo_name;
|
||||
return `${repoName}`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @link https://docs.docker.com/docker-hub/webhooks/#example-webhook-payload
|
||||
* @link https://docs.github.com/en/webhooks/webhook-events-and-payloads#registry_package
|
||||
*/
|
||||
export const extractImageTagFromRequest = (
|
||||
headers: any,
|
||||
body: any,
|
||||
): string | null => {
|
||||
// GitHub Packages: registry_package events (container registry)
|
||||
const packageVersion = getPackageVersion(headers, body);
|
||||
if (packageVersion) {
|
||||
// Try to get tag from container_metadata first (most reliable)
|
||||
// Only use it if it's not empty and not the same as the version (digest)
|
||||
const tagName = packageVersion.container_metadata?.tag?.name?.trim() || "";
|
||||
if (
|
||||
tagName &&
|
||||
tagName !== packageVersion.version &&
|
||||
!tagName.startsWith("sha256:")
|
||||
) {
|
||||
return tagName;
|
||||
}
|
||||
// Fallback: extract tag from package_url (e.g., "ghcr.io/owner/repo:tag")
|
||||
if (packageVersion.package_url) {
|
||||
const packageUrl = packageVersion.package_url;
|
||||
// Handle case where package_url ends with colon (no tag)
|
||||
if (packageUrl.endsWith(":")) {
|
||||
return null;
|
||||
}
|
||||
const tagMatch = packageUrl.match(/:([^:]+)$/);
|
||||
if (tagMatch?.[1]?.trim()) {
|
||||
return tagMatch[1].trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Docker Hub
|
||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||
if (body.push_data && body.repository) {
|
||||
return body.push_data.tag;
|
||||
@@ -255,6 +404,18 @@ export const extractImageTagFromRequest = (
|
||||
};
|
||||
|
||||
export const extractCommitMessage = (headers: any, body: any) => {
|
||||
// GitHub Packages: registry_package events (container tags)
|
||||
const githubEvent = headers["x-github-event"];
|
||||
if (githubEvent === "registry_package") {
|
||||
const packageVersion = getPackageVersion(headers, body);
|
||||
if (packageVersion) {
|
||||
if (packageVersion.package_url) {
|
||||
return `Docker GHCR image pushed: ${packageVersion.package_url}`;
|
||||
}
|
||||
return "Docker GHCR image pushed";
|
||||
}
|
||||
// If package_version is missing, fall through to default behavior
|
||||
}
|
||||
// GitHub
|
||||
if (headers["x-github-event"]) {
|
||||
return body.head_commit ? body.head_commit.message : "NEW COMMIT";
|
||||
@@ -283,7 +444,7 @@ export const extractCommitMessage = (headers: any, body: any) => {
|
||||
|
||||
if (headers["user-agent"]?.includes("Go-http-client")) {
|
||||
if (body.push_data && body.repository) {
|
||||
return `Docker image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
||||
return `DockerHub image pushed: ${body.repository.repo_name}:${body.push_data.tag} by ${body.push_data.pusher}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { asc, eq } from "drizzle-orm";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import Stripe from "stripe";
|
||||
import { db } from "@/server/db";
|
||||
import { organization, server, users_temp } from "@/server/db/schema";
|
||||
import { organization, server, user } from "@/server/db/schema";
|
||||
|
||||
const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET!;
|
||||
|
||||
@@ -64,13 +64,13 @@ export default async function handler(
|
||||
session.subscription as string,
|
||||
);
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
stripeCustomerId: session.customer as string,
|
||||
stripeSubscriptionId: session.subscription as string,
|
||||
serversQuantity: subscription?.items?.data?.[0]?.quantity ?? 0,
|
||||
})
|
||||
.where(eq(users_temp.id, adminId))
|
||||
.where(eq(user.id, adminId))
|
||||
.returning();
|
||||
|
||||
const admin = await findUserById(adminId);
|
||||
@@ -85,14 +85,12 @@ export default async function handler(
|
||||
const newSubscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
stripeSubscriptionId: newSubscription.id,
|
||||
stripeCustomerId: newSubscription.customer as string,
|
||||
})
|
||||
.where(
|
||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
||||
)
|
||||
.where(eq(user.stripeCustomerId, newSubscription.customer as string))
|
||||
.returning();
|
||||
|
||||
break;
|
||||
@@ -102,14 +100,12 @@ export default async function handler(
|
||||
const newSubscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
stripeSubscriptionId: null,
|
||||
serversQuantity: 0,
|
||||
})
|
||||
.where(
|
||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
||||
);
|
||||
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
|
||||
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
newSubscription.customer as string,
|
||||
@@ -135,24 +131,20 @@ export default async function handler(
|
||||
|
||||
if (newSubscription.status === "active") {
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
serversQuantity: newSubscription?.items?.data?.[0]?.quantity ?? 0,
|
||||
})
|
||||
.where(
|
||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
||||
);
|
||||
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
|
||||
|
||||
const newServersQuantity = admin.serversQuantity;
|
||||
await updateServersBasedOnQuantity(admin.id, newServersQuantity);
|
||||
} else {
|
||||
await disableServers(admin.id);
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({ serversQuantity: 0 })
|
||||
.where(
|
||||
eq(users_temp.stripeCustomerId, newSubscription.customer as string),
|
||||
);
|
||||
.where(eq(user.stripeCustomerId, newSubscription.customer as string));
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -172,11 +164,11 @@ export default async function handler(
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
serversQuantity: suscription?.items?.data?.[0]?.quantity ?? 0,
|
||||
})
|
||||
.where(eq(users_temp.stripeCustomerId, suscription.customer as string));
|
||||
.where(eq(user.stripeCustomerId, suscription.customer as string));
|
||||
|
||||
const admin = await findUserByStripeCustomerId(
|
||||
suscription.customer as string,
|
||||
@@ -205,13 +197,11 @@ export default async function handler(
|
||||
return res.status(400).send("Webhook Error: Admin not found");
|
||||
}
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
serversQuantity: 0,
|
||||
})
|
||||
.where(
|
||||
eq(users_temp.stripeCustomerId, newInvoice.customer as string),
|
||||
);
|
||||
.where(eq(user.stripeCustomerId, newInvoice.customer as string));
|
||||
|
||||
await disableServers(admin.id);
|
||||
}
|
||||
@@ -229,13 +219,13 @@ export default async function handler(
|
||||
|
||||
await disableServers(admin.id);
|
||||
await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
stripeCustomerId: null,
|
||||
stripeSubscriptionId: null,
|
||||
serversQuantity: 0,
|
||||
})
|
||||
.where(eq(users_temp.stripeCustomerId, customer.id));
|
||||
.where(eq(user.stripeCustomerId, customer.id));
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -262,10 +252,10 @@ const disableServers = async (userId: string) => {
|
||||
};
|
||||
|
||||
const findUserByStripeCustomerId = async (stripeCustomerId: string) => {
|
||||
const user = db.query.users_temp.findFirst({
|
||||
where: eq(users_temp.stripeCustomerId, stripeCustomerId),
|
||||
const userResult = await db.query.user.findFirst({
|
||||
where: eq(user.stripeCustomerId, stripeCustomerId),
|
||||
});
|
||||
return user;
|
||||
return userResult;
|
||||
};
|
||||
|
||||
const activateServer = async (serverId: string) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { findProjectById } from "@dokploy/server";
|
||||
import type { findEnvironmentById } from "@dokploy/server";
|
||||
import { validateRequest } from "@dokploy/server/lib/auth";
|
||||
import { createServerSideHelpers } from "@trpc/react-query/server";
|
||||
import {
|
||||
@@ -102,6 +102,7 @@ import { api } from "@/utils/api";
|
||||
export type Services = {
|
||||
appName: string;
|
||||
serverId?: string | null;
|
||||
serverName?: string | null;
|
||||
name: string;
|
||||
type:
|
||||
| "mariadb"
|
||||
@@ -115,10 +116,10 @@ export type Services = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
status?: "idle" | "running" | "done" | "error";
|
||||
lastDeployDate?: Date | null;
|
||||
};
|
||||
|
||||
type Project = Awaited<ReturnType<typeof findProjectById>>;
|
||||
type Environment = Project["environments"][0];
|
||||
type Environment = Awaited<ReturnType<typeof findEnvironmentById>>;
|
||||
|
||||
export const extractServicesFromEnvironment = (
|
||||
environment: Environment | undefined,
|
||||
@@ -128,16 +129,35 @@ export const extractServicesFromEnvironment = (
|
||||
const allServices: Services[] = [];
|
||||
|
||||
const applications: Services[] =
|
||||
environment.applications?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "application",
|
||||
id: item.applicationId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) || [];
|
||||
environment.applications?.map((item) => {
|
||||
// Get the most recent deployment date
|
||||
let lastDeployDate: Date | null = null;
|
||||
const deployments = (item as any).deployments;
|
||||
if (deployments && deployments.length > 0) {
|
||||
for (const deployment of deployments) {
|
||||
const deployDate = new Date(
|
||||
deployment.finishedAt ||
|
||||
deployment.startedAt ||
|
||||
deployment.createdAt,
|
||||
);
|
||||
if (!lastDeployDate || deployDate > lastDeployDate) {
|
||||
lastDeployDate = deployDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "application",
|
||||
id: item.applicationId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
const mariadb: Services[] =
|
||||
environment.mariadb?.map((item) => ({
|
||||
@@ -149,6 +169,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const postgres: Services[] =
|
||||
@@ -161,6 +182,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const mongo: Services[] =
|
||||
@@ -173,6 +195,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const redis: Services[] =
|
||||
@@ -185,6 +208,7 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const mysql: Services[] =
|
||||
@@ -197,19 +221,39 @@ export const extractServicesFromEnvironment = (
|
||||
status: item.applicationStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
})) || [];
|
||||
|
||||
const compose: Services[] =
|
||||
environment.compose?.map((item) => ({
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "compose",
|
||||
id: item.composeId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.composeStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
})) || [];
|
||||
environment.compose?.map((item) => {
|
||||
// Get the most recent deployment date
|
||||
let lastDeployDate: Date | null = null;
|
||||
const deployments = (item as any).deployments;
|
||||
if (deployments && deployments.length > 0) {
|
||||
for (const deployment of deployments) {
|
||||
const deployDate = new Date(
|
||||
deployment.finishedAt ||
|
||||
deployment.startedAt ||
|
||||
deployment.createdAt,
|
||||
);
|
||||
if (!lastDeployDate || deployDate > lastDeployDate) {
|
||||
lastDeployDate = deployDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
appName: item.appName,
|
||||
name: item.name,
|
||||
type: "compose",
|
||||
id: item.composeId,
|
||||
createdAt: item.createdAt,
|
||||
status: item.composeStatus,
|
||||
description: item.description,
|
||||
serverId: item.serverId,
|
||||
serverName: item?.server?.name || null,
|
||||
lastDeployDate,
|
||||
};
|
||||
}) || [];
|
||||
|
||||
allServices.push(
|
||||
...applications,
|
||||
@@ -237,9 +281,9 @@ const EnvironmentPage = (
|
||||
const { data: auth } = api.user.get.useQuery();
|
||||
const [sortBy, setSortBy] = useState<string>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
return localStorage.getItem("servicesSort") || "createdAt-desc";
|
||||
return localStorage.getItem("servicesSort") || "lastDeploy-desc";
|
||||
}
|
||||
return "createdAt-desc";
|
||||
return "lastDeploy-desc";
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -261,10 +305,45 @@ const EnvironmentPage = (
|
||||
comparison =
|
||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
||||
break;
|
||||
case "lastDeploy": {
|
||||
const aLastDeploy = a.lastDeployDate;
|
||||
const bLastDeploy = b.lastDeployDate;
|
||||
|
||||
if (direction === "desc") {
|
||||
// For "desc" (newest first): services with deployments first, then those without
|
||||
if (!aLastDeploy && !bLastDeploy) {
|
||||
comparison = 0;
|
||||
} else if (!aLastDeploy) {
|
||||
comparison = 1; // a (no deploy) goes after b (has deploy)
|
||||
} else if (!bLastDeploy) {
|
||||
comparison = -1; // a (has deploy) goes before b (no deploy)
|
||||
} else {
|
||||
// Both have deployments: newest first (negative if a is newer)
|
||||
comparison = bLastDeploy.getTime() - aLastDeploy.getTime();
|
||||
}
|
||||
} else {
|
||||
// For "asc" (oldest first): services with deployments first, then those without
|
||||
if (!aLastDeploy && !bLastDeploy) {
|
||||
comparison = 0;
|
||||
} else if (!aLastDeploy) {
|
||||
comparison = 1; // a (no deploy) goes after b (has deploy)
|
||||
} else if (!bLastDeploy) {
|
||||
comparison = -1; // a (has deploy) goes before b (no deploy)
|
||||
} else {
|
||||
// Both have deployments: oldest first
|
||||
comparison = aLastDeploy.getTime() - bLastDeploy.getTime();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
// For other fields, apply direction normally
|
||||
if (field !== "lastDeploy") {
|
||||
return direction === "asc" ? comparison : -comparison;
|
||||
}
|
||||
return comparison;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -320,6 +399,7 @@ const EnvironmentPage = (
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isBulkDeleteDialogOpen, setIsBulkDeleteDialogOpen] = useState(false);
|
||||
const [deleteVolumes, setDeleteVolumes] = useState(false);
|
||||
const [selectedServerId, setSelectedServerId] = useState<string>("all");
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedServices.length === filteredServices.length) {
|
||||
@@ -709,6 +789,27 @@ const EnvironmentPage = (
|
||||
setIsBulkActionLoading(false);
|
||||
};
|
||||
|
||||
// Get unique servers from services
|
||||
const availableServers = useMemo(() => {
|
||||
if (!applications) return [];
|
||||
const servers = new Map<string, { serverId: string; serverName: string }>();
|
||||
applications.forEach((service) => {
|
||||
if (service.serverId && service.serverName) {
|
||||
servers.set(service.serverId, {
|
||||
serverId: service.serverId,
|
||||
serverName: service.serverName,
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(servers.values());
|
||||
}, [applications]);
|
||||
|
||||
// Check if there are services without a server (Dokploy server)
|
||||
const hasServicesWithoutServer = useMemo(() => {
|
||||
if (!applications) return false;
|
||||
return applications.some((service) => !service.serverId);
|
||||
}, [applications]);
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!applications) return [];
|
||||
const filtered = applications.filter(
|
||||
@@ -717,10 +818,14 @@ const EnvironmentPage = (
|
||||
service.description
|
||||
?.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())) &&
|
||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)),
|
||||
(selectedTypes.length === 0 || selectedTypes.includes(service.type)) &&
|
||||
(selectedServerId === "" ||
|
||||
selectedServerId === "all" ||
|
||||
(selectedServerId === "dokploy-server" && !service.serverId) ||
|
||||
service.serverId === selectedServerId),
|
||||
);
|
||||
return sortServices(filtered);
|
||||
}, [applications, searchQuery, selectedTypes, sortBy]);
|
||||
}, [applications, searchQuery, selectedTypes, selectedServerId, sortBy]);
|
||||
|
||||
const selectedServicesWithRunningStatus = useMemo(() => {
|
||||
return filteredServices.filter(
|
||||
@@ -1217,6 +1322,9 @@ const EnvironmentPage = (
|
||||
<SelectValue placeholder="Sort by..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="lastDeploy-desc">
|
||||
Recently deployed
|
||||
</SelectItem>
|
||||
<SelectItem value="createdAt-desc">
|
||||
Newest first
|
||||
</SelectItem>
|
||||
@@ -1291,6 +1399,39 @@ const EnvironmentPage = (
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{(availableServers.length > 0 ||
|
||||
hasServicesWithoutServer) && (
|
||||
<Select
|
||||
value={selectedServerId || "all"}
|
||||
onValueChange={setSelectedServerId}
|
||||
>
|
||||
<SelectTrigger className="lg:w-[200px]">
|
||||
<SelectValue placeholder="Filter by server..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All servers</SelectItem>
|
||||
{hasServicesWithoutServer && (
|
||||
<SelectItem value="dokploy-server">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<span>Dokploy server</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
{availableServers.map((server) => (
|
||||
<SelectItem
|
||||
key={server.serverId}
|
||||
value={server.serverId}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerIcon className="size-4" />
|
||||
<span>{server.serverName}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1396,7 +1537,15 @@ const EnvironmentPage = (
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardFooter className="mt-auto">
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="space-y-1 text-sm w-full">
|
||||
{service.serverName && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mb-1">
|
||||
<ServerIcon className="size-3" />
|
||||
<span className="truncate">
|
||||
{service.serverName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<DateTooltip date={service.createdAt}>
|
||||
Created
|
||||
</DateTooltip>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { findAdmin } from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { users_temp } from "@dokploy/server/db/schema";
|
||||
import { user } from "@dokploy/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
(async () => {
|
||||
@@ -8,11 +8,11 @@ import { eq } from "drizzle-orm";
|
||||
const result = await findAdmin();
|
||||
|
||||
const update = await db
|
||||
.update(users_temp)
|
||||
.update(user)
|
||||
.set({
|
||||
twoFactorEnabled: false,
|
||||
})
|
||||
.where(eq(users_temp.id, result.userId));
|
||||
.where(eq(user.id, result.userId));
|
||||
|
||||
if (update) {
|
||||
console.log("2FA reset successful");
|
||||
|
||||
@@ -58,7 +58,11 @@ import {
|
||||
applications,
|
||||
} from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
|
||||
import {
|
||||
cleanQueuesByApplication,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { uploadFileSchema } from "@/utils/schema";
|
||||
|
||||
@@ -725,7 +729,21 @@ export const applicationRouter = createTRPCRouter({
|
||||
}
|
||||
await cleanQueuesByApplication(input.applicationId);
|
||||
}),
|
||||
|
||||
killBuild: protectedProcedure
|
||||
.input(apiFindOneApplication)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const application = await findApplicationById(input.applicationId);
|
||||
if (
|
||||
application.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to kill this build",
|
||||
});
|
||||
}
|
||||
await killDockerBuild("application", application.serverId);
|
||||
}),
|
||||
readTraefikConfig: protectedProcedure
|
||||
.input(apiFindOneApplication)
|
||||
.query(async ({ input, ctx }) => {
|
||||
|
||||
@@ -3,13 +3,14 @@ import {
|
||||
addNewService,
|
||||
checkServiceAccess,
|
||||
cloneCompose,
|
||||
cloneComposeRemote,
|
||||
createCommand,
|
||||
createCompose,
|
||||
createComposeByTemplate,
|
||||
createDomain,
|
||||
createMount,
|
||||
deleteMount,
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
findComposeById,
|
||||
findDomainsByComposeId,
|
||||
findEnvironmentById,
|
||||
@@ -58,7 +59,11 @@ import {
|
||||
compose as composeTable,
|
||||
} from "@/server/db/schema";
|
||||
import type { DeploymentJob } from "@/server/queues/queue-types";
|
||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
||||
import {
|
||||
cleanQueuesByCompose,
|
||||
killDockerBuild,
|
||||
myQueue,
|
||||
} from "@/server/queues/queueSetup";
|
||||
import { cancelDeployment, deploy } from "@/server/utils/deploy";
|
||||
import { generatePassword } from "@/templates/utils";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
@@ -247,6 +252,21 @@ export const composeRouter = createTRPCRouter({
|
||||
await cleanQueuesByCompose(input.composeId);
|
||||
return { success: true, message: "Queues cleaned successfully" };
|
||||
}),
|
||||
killBuild: protectedProcedure
|
||||
.input(apiFindCompose)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const compose = await findComposeById(input.composeId);
|
||||
if (
|
||||
compose.environment.project.organizationId !==
|
||||
ctx.session.activeOrganizationId
|
||||
) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You are not authorized to kill this build",
|
||||
});
|
||||
}
|
||||
await killDockerBuild("compose", compose.serverId);
|
||||
}),
|
||||
|
||||
loadServices: protectedProcedure
|
||||
.input(apiFetchServices)
|
||||
@@ -302,10 +322,12 @@ export const composeRouter = createTRPCRouter({
|
||||
message: "You are not authorized to fetch this compose",
|
||||
});
|
||||
}
|
||||
|
||||
const command = await cloneCompose(compose);
|
||||
if (compose.serverId) {
|
||||
await cloneComposeRemote(compose);
|
||||
await execAsyncRemote(compose.serverId, command);
|
||||
} else {
|
||||
await cloneCompose(compose);
|
||||
await execAsync(command);
|
||||
}
|
||||
return compose.sourceType;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
createDiscordNotification,
|
||||
createEmailNotification,
|
||||
createLarkNotification,
|
||||
createGotifyNotification,
|
||||
createLarkNotification,
|
||||
createNtfyNotification,
|
||||
createSlackNotification,
|
||||
createTelegramNotification,
|
||||
@@ -11,16 +11,16 @@ import {
|
||||
removeNotificationById,
|
||||
sendDiscordNotification,
|
||||
sendEmailNotification,
|
||||
sendLarkNotification,
|
||||
sendGotifyNotification,
|
||||
sendLarkNotification,
|
||||
sendNtfyNotification,
|
||||
sendServerThresholdNotifications,
|
||||
sendSlackNotification,
|
||||
sendTelegramNotification,
|
||||
updateDiscordNotification,
|
||||
updateEmailNotification,
|
||||
updateLarkNotification,
|
||||
updateGotifyNotification,
|
||||
updateLarkNotification,
|
||||
updateNtfyNotification,
|
||||
updateSlackNotification,
|
||||
updateTelegramNotification,
|
||||
@@ -38,29 +38,29 @@ import { db } from "@/server/db";
|
||||
import {
|
||||
apiCreateDiscord,
|
||||
apiCreateEmail,
|
||||
apiCreateLark,
|
||||
apiCreateGotify,
|
||||
apiCreateLark,
|
||||
apiCreateNtfy,
|
||||
apiCreateSlack,
|
||||
apiCreateTelegram,
|
||||
apiFindOneNotification,
|
||||
apiTestDiscordConnection,
|
||||
apiTestEmailConnection,
|
||||
apiTestLarkConnection,
|
||||
apiTestGotifyConnection,
|
||||
apiTestLarkConnection,
|
||||
apiTestNtfyConnection,
|
||||
apiTestSlackConnection,
|
||||
apiTestTelegramConnection,
|
||||
apiUpdateDiscord,
|
||||
apiUpdateEmail,
|
||||
apiUpdateLark,
|
||||
apiUpdateGotify,
|
||||
apiUpdateLark,
|
||||
apiUpdateNtfy,
|
||||
apiUpdateSlack,
|
||||
apiUpdateTelegram,
|
||||
notifications,
|
||||
server,
|
||||
users_temp,
|
||||
user,
|
||||
} from "@/server/db/schema";
|
||||
|
||||
export const notificationRouter = createTRPCRouter({
|
||||
@@ -359,9 +359,9 @@ export const notificationRouter = createTRPCRouter({
|
||||
if (input.ServerType === "Dokploy") {
|
||||
const result = await db
|
||||
.select()
|
||||
.from(users_temp)
|
||||
.from(user)
|
||||
.where(
|
||||
sql`${users_temp.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
|
||||
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
|
||||
);
|
||||
|
||||
if (!result?.[0]?.id) {
|
||||
|
||||
@@ -383,6 +383,15 @@ export const serverRouter = createTRPCRouter({
|
||||
const ip = await getPublicIpWithFallback();
|
||||
return ip;
|
||||
}),
|
||||
getServerTime: protectedProcedure.query(() => {
|
||||
if (IS_CLOUD) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
time: new Date(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
};
|
||||
}),
|
||||
getServerMetrics: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
findNotificationById,
|
||||
findOrganizationById,
|
||||
findUserById,
|
||||
getDokployUrl,
|
||||
getUserByToken,
|
||||
IS_CLOUD,
|
||||
removeUserById,
|
||||
@@ -419,11 +420,10 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const admin = await findAdmin();
|
||||
const host =
|
||||
process.env.NODE_ENV === "development"
|
||||
? "http://localhost:3000"
|
||||
: admin.user.host;
|
||||
: await getDokployUrl();
|
||||
const inviteLink = `${host}/invitation?token=${input.invitationId}`;
|
||||
|
||||
const organization = await findOrganizationById(
|
||||
|
||||
@@ -2,13 +2,8 @@ import {
|
||||
deployApplication,
|
||||
deployCompose,
|
||||
deployPreviewApplication,
|
||||
deployRemoteApplication,
|
||||
deployRemoteCompose,
|
||||
deployRemotePreviewApplication,
|
||||
rebuildApplication,
|
||||
rebuildCompose,
|
||||
rebuildRemoteApplication,
|
||||
rebuildRemoteCompose,
|
||||
updateApplicationStatus,
|
||||
updateCompose,
|
||||
updatePreviewDeployment,
|
||||
@@ -24,91 +19,48 @@ export const deploymentWorker = new Worker(
|
||||
if (job.data.applicationType === "application") {
|
||||
await updateApplicationStatus(job.data.applicationId, "running");
|
||||
|
||||
if (job.data.server) {
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildRemoteApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployRemoteApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "compose") {
|
||||
await updateCompose(job.data.composeId, {
|
||||
composeStatus: "running",
|
||||
});
|
||||
|
||||
if (job.data.server) {
|
||||
if (job.data.type === "redeploy") {
|
||||
await rebuildRemoteCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "deploy") {
|
||||
await deployRemoteCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (job.data.type === "deploy") {
|
||||
await deployCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "redeploy") {
|
||||
await rebuildCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
if (job.data.type === "deploy") {
|
||||
await deployCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
} else if (job.data.type === "redeploy") {
|
||||
await rebuildCompose({
|
||||
composeId: job.data.composeId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
});
|
||||
}
|
||||
} else if (job.data.applicationType === "application-preview") {
|
||||
await updatePreviewDeployment(job.data.previewDeploymentId, {
|
||||
previewStatus: "running",
|
||||
});
|
||||
if (job.data.server) {
|
||||
if (job.data.type === "deploy") {
|
||||
await deployRemotePreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (job.data.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
|
||||
if (job.data.type === "deploy") {
|
||||
await deployPreviewApplication({
|
||||
applicationId: job.data.applicationId,
|
||||
titleLog: job.data.titleLog,
|
||||
descriptionLog: job.data.descriptionLog,
|
||||
previewDeploymentId: job.data.previewDeploymentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { Queue } from "bullmq";
|
||||
import { redisConfig } from "./redis-connection";
|
||||
|
||||
@@ -41,4 +45,31 @@ export const cleanQueuesByCompose = async (composeId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const killDockerBuild = async (
|
||||
type: "application" | "compose",
|
||||
serverId: string | null,
|
||||
) => {
|
||||
try {
|
||||
if (type === "application") {
|
||||
const command = `pkill -2 -f "docker build"`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
} else if (type === "compose") {
|
||||
const command = `pkill -2 -f "docker compose"`;
|
||||
|
||||
if (serverId) {
|
||||
await execAsyncRemote(serverId, command);
|
||||
} else {
|
||||
await execAsync(command);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
export { myQueue };
|
||||
|
||||
@@ -2,6 +2,7 @@ import type http from "node:http";
|
||||
import {
|
||||
docker,
|
||||
execAsync,
|
||||
getHostSystemStats,
|
||||
getLastAdvancedStatsFile,
|
||||
recordAdvancedStats,
|
||||
validateRequest,
|
||||
@@ -49,6 +50,21 @@ export const setupDockerStatsMonitoringSocketServer = (
|
||||
}
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
// Special case: when monitoring "dokploy", get host system stats instead of container stats
|
||||
if (appName === "dokploy") {
|
||||
const stat = await getHostSystemStats();
|
||||
|
||||
await recordAdvancedStats(stat, appName);
|
||||
const data = await getLastAdvancedStatsFile(appName);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
data,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
status: ["running"],
|
||||
...(appType === "application" && {
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
await initializeNetwork();
|
||||
createDefaultTraefikConfig();
|
||||
createDefaultServerTraefikConfig();
|
||||
await execAsync("docker pull traefik:v3.5.0");
|
||||
await execAsync("docker pull traefik:v3.6.1");
|
||||
await initializeStandaloneTraefik();
|
||||
await initializeRedis();
|
||||
await initializePostgres();
|
||||
|
||||
Reference in New Issue
Block a user