Merge branch 'canary' into feature/stop-grace-period-2227

This commit is contained in:
Lucas Manchine
2025-09-24 08:52:32 -03:00
111 changed files with 15260 additions and 648 deletions

View File

@@ -5,7 +5,11 @@ import { zValidator } from "@hono/zod-validator";
import { Inngest } from "inngest";
import { serve as serveInngest } from "inngest/hono";
import { logger } from "./logger.js";
import { type DeployJob, deployJobSchema } from "./schema.js";
import {
cancelDeploymentSchema,
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction(
},
],
retries: 0,
cancelOn: [
{
event: "deployment/cancelled",
if: "async.data.applicationId == event.data.applicationId || async.data.composeId == event.data.composeId",
timeout: "1h", // Allow cancellation for up to 1 hour
},
],
},
{ event: "deployment/requested" },
@@ -119,6 +130,48 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => {
}
});
app.post(
"/cancel-deployment",
zValidator("json", cancelDeploymentSchema),
async (c) => {
const data = c.req.valid("json");
logger.info("Received cancel deployment request", data);
try {
// Send cancellation event to Inngest
await inngest.send({
name: "deployment/cancelled",
data,
});
const identifier =
data.applicationType === "application"
? `applicationId: ${data.applicationId}`
: `composeId: ${data.composeId}`;
logger.info("Deployment cancellation event sent", {
...data,
identifier,
});
return c.json({
message: "Deployment cancellation requested",
applicationType: data.applicationType,
});
} catch (error) {
logger.error("Failed to send deployment cancellation event", error);
return c.json(
{
message: "Failed to cancel deployment",
error: error instanceof Error ? error.message : String(error),
},
500,
);
}
},
);
app.get("/health", async (c) => {
return c.json({ status: "ok" });
});

View File

@@ -3,8 +3,8 @@ import { z } from "zod";
export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application"),
@@ -12,8 +12,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
}),
z.object({
composeId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("compose"),
@@ -22,8 +22,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
previewDeploymentId: z.string(),
titleLog: z.string(),
descriptionLog: z.string(),
titleLog: z.string().optional(),
descriptionLog: z.string().optional(),
server: z.boolean().optional(),
type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"),
@@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
]);
export type DeployJob = z.infer<typeof deployJobSchema>;
export const cancelDeploymentSchema = z.discriminatedUnion("applicationType", [
z.object({
applicationId: z.string(),
applicationType: z.literal("application"),
}),
z.object({
composeId: z.string(),
applicationType: z.literal("compose"),
}),
]);
export type CancelDeploymentJob = z.infer<typeof cancelDeploymentSchema>;

View File

@@ -18,14 +18,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") {
await rebuildRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
});
}
}
@@ -38,14 +38,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") {
await rebuildRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog || "",
});
} else if (job.type === "deploy") {
await deployRemoteCompose({
composeId: job.composeId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog || "",
});
}
}
@@ -57,8 +57,8 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "deploy") {
await deployRemotePreviewApplication({
applicationId: job.applicationId,
titleLog: job.titleLog,
descriptionLog: job.descriptionLog,
titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId,
});
}

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllProperties } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile1 = `
version: "3.8"
@@ -61,7 +61,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile1 = load(`
const expectedComposeFile1 = parse(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -185,7 +185,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -243,7 +243,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -308,7 +308,7 @@ secrets:
file: ./service_secret.txt
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -366,7 +366,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all properties in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);
@@ -420,7 +420,7 @@ volumes:
driver: local
`;
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -467,7 +467,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to all properties in Plausible compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllProperties(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToConfigsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ configs:
`;
test("Add suffix to configs in root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ configs:
`;
test("Add suffix to multiple configs in root property", () => {
const composeData = load(composeFileMultipleConfigs) as ComposeSpecification;
const composeData = parse(composeFileMultipleConfigs) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -92,7 +92,7 @@ configs:
`;
test("Add suffix to configs with different properties in root property", () => {
const composeData = load(
const composeData = parse(
composeFileDifferentProperties,
) as ComposeSpecification;
@@ -137,7 +137,7 @@ configs:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigRoot = load(`
const expectedComposeFileConfigRoot = parse(`
version: "3.8"
services:
@@ -162,7 +162,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs in root property", () => {
const composeData = load(composeFileConfigRoot) as ComposeSpecification;
const composeData = parse(composeFileConfigRoot) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToConfigsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -22,7 +22,7 @@ configs:
`;
test("Add suffix to configs in services", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -54,7 +54,7 @@ configs:
`;
test("Add suffix to configs in services with single config", () => {
const composeData = load(
const composeData = parse(
composeFileSingleServiceConfig,
) as ComposeSpecification;
@@ -108,7 +108,7 @@ configs:
`;
test("Add suffix to configs in services with multiple configs", () => {
const composeData = load(
const composeData = parse(
composeFileMultipleServicesConfigs,
) as ComposeSpecification;
@@ -157,7 +157,7 @@ services:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFileConfigServices = load(`
const expectedComposeFileConfigServices = parse(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ services:
`) as ComposeSpecification;
test("Add suffix to configs in services", () => {
const composeData = load(composeFileConfigServices) as ComposeSpecification;
const composeData = parse(composeFileConfigServices) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllConfigs, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -43,7 +43,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileCombinedConfigs = load(`
const expectedComposeFileCombinedConfigs = parse(`
version: "3.8"
services:
@@ -77,7 +77,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to all configs in root and services", () => {
const composeData = load(composeFileCombinedConfigs) as ComposeSpecification;
const composeData = parse(composeFileCombinedConfigs) as ComposeSpecification;
const suffix = "testhash";
@@ -122,7 +122,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithEnvAndExternal = load(`
const expectedComposeFileWithEnvAndExternal = parse(`
version: "3.8"
services:
@@ -159,7 +159,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with environment and external", () => {
const composeData = load(
const composeData = parse(
composeFileWithEnvAndExternal,
) as ComposeSpecification;
@@ -200,7 +200,7 @@ configs:
file: ./db-config.yml
`;
const expectedComposeFileWithTemplateDriverAndLabels = load(`
const expectedComposeFileWithTemplateDriverAndLabels = parse(`
version: "3.8"
services:
@@ -231,7 +231,7 @@ configs:
`) as ComposeSpecification;
test("Add suffix to configs with template driver and labels", () => {
const composeData = load(
const composeData = parse(
composeFileWithTemplateDriverAndLabels,
) as ComposeSpecification;

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToNetworksRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -35,7 +35,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to networks root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -79,7 +79,7 @@ networks:
`;
test("Add suffix to advanced networks root property (2 TRY)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -120,7 +120,7 @@ networks:
`;
test("Add suffix to networks with external properties", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -160,7 +160,7 @@ networks:
`;
test("Add suffix to networks with IPAM configurations", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -201,7 +201,7 @@ networks:
`;
test("Add suffix to networks with custom options", () => {
const composeData = load(composeFile5) as ComposeSpecification;
const composeData = parse(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -264,7 +264,7 @@ networks:
`;
test("Add suffix to networks with static suffix", () => {
const composeData = load(composeFile6) as ComposeSpecification;
const composeData = parse(composeFile6) as ComposeSpecification;
const suffix = "testhash";
@@ -273,7 +273,7 @@ test("Add suffix to networks with static suffix", () => {
}
const networks = addSuffixToNetworksRoot(composeData.networks, suffix);
const expectedComposeData = load(
const expectedComposeData = parse(
expectedComposeFile6,
) as ComposeSpecification;
expect(networks).toStrictEqual(expectedComposeData.networks);
@@ -293,7 +293,7 @@ networks:
`;
test("It shoudn't add suffix to dokploy-network", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -23,7 +23,7 @@ services:
`;
test("Add suffix to networks in services", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to networks in services with aliases", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -107,7 +107,7 @@ networks:
`;
test("Add suffix to networks in services (Object with simple networks)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -153,7 +153,7 @@ networks:
`;
test("Add suffix to networks in services (combined case)", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const composeData = parse(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -196,7 +196,7 @@ services:
`;
test("It shoudn't add suffix to dokploy-network in services", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -245,7 +245,7 @@ services:
`;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = load(composeFile8) as ComposeSpecification;
const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -5,8 +5,8 @@ import {
addSuffixToServiceNetworks,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombined = `
version: "3.8"
@@ -39,7 +39,7 @@ networks:
`;
test("Add suffix to networks in services and root (combined case)", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const composeData = parse(composeFileCombined) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -89,7 +89,7 @@ test("Add suffix to networks in services and root (combined case)", () => {
expect(redisNetworks).not.toHaveProperty("backend");
});
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -120,7 +120,7 @@ networks:
`);
test("Add suffix to networks in compose file", () => {
const composeData = load(composeFileCombined) as ComposeSpecification;
const composeData = parse(composeFileCombined) as ComposeSpecification;
const suffix = "testhash";
if (!composeData?.networks) {
@@ -156,7 +156,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -182,7 +182,7 @@ networks:
`);
test("Add suffix to networks in compose file with external and internal networks", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -218,7 +218,7 @@ networks:
com.docker.network.bridge.enable_icc: "true"
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -247,7 +247,7 @@ networks:
`);
test("Add suffix to networks in compose file with multiple services and complex network configurations", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);
@@ -289,7 +289,7 @@ networks:
`;
const expectedComposeFile4 = load(`
const expectedComposeFile4 = parse(`
version: "3.8"
services:
@@ -326,7 +326,7 @@ networks:
`);
test("Expect don't add suffix to dokploy-network in compose file with multiple services and complex network configurations", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllNetworks(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToSecretsRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -23,7 +23,7 @@ secrets:
`;
test("Add suffix to secrets in root property", () => {
const composeData = load(composeFileSecretsRoot) as ComposeSpecification;
const composeData = parse(composeFileSecretsRoot) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -52,7 +52,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 1)", () => {
const composeData = load(composeFileSecretsRoot1) as ComposeSpecification;
const composeData = parse(composeFileSecretsRoot1) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {
@@ -84,7 +84,7 @@ secrets:
`;
test("Add suffix to secrets in root property (Test 2)", () => {
const composeData = load(composeFileSecretsRoot2) as ComposeSpecification;
const composeData = parse(composeFileSecretsRoot2) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData?.secrets) {

View File

@@ -3,8 +3,8 @@ import {
addSuffixToSecretsInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileSecretsServices = `
version: "3.8"
@@ -21,7 +21,7 @@ secrets:
`;
test("Add suffix to secrets in services", () => {
const composeData = load(composeFileSecretsServices) as ComposeSpecification;
const composeData = parse(composeFileSecretsServices) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -54,7 +54,9 @@ secrets:
`;
test("Add suffix to secrets in services (Test 1)", () => {
const composeData = load(composeFileSecretsServices1) as ComposeSpecification;
const composeData = parse(
composeFileSecretsServices1,
) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {
@@ -93,7 +95,9 @@ secrets:
`;
test("Add suffix to secrets in services (Test 2)", () => {
const composeData = load(composeFileSecretsServices2) as ComposeSpecification;
const composeData = parse(
composeFileSecretsServices2,
) as ComposeSpecification;
const suffix = generateRandomHash();
if (!composeData.services) {

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllSecrets } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedSecrets = `
version: "3.8"
@@ -25,7 +25,7 @@ secrets:
file: ./app_secret.txt
`;
const expectedComposeFileCombinedSecrets = load(`
const expectedComposeFileCombinedSecrets = parse(`
version: "3.8"
services:
@@ -48,7 +48,7 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets", () => {
const composeData = load(composeFileCombinedSecrets) as ComposeSpecification;
const composeData = parse(composeFileCombinedSecrets) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -77,7 +77,7 @@ secrets:
file: ./cache_secret.txt
`;
const expectedComposeFileCombinedSecrets3 = load(`
const expectedComposeFileCombinedSecrets3 = parse(`
version: "3.8"
services:
@@ -99,7 +99,9 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (3rd Case)", () => {
const composeData = load(composeFileCombinedSecrets3) as ComposeSpecification;
const composeData = parse(
composeFileCombinedSecrets3,
) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);
@@ -128,7 +130,7 @@ secrets:
file: ./db_password.txt
`;
const expectedComposeFileCombinedSecrets4 = load(`
const expectedComposeFileCombinedSecrets4 = parse(`
version: "3.8"
services:
@@ -150,7 +152,9 @@ secrets:
`) as ComposeSpecification;
test("Add suffix to all secrets (4th Case)", () => {
const composeData = load(composeFileCombinedSecrets4) as ComposeSpecification;
const composeData = parse(
composeFileCombinedSecrets4,
) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllSecrets(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -27,7 +27,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to service names with container_name in compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -32,7 +32,7 @@ networks:
`;
test("Add suffix to service names with depends_on (array) in compose file", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -102,7 +102,7 @@ networks:
`;
test("Add suffix to service names with depends_on (object) in compose file", () => {
const composeData = load(composeFile5) as ComposeSpecification;
const composeData = parse(composeFile5) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -30,7 +30,7 @@ networks:
`;
test("Add suffix to service names with extends (string) in compose file", () => {
const composeData = load(composeFile6) as ComposeSpecification;
const composeData = parse(composeFile6) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -90,7 +90,7 @@ networks:
`;
test("Add suffix to service names with extends (object) in compose file", () => {
const composeData = load(composeFile7) as ComposeSpecification;
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -31,7 +31,7 @@ networks:
`;
test("Add suffix to service names with links in compose file", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -26,7 +26,7 @@ networks:
`;
test("Add suffix to service names in compose file", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -3,8 +3,8 @@ import {
addSuffixToAllServiceNames,
addSuffixToServiceNames,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileCombinedAllCases = `
version: "3.8"
@@ -38,7 +38,7 @@ networks:
driver: bridge
`;
const expectedComposeFile = load(`
const expectedComposeFile = parse(`
version: "3.8"
services:
@@ -71,7 +71,9 @@ networks:
`);
test("Add suffix to all service names in compose file", () => {
const composeData = load(composeFileCombinedAllCases) as ComposeSpecification;
const composeData = parse(
composeFileCombinedAllCases,
) as ComposeSpecification;
const suffix = "testhash";
@@ -131,7 +133,7 @@ networks:
driver: bridge
`;
const expectedComposeFile1 = load(`
const expectedComposeFile1 = parse(`
version: "3.8"
services:
@@ -176,7 +178,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 1", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -227,7 +229,7 @@ networks:
driver: bridge
`;
const expectedComposeFile2 = load(`
const expectedComposeFile2 = parse(`
version: "3.8"
services:
@@ -271,7 +273,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 2", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);
@@ -322,7 +324,7 @@ networks:
driver: bridge
`;
const expectedComposeFile3 = load(`
const expectedComposeFile3 = parse(`
version: "3.8"
services:
@@ -366,7 +368,7 @@ networks:
`) as ComposeSpecification;
test("Add suffix to all service names in compose file 3", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllServiceNames(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -35,7 +35,7 @@ networks:
`;
test("Add suffix to service names with volumes_from in compose file", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -4,8 +4,8 @@ import {
addSuffixToVolumesRoot,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
services:
@@ -70,7 +70,7 @@ volumes:
driver: local
`;
const expectedDockerCompose = load(`
const expectedDockerCompose = parse(`
services:
mail:
image: bytemark/smtp
@@ -143,7 +143,7 @@ test("Generate random hash with 8 characters", () => {
// Docker compose needs unique names for services, volumes, networks and containers
// So base on a input which is a dockercompose file, it should replace the name with a hash and return a new dockercompose file
test("Add suffix to volumes root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -165,7 +165,7 @@ test("Add suffix to volumes root property", () => {
});
test("Expect to change the suffix in all the possible places", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -195,7 +195,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose2 = load(`
const expectedDockerCompose2 = parse(`
version: '3.8'
services:
app:
@@ -218,7 +218,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (2 Try)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -248,7 +248,7 @@ volumes:
mongo-data:
`;
const expectedDockerCompose3 = load(`
const expectedDockerCompose3 = parse(`
version: '3.8'
services:
app:
@@ -271,7 +271,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (3 Try)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -645,7 +645,7 @@ volumes:
db-config:
`;
const expectedDockerComposeComplex = load(`
const expectedDockerComposeComplex = parse(`
version: "3.8"
services:
studio:
@@ -1012,7 +1012,7 @@ volumes:
`);
test("Expect to change the suffix in all the possible places (4 Try)", () => {
const composeData = load(composeFileComplex) as ComposeSpecification;
const composeData = parse(composeFileComplex) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1065,7 +1065,7 @@ volumes:
db-data:
`;
const expectedDockerComposeExample1 = load(`
const expectedDockerComposeExample1 = parse(`
version: "3.8"
services:
web:
@@ -1111,7 +1111,7 @@ volumes:
`) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (5 Try)", () => {
const composeData = load(composeFileExample1) as ComposeSpecification;
const composeData = parse(composeFileExample1) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1143,7 +1143,7 @@ volumes:
backrest-cache:
`;
const expectedDockerComposeBackrest = load(`
const expectedDockerComposeBackrest = parse(`
services:
backrest:
image: garethgeorge/backrest:v1.7.3
@@ -1168,7 +1168,7 @@ volumes:
`) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => {
const composeData = load(composeFileBackrest) as ComposeSpecification;
const composeData = parse(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToVolumesRoot, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = `
version: "3.8"
@@ -29,7 +29,7 @@ test("Generate random hash with 8 characters", () => {
});
test("Add suffix to volumes in root property", () => {
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -67,7 +67,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 2)", () => {
const composeData = load(composeFile2) as ComposeSpecification;
const composeData = parse(composeFile2) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -101,7 +101,7 @@ networks:
`;
test("Add suffix to volumes in root property (Case 3)", () => {
const composeData = load(composeFile3) as ComposeSpecification;
const composeData = parse(composeFile3) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -148,7 +148,7 @@ volumes:
`;
// Expected compose file con el prefijo `testhash`
const expectedComposeFile4 = load(`
const expectedComposeFile4 = parse(`
version: "3.8"
services:
@@ -179,7 +179,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes in root property", () => {
const composeData = load(composeFile4) as ComposeSpecification;
const composeData = parse(composeFile4) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -3,8 +3,8 @@ import {
addSuffixToVolumesInServices,
generateRandomHash,
} from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
test("Generate random hash with 8 characters", () => {
const hash = generateRandomHash();
@@ -24,7 +24,7 @@ services:
`;
test("Add suffix to volumes declared directly in services", () => {
const composeData = load(composeFile1) as ComposeSpecification;
const composeData = parse(composeFile1) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -59,7 +59,7 @@ volumes:
`;
test("Add suffix to volumes declared directly in services (Case 2)", () => {
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const suffix = generateRandomHash();

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToAllVolumes } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFileTypeVolume = `
version: "3.8"
@@ -23,7 +23,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume = load(`
const expectedComposeFileTypeVolume = parse(`
version: "3.8"
services:
@@ -44,7 +44,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to volumes with type: volume in services", () => {
const composeData = load(composeFileTypeVolume) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume) as ComposeSpecification;
const suffix = "testhash";
@@ -73,7 +73,7 @@ volumes:
driver: local
`;
const expectedComposeFileTypeVolume1 = load(`
const expectedComposeFileTypeVolume1 = parse(`
version: "3.8"
services:
@@ -93,7 +93,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to mixed volumes in services", () => {
const composeData = load(composeFileTypeVolume1) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume1) as ComposeSpecification;
const suffix = "testhash";
@@ -128,7 +128,7 @@ volumes:
device: /path/to/app/logs
`;
const expectedComposeFileTypeVolume2 = load(`
const expectedComposeFileTypeVolume2 = parse(`
version: "3.8"
services:
@@ -154,7 +154,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex volume configurations in services", () => {
const composeData = load(composeFileTypeVolume2) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume2) as ComposeSpecification;
const suffix = "testhash";
@@ -218,7 +218,7 @@ volumes:
device: /path/to/shared/logs
`;
const expectedComposeFileTypeVolume3 = load(`
const expectedComposeFileTypeVolume3 = parse(`
version: "3.8"
services:
@@ -273,7 +273,7 @@ volumes:
`) as ComposeSpecification;
test("Add suffix to complex nested volumes configuration in services", () => {
const composeData = load(composeFileTypeVolume3) as ComposeSpecification;
const composeData = parse(composeFileTypeVolume3) as ComposeSpecification;
const suffix = "testhash";

View File

@@ -1,8 +1,8 @@
import { zodResolver } from "@hookform/resolvers/zod";
import jsyaml from "js-yaml";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { parse, stringify, YAMLParseError } from "yaml";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
@@ -38,11 +38,11 @@ interface Props {
export const validateAndFormatYAML = (yamlText: string) => {
try {
const obj = jsyaml.load(yamlText);
const formattedYaml = jsyaml.dump(obj, { indent: 4 });
const obj = parse(yamlText);
const formattedYaml = stringify(obj, { indent: 4 });
return { valid: true, formattedYaml, error: null };
} catch (error) {
if (error instanceof jsyaml.YAMLException) {
if (error instanceof YAMLParseError) {
return {
valid: false,
formattedYaml: yamlText,
@@ -89,7 +89,7 @@ export const UpdateTraefikConfig = ({ applicationId }: Props) => {
if (!valid) {
form.setError("traefikConfig", {
type: "manual",
message: error || "Invalid YAML",
message: (error as string) || "Invalid YAML",
});
return;
}

View File

@@ -1,6 +1,7 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react";
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -61,12 +62,48 @@ export const ShowDeployments = ({
},
);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation();
// Cancel deployment mutations
const {
mutateAsync: cancelApplicationDeployment,
isLoading: isCancellingApp,
} = api.application.cancelDeployment.useMutation();
const {
mutateAsync: cancelComposeDeployment,
isLoading: isCancellingCompose,
} = api.compose.cancelDeployment.useMutation();
const [url, setUrl] = React.useState("");
// Check for stuck deployment (more than 9 minutes) - only for the most recent deployment
const stuckDeployment = useMemo(() => {
if (!isCloud || !deployments || deployments.length === 0) return null;
const now = Date.now();
const NINE_MINUTES = 10 * 60 * 1000; // 9 minutes in milliseconds
// Get the most recent deployment (first in the list since they're sorted by date)
const mostRecentDeployment = deployments[0];
if (
!mostRecentDeployment ||
mostRecentDeployment.status !== "running" ||
!mostRecentDeployment.startedAt
) {
return null;
}
const startTime = new Date(mostRecentDeployment.startedAt).getTime();
const elapsed = now - startTime;
return elapsed > NINE_MINUTES ? mostRecentDeployment : null;
}, [isCloud, deployments]);
useEffect(() => {
setUrl(document.location.origin);
}, []);
@@ -94,6 +131,54 @@ export const ShowDeployments = ({
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{stuckDeployment && (type === "application" || type === "compose") && (
<AlertBlock
type="warning"
className="flex-col items-start w-full p-4"
>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium text-sm mb-1">
Build appears to be stuck
</div>
<p className="text-sm">
Hey! Looks like the build has been running for more than 10
minutes. Would you like to cancel this deployment?
</p>
</div>
<Button
variant="destructive"
size="sm"
className="w-fit"
isLoading={
type === "application" ? isCancellingApp : isCancellingCompose
}
onClick={async () => {
try {
if (type === "application") {
await cancelApplicationDeployment({
applicationId: id,
});
} else if (type === "compose") {
await cancelComposeDeployment({
composeId: id,
});
}
toast.success("Deployment cancellation requested");
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to cancel deployment",
);
}
}}
>
Cancel Deployment
</Button>
</div>
</AlertBlock>
)}
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>

View File

@@ -102,9 +102,9 @@ export const ShowExternalMariadbCredentials = ({ mariadbId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -102,9 +102,9 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -102,9 +102,9 @@ export const ShowExternalMysqlCredentials = ({ mysqlId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -76,6 +76,10 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const { mutateAsync, isLoading, error, isError } =
api.application.create.useMutation();
@@ -94,8 +98,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
name: data.name,
appName: data.appName,
description: data.description,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId,
serverId: data.serverId,
})
.then(async () => {
toast.success("Service Created");
@@ -157,7 +161,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</FormItem>
)}
/>
{hasServers && (
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
@@ -186,13 +190,27 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={field.value}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -206,7 +224,9 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -82,6 +82,10 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddCompose>({
defaultValues: {
@@ -104,7 +108,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
environmentId,
composeType: data.composeType,
appName: data.appName,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
})
.then(async () => {
toast.success("Compose Created");
@@ -169,7 +173,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
)}
/>
</div>
{hasServers && (
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
@@ -198,13 +202,27 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={field.value}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -218,7 +236,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -179,6 +179,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.create.useMutation();
@@ -190,6 +191,10 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddDatabase>({
defaultValues: {
@@ -223,9 +228,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
name: data.name,
appName: data.appName,
dockerImage: defaultDockerImage,
projectId: environment?.projectId || "",
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId,
serverId: data.serverId,
description: data.description,
};
@@ -237,7 +241,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mongo") {
promise = mongoMutation.mutateAsync({
@@ -245,14 +249,14 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databasePassword: data.databasePassword,
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets,
});
} else if (data.type === "redis") {
promise = redisMutation.mutateAsync({
...commonParams,
databasePassword: data.databasePassword,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({
@@ -262,7 +266,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseName: data.databaseName || "mariadb",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? null : data.serverId,
});
} else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({
@@ -271,8 +275,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseName: data.databaseName || "mysql",
databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "",
serverId: data.serverId,
});
}
@@ -403,7 +407,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem>
)}
/>
{hasServers && (
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
@@ -412,13 +416,29 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<FormLabel>Select a Server</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -428,7 +448,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length})
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>

View File

@@ -141,6 +141,10 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
}) || [];
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
return (
<Dialog open={open} onOpenChange={setOpen}>
@@ -167,7 +171,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
<Input
placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)}
className="w-full sm:w-[200px]"
className="w-full"
value={query}
/>
<Input
@@ -244,7 +248,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onClick={() =>
setViewMode(viewMode === "detailed" ? "icon" : "detailed")
}
className="h-9 w-9"
className="h-9 w-9 flex-shrink-0"
>
{viewMode === "detailed" ? (
<LayoutGrid className="size-4" />
@@ -430,7 +434,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
project.
</AlertDialogDescription>
{hasServers && (
{shouldShowServerDropdown && (
<div>
<TooltipProvider delayDuration={0}>
<Tooltip>
@@ -459,12 +463,29 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onValueChange={(e) => {
setServerId(e);
}}
defaultValue={
!isCloud ? "dokploy" : undefined
}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
@@ -479,7 +500,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length})
Servers (
{servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -493,8 +515,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
disabled={isLoading}
onClick={async () => {
const promise = mutateAsync({
serverId:
serverId === "dokploy"
? undefined
: serverId,
environmentId,
serverId: serverId || undefined,
id: template.id,
baseUrl: customBaseUrl,
});

View File

@@ -25,7 +25,12 @@ const examples = [
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
// Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const handleExampleClick = (example: string) => {
setTemplateInfo({ ...templateInfo, userInput: example });
@@ -48,34 +53,58 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
/>
</div>
{hasServers && (
{shouldShowServerDropdown && (
<div className="space-y-2">
<Label htmlFor="server-deploy">
Select the server where you want to deploy (optional)
</Label>
<Select
value={templateInfo.server?.serverId}
value={
templateInfo.server?.serverId ||
(!isCloud ? "dokploy" : undefined)
}
onValueChange={(value) => {
const server = servers?.find((s) => s.serverId === value);
if (server) {
if (value === "dokploy") {
setTemplateInfo({
...templateInfo,
server: server,
server: undefined,
});
} else {
const server = servers?.find((s) => s.serverId === value);
if (server) {
setTemplateInfo({
...templateInfo,
server: server,
});
}
}
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" />
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
{server.name}
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>

View File

@@ -88,7 +88,7 @@ export const StepThree = ({ templateInfo }: StepProps) => {
<div>
<h3 className="text-sm font-semibold">Configuration Files</h3>
<ul className="list-disc pl-5">
{templateInfo?.details?.configFiles.map((file, index) => (
{templateInfo?.details?.configFiles?.map((file, index) => (
<li key={index}>
<strong className="text-sm font-semibold">
{file.filePath}

View File

@@ -1,5 +1,5 @@
import { Bot, Eye, EyeOff, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Bot, PlusCircle, Trash2 } from "lucide-react";
import { useEffect } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -27,7 +27,6 @@ export interface StepProps {
export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
const suggestions = templateInfo.suggestions || [];
const selectedVariant = templateInfo.details;
const [showValues, setShowValues] = useState<Record<string, boolean>>({});
const { mutateAsync, isLoading, error, isError } =
api.ai.suggest.useMutation();
@@ -44,7 +43,7 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
.then((data) => {
setTemplateInfo({
...templateInfo,
suggestions: data,
suggestions: data || [],
});
})
.catch((error) => {
@@ -54,10 +53,6 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
});
}, [templateInfo.userInput]);
const toggleShowValue = (name: string) => {
setShowValues((prev) => ({ ...prev, [name]: !prev[name] }));
};
const handleEnvVariableChange = (
index: number,
field: "name" | "value",
@@ -308,11 +303,9 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
placeholder="Variable Name"
className="flex-1"
/>
<div className="flex-1 relative">
<div className="relative">
<Input
type={
showValues[env.name] ? "text" : "password"
}
type={"password"}
value={env.value}
onChange={(e) =>
handleEnvVariableChange(
@@ -323,19 +316,6 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
}
placeholder="Variable Value"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 transform -translate-y-1/2"
onClick={() => toggleShowValue(env.name)}
>
{showValues[env.name] ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
</div>
<Button
type="button"
@@ -437,13 +417,14 @@ export const StepTwo = ({ templateInfo, setTemplateInfo }: StepProps) => {
<AccordionContent>
<ScrollArea className="w-full rounded-md border">
<div className="p-4 space-y-4">
{selectedVariant?.configFiles?.length > 0 ? (
{selectedVariant?.configFiles?.length &&
selectedVariant?.configFiles?.length > 0 ? (
<>
<div className="text-sm text-muted-foreground mb-4">
This template requires the following
configuration files to be mounted:
</div>
{selectedVariant.configFiles.map(
{selectedVariant?.configFiles?.map(
(config, index) => (
<div
key={index}

View File

@@ -47,7 +47,7 @@ interface Details {
envVariables: EnvVariable[];
shortDescription: string;
domains: Domain[];
configFiles: Mount[];
configFiles?: Mount[];
}
interface Mount {

View File

@@ -80,6 +80,29 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
// If duplicating to same project+environment, invalidate the environment query
// to refresh the services list
if (duplicateType === "existing-environment") {
await utils.environment.one.invalidate({
environmentId: selectedTargetEnvironment,
});
await utils.environment.byProjectId.invalidate({
projectId: selectedTargetProject,
});
// If duplicating to the same environment we're currently viewing,
// also invalidate the current environment to refresh the services list
if (selectedTargetEnvironment === environmentId) {
await utils.environment.one.invalidate({ environmentId });
// Also invalidate the project query to refresh the project data
const projectId = router.query.projectId as string;
if (projectId) {
await utils.project.one.invalidate({ projectId });
}
}
}
toast.success(
duplicateType === "new-project"
? "Project duplicated successfully"

View File

@@ -44,7 +44,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import {
Select,
SelectContent,
@@ -144,12 +144,13 @@ export const ShowProjects = () => {
<>
<div className="flex max-sm:flex-col gap-4 items-center w-full">
<div className="flex-1 relative max-sm:w-full">
<Input
<FocusShortcutInput
placeholder="Filter projects..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pr-10"
/>
<Search className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2 min-w-48 max-sm:w-full">
@@ -290,45 +291,48 @@ export const ShowProjects = () => {
)}
</DropdownMenuGroup>
)}
{/*
{project.compose.length > 0 && (
{project.environments.some(
(env) => env.compose.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
))}
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)} */}
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}

View File

@@ -96,9 +96,9 @@ export const ShowExternalRedisCredentials = ({ redisId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription>
In order to make the database reachable trought internet is
required to set a port, make sure the port is not used by another
application or database
In order to make the database reachable through the internet, you
must set a port and ensure that the port is not being used by
another application or database
</CardDescription>
</CardHeader>
<CardContent className="flex w-full flex-col gap-4">

View File

@@ -3,6 +3,10 @@
import { BookIcon, CircuitBoard, GlobeIcon } from "lucide-react";
import { useRouter } from "next/router";
import React from "react";
import {
extractServices,
type Services,
} from "@/components/dashboard/settings/users/add-permissions";
import {
MariadbIcon,
MongodbIcon,
@@ -20,13 +24,34 @@ import {
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
// import {
// extractServices,
// type Services,
// } from "@/pages/dashboard/project/[projectId]";
import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip";
// Extended Services type to include environmentId and environmentName for search navigation
type SearchServices = Services & {
environmentId: string;
environmentName: string;
};
const extractAllServicesFromProject = (project: any): SearchServices[] => {
const allServices: SearchServices[] = [];
// Iterate through all environments in the project
project.environments?.forEach((environment: any) => {
const environmentServices = extractServices(environment);
const servicesWithEnvironmentId: SearchServices[] = environmentServices.map(
(service) => ({
...service,
environmentId: environment.environmentId,
environmentName: environment.name,
}),
);
allServices.push(...servicesWithEnvironmentId);
});
return allServices;
};
export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
@@ -51,7 +76,7 @@ export const SearchCommand = () => {
return (
<div>
{/* <CommandDialog open={open} onOpenChange={setOpen}>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput
placeholder={"Search projects or settings"}
value={search}
@@ -63,25 +88,36 @@ export const SearchCommand = () => {
</CommandEmpty>
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(`/dashboard/project/${project.projectId}`);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name}
</CommandItem>
))}
{data?.map((project) => {
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);
if (!productionEnvironment) return null;
return (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} / {productionEnvironment!.name}
</CommandItem>
);
})}
</CommandList>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading={"Services"}>
<CommandList>
{data?.map((project) => {
const applications: Services[] = extractServices(project);
const applications: SearchServices[] =
extractAllServicesFromProject(project);
return applications.map((application) => (
<CommandItem
key={application.id}
@@ -114,7 +150,8 @@ export const SearchCommand = () => {
<CircuitBoard className="h-6 w-6 mr-2" />
)}
<span className="flex-grow">
{project.name} / {application.name}{" "}
{project.name} / {application.environmentName} /{" "}
{application.name}{" "}
<div style={{ display: "none" }}>{application.id}</div>
</span>
<div>
@@ -181,7 +218,7 @@ export const SearchCommand = () => {
</CommandItem>
</CommandGroup>
</CommandList>
</CommandDialog> */}
</CommandDialog>
</div>
);
};

View File

@@ -65,6 +65,11 @@ export const AddCertificate = () => {
const { mutateAsync, isError, error, isLoading } =
api.certificates.create.useMutation();
const { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const form = useForm<AddCertificate>({
defaultValues: {
@@ -85,7 +90,7 @@ export const AddCertificate = () => {
certificateData: data.certificateData,
privateKey: data.privateKey,
autoRenew: data.autoRenew,
serverId: data.serverId,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
organizationId: "",
})
.then(async () => {
@@ -174,52 +179,70 @@ export const AddCertificate = () => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud && "(Optional)"}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud && "(Optional)"}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
</Tooltip>
</TooltipProvider>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a Server" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</span>
</SelectItem>
))}
<SelectLabel>Servers ({servers?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
<DialogFooter className="flex w-full flex-row !justify-end">

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
@@ -27,18 +26,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
username: z.string().min(1, {
message: "Username is required",
}),
password: z.string().min(1, {
message: "App Password is required",
}),
name: z.string().min(1, { message: "Name is required" }),
username: z.string().min(1, { message: "Username is required" }),
email: z.string().email().optional(),
apiToken: z.string().min(1, { message: "API Token is required" }),
workspaceName: z.string().optional(),
});
@@ -47,14 +40,12 @@ type Schema = z.infer<typeof Schema>;
export const AddBitbucketProvider = () => {
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const _url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.user.get.useQuery();
const _router = useRouter();
const form = useForm<Schema>({
defaultValues: {
username: "",
password: "",
apiToken: "",
workspaceName: "",
},
resolver: zodResolver(Schema),
@@ -63,7 +54,8 @@ export const AddBitbucketProvider = () => {
useEffect(() => {
form.reset({
username: "",
password: "",
email: "",
apiToken: "",
workspaceName: "",
});
}, [form, isOpen]);
@@ -71,10 +63,11 @@ export const AddBitbucketProvider = () => {
const onSubmit = async (data: Schema) => {
await mutateAsync({
bitbucketUsername: data.username,
appPassword: data.password,
apiToken: data.apiToken,
bitbucketWorkspaceName: data.workspaceName || "",
authId: auth?.id || "",
name: data.name || "",
bitbucketEmail: data.email || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -113,37 +106,46 @@ export const AddBitbucketProvider = () => {
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<AlertBlock type="warning">
Bitbucket App Passwords are deprecated for new providers. Use
an API Token instead. Existing providers with App Passwords
will continue to work until 9th June 2026.
</AlertBlock>
<div className="mt-1 text-sm">
Manage tokens in
<Link
href="https://id.atlassian.com/manage-profile/security/api-tokens"
target="_blank"
className="inline-flex items-center gap-1 ml-1"
>
<span>Bitbucket settings</span>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</div>
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
<li className="text-muted-foreground text-sm">
Click on Create API token with scopes
</li>
<li className="text-muted-foreground text-sm">
Select the expiration date (Max 1 year)
</li>
<li className="text-muted-foreground text-sm">
Select Bitbucket product.
</li>
</ul>
<p className="text-muted-foreground text-sm">
To integrate your Bitbucket account, you need to create a new
App Password in your Bitbucket settings. Follow these steps:
Select the following scopes:
</p>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center">
Create new App Password{" "}
<Link
href="https://bitbucket.org/account/settings/app-passwords/new"
target="_blank"
>
<ExternalLink className="w-fit text-primary size-4" />
</Link>
</li>
<li>
When creating the App Password, ensure you grant the
following permissions:
<ul className="list-disc list-inside ml-4">
<li>Account: Read</li>
<li>Workspace membership: Read</li>
<li>Projects: Read</li>
<li>Repositories: Read</li>
<li>Pull requests: Read</li>
<li>Webhooks: Read and write</li>
</ul>
</li>
<li>
After creating, you'll receive an App Password. Copy it and
paste it below along with your Bitbucket username.
</li>
</ol>
<ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
<li>read:repository:bitbucket</li>
<li>read:pullrequest:bitbucket</li>
<li>read:webhook:bitbucket</li>
<li>read:workspace:bitbucket</li>
<li>write:webhook:bitbucket</li>
</ul>
<FormField
control={form.control}
name="name"
@@ -152,7 +154,7 @@ export const AddBitbucketProvider = () => {
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="Random Name eg(my-personal-account)"
placeholder="Your Bitbucket Provider, eg: my-personal-account"
{...field}
/>
</FormControl>
@@ -179,14 +181,27 @@ export const AddBitbucketProvider = () => {
<FormField
control={form.control}
name="password"
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>App Password</FormLabel>
<FormLabel>Bitbucket Email</FormLabel>
<FormControl>
<Input placeholder="Your Bitbucket email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token</FormLabel>
<FormControl>
<Input
type="password"
placeholder="ATBBPDYUC94nR96Nj7Cqpp4pfwKk03573DD2"
placeholder="Paste your Bitbucket API token"
{...field}
/>
</FormControl>
@@ -200,7 +215,7 @@ export const AddBitbucketProvider = () => {
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace Name (Optional)</FormLabel>
<FormLabel>Workspace Name (optional)</FormLabel>
<FormControl>
<Input
placeholder="For organization accounts"

View File

@@ -33,7 +33,10 @@ const Schema = z.object({
username: z.string().min(1, {
message: "Username is required",
}),
email: z.string().email().optional(),
workspaceName: z.string().optional(),
apiToken: z.string().optional(),
appPassword: z.string().optional(),
});
type Schema = z.infer<typeof Schema>;
@@ -60,19 +63,28 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
username: "",
email: "",
workspaceName: "",
apiToken: "",
appPassword: "",
},
resolver: zodResolver(Schema),
});
const username = form.watch("username");
const email = form.watch("email");
const workspaceName = form.watch("workspaceName");
const apiToken = form.watch("apiToken");
const appPassword = form.watch("appPassword");
useEffect(() => {
form.reset({
username: bitbucket?.bitbucketUsername || "",
email: bitbucket?.bitbucketEmail || "",
workspaceName: bitbucket?.bitbucketWorkspaceName || "",
name: bitbucket?.gitProvider.name || "",
apiToken: bitbucket?.apiToken || "",
appPassword: bitbucket?.appPassword || "",
});
}, [form, isOpen, bitbucket]);
@@ -81,8 +93,11 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
bitbucketId,
gitProviderId: bitbucket?.gitProviderId || "",
bitbucketUsername: data.username,
bitbucketEmail: data.email || "",
bitbucketWorkspaceName: data.workspaceName || "",
name: data.name || "",
apiToken: data.apiToken || "",
appPassword: data.appPassword || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -121,6 +136,12 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
>
<CardContent className="p-0">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">
Update your Bitbucket authentication. Use API Token for
enhanced security (recommended) or App Password for legacy
support.
</p>
<FormField
control={form.control}
name="name"
@@ -154,6 +175,24 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (Required for API Tokens)</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Your Bitbucket email address"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="workspaceName"
@@ -171,6 +210,49 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
)}
/>
<div className="flex flex-col gap-2 border-t pt-4">
<h3 className="text-sm font-medium mb-2">
Authentication (Update to use API Token)
</h3>
<FormField
control={form.control}
name="apiToken"
render={({ field }) => (
<FormItem>
<FormLabel>API Token (Recommended)</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Bitbucket API Token"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="appPassword"
render={({ field }) => (
<FormItem>
<FormLabel>
App Password (Legacy - will be deprecated June 2026)
</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your Bitbucket App Password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="flex w-full justify-between gap-4 mt-4">
<Button
type="button"
@@ -180,7 +262,10 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
await testConnection({
bitbucketId,
bitbucketUsername: username,
bitbucketEmail: email,
workspaceName: workspaceName,
apiToken: apiToken,
appPassword: appPassword,
})
.then(async (message) => {
toast.info(`Message: ${message}`);

View File

@@ -30,6 +30,9 @@ const Schema = z.object({
name: z.string().min(1, {
message: "Name is required",
}),
appName: z.string().min(1, {
message: "App Name is required",
}),
});
type Schema = z.infer<typeof Schema>;
@@ -55,6 +58,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
name: "",
appName: "",
},
resolver: zodResolver(Schema),
});
@@ -62,6 +66,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
useEffect(() => {
form.reset({
name: github?.gitProvider.name || "",
appName: github?.githubAppName || "",
});
}, [form, isOpen]);
@@ -70,6 +75,7 @@ export const EditGithubProvider = ({ githubId }: Props) => {
githubId,
name: data.name || "",
gitProviderId: github?.gitProviderId || "",
githubAppName: data.appName || "",
})
.then(async () => {
await utils.gitProvider.getAll.invalidate();
@@ -124,6 +130,22 @@ export const EditGithubProvider = ({ githubId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input
placeholder="pp Name eg(my-personal)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-between gap-4 mt-4">
<Button

View File

@@ -157,7 +157,13 @@ export const ShowGitProviders = () => {
</div>
</div>
<div className="flex flex-row gap-1">
<div className="flex flex-row gap-1 items-center">
{isBitbucket &&
gitProvider.bitbucket?.appPassword &&
!gitProvider.bitbucket?.apiToken ? (
<Badge variant="yellow">Deprecated</Badge>
) : null}
{!haveGithubRequirements && isGithub && (
<div className="flex flex-row gap-1 items-center">
<Badge

View File

@@ -101,6 +101,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
decoration: z.boolean().default(true),
})
.merge(notificationBaseSchema),
z
.object({
type: z.literal("ntfy"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
topic: z.string().min(1, { message: "Topic is required" }),
accessToken: z.string().min(1, { message: "Access Token is required" }),
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
]);
export const notificationsMap = {
@@ -124,6 +133,10 @@ export const notificationsMap = {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify",
},
ntfy: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "ntfy",
},
};
export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -155,6 +168,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testEmailConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
api.notification.testNtfyConnection.useMutation();
const slackMutation = notificationId
? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation();
@@ -170,6 +185,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation();
const ntfyMutation = notificationId
? api.notification.updateNtfy.useMutation()
: api.notification.createNtfy.useMutation();
const form = useForm<NotificationSchema>({
defaultValues: {
@@ -266,6 +284,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
} else if (notification.notificationType === "ntfy") {
form.reset({
appBuildError: notification.appBuildError,
appDeploy: notification.appDeploy,
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken,
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
});
}
} else {
form.reset();
@@ -278,6 +310,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
discord: discordMutation,
email: emailMutation,
gotify: gotifyMutation,
ntfy: ntfyMutation,
};
const onSubmit = async (data: NotificationSchema) => {
@@ -366,6 +399,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "",
});
} else if (data.type === "ntfy") {
promise = ntfyMutation.mutateAsync({
appBuildError: appBuildError,
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken,
topic: data.topic,
priority: data.priority,
name: data.name,
dockerCleanup: dockerCleanup,
notificationId: notificationId || "",
ntfyId: notification?.ntfyId || "",
});
}
if (promise) {
@@ -875,6 +923,83 @@ export const HandleNotifications = ({ notificationId }: Props) => {
/>
</>
)}
{type === "ntfy" && (
<>
<FormField
control={form.control}
name="serverUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Server URL</FormLabel>
<FormControl>
<Input placeholder="https://ntfy.sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="topic"
render={({ field }) => (
<FormItem>
<FormLabel>Topic</FormLabel>
<FormControl>
<Input placeholder="deployments" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="accessToken"
render={({ field }) => (
<FormItem>
<FormLabel>Access Token</FormLabel>
<FormControl>
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="priority"
defaultValue={3}
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Priority</FormLabel>
<FormControl>
<Input
placeholder="3"
{...field}
onChange={(e) => {
const value = e.target.value;
if (value) {
const port = Number.parseInt(value);
if (port > 0 && port <= 5) {
field.onChange(port);
}
}
}}
type="number"
/>
</FormControl>
<FormDescription>
Message priority (1-5, default: 3)
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</div>
</div>
<div className="flex flex-col gap-4">
@@ -1024,7 +1149,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram ||
isLoadingDiscord ||
isLoadingEmail ||
isLoadingGotify
isLoadingGotify ||
isLoadingNtfy
}
variant="secondary"
onClick={async () => {
@@ -1061,6 +1187,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: form.getValues("priority"),
decoration: form.getValues("decoration"),
});
} else if (type === "ntfy") {
await testNtfyConnection({
serverUrl: form.getValues("serverUrl"),
topic: form.getValues("topic"),
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
});
}
toast.success("Connection Success");
} catch {

View File

@@ -88,6 +88,11 @@ export const ShowNotifications = () => {
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.notificationType === "ntfy" && (
<div className="flex items-center justify-center rounded-lg ">
<MessageCircleMore className="size-6 text-muted-foreground" />
</div>
)}
{notification.name}
</span>

View File

@@ -33,7 +33,10 @@ import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({
email: z.string(),
email: z
.string()
.email("Please enter a valid email address")
.min(1, "Email is required"),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),

View File

@@ -97,11 +97,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
);
refetchDashboard();
})
.catch(() => {
toast.error(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
});
.catch(() => {});
}}
className="w-full cursor-pointer space-x-3"
>

View File

@@ -0,0 +1,36 @@
import { useEffect, useRef } from "react";
import { Input } from "@/components/ui/input";
type Props = React.ComponentPropsWithoutRef<typeof Input>;
export const FocusShortcutInput = (props: Props) => {
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
const isMod = e.metaKey || e.ctrlKey;
if (!isMod || e.key.toLowerCase() !== "k") return;
const target = e.target as HTMLElement | null;
if (target) {
const tag = target.tagName;
if (
target.isContentEditable ||
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
target.getAttribute("role") === "textbox"
)
return;
}
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return <Input {...props} ref={inputRef} />;
};

View File

@@ -0,0 +1,11 @@
ALTER TYPE "public"."notificationType" ADD VALUE 'ntfy';--> statement-breakpoint
CREATE TABLE "ntfy" (
"ntfyId" text PRIMARY KEY NOT NULL,
"serverUrl" text NOT NULL,
"topic" text NOT NULL,
"accessToken" text NOT NULL,
"priority" integer DEFAULT 3 NOT NULL
);
--> statement-breakpoint
ALTER TABLE "notification" ADD COLUMN "ntfyId" text;--> statement-breakpoint
ALTER TABLE "notification" ADD CONSTRAINT "notification_ntfyId_ntfy_ntfyId_fk" FOREIGN KEY ("ntfyId") REFERENCES "public"."ntfy"("ntfyId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "bitbucket" ADD COLUMN "apiToken" text;

View File

@@ -0,0 +1 @@
ALTER TABLE "bitbucket" ADD COLUMN "bitbucketEmail" text;

View File

@@ -1,5 +1,5 @@
{
"id": "e182597b-57b3-4c6a-927b-e283438eccf9",
"id": "9f9b4142-e739-4c21-8618-676d62e9b5ae",
"prevId": "b5e88b4f-1396-4456-b338-65c93e2194fa",
"version": "7",
"dialect": "postgresql",
@@ -1249,12 +1249,6 @@
"primaryKey": false,
"notNull": false
},
"stopGracePeriodSwarm": {
"name": "stopGracePeriodSwarm",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"replicas": {
"name": "replicas",
"type": "integer",
@@ -4222,6 +4216,12 @@
"primaryKey": false,
"notNull": false
},
"ntfyId": {
"name": "ntfyId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organizationId": {
"name": "organizationId",
"type": "text",
@@ -4296,6 +4296,19 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_ntfyId_ntfy_ntfyId_fk": {
"name": "notification_ntfyId_ntfy_ntfyId_fk",
"tableFrom": "notification",
"tableTo": "ntfy",
"columnsFrom": [
"ntfyId"
],
"columnsTo": [
"ntfyId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"notification_organizationId_organization_id_fk": {
"name": "notification_organizationId_organization_id_fk",
"tableFrom": "notification",
@@ -4316,6 +4329,50 @@
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ntfy": {
"name": "ntfy",
"schema": "",
"columns": {
"ntfyId": {
"name": "ntfyId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"serverUrl": {
"name": "serverUrl",
"type": "text",
"primaryKey": false,
"notNull": true
},
"topic": {
"name": "topic",
"type": "text",
"primaryKey": false,
"notNull": true
},
"accessToken": {
"name": "accessToken",
"type": "text",
"primaryKey": false,
"notNull": true
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.slack": {
"name": "slack",
"schema": "",
@@ -6407,7 +6464,8 @@
"telegram",
"discord",
"email",
"gotify"
"gotify",
"ntfy"
]
},
"public.protocolType": {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -775,8 +775,22 @@
{
"idx": 110,
"version": "7",
"when": 1757088700712,
"tag": "0110_vengeful_maddog",
"when": 1757189541734,
"tag": "0110_red_psynapse",
"breakpoints": true
},
{
"idx": 111,
"version": "7",
"when": 1758445844561,
"tag": "0111_mushy_wolfsbane",
"breakpoints": true
},
{
"idx": 112,
"version": "7",
"when": 1758483520214,
"tag": "0112_freezing_skrulls",
"breakpoints": true
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.25.0",
"version": "v0.25.3",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -112,7 +112,7 @@
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"js-yaml": "4.1.0",
"yaml": "2.8.1",
"lodash": "4.17.21",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
@@ -160,7 +160,6 @@
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6",
"@types/js-yaml": "4.0.9",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",

View File

@@ -1,4 +1,9 @@
import { IS_CLOUD, shouldDeploy } from "@dokploy/server";
import {
type Bitbucket,
getBitbucketHeaders,
IS_CLOUD,
shouldDeploy,
} from "@dokploy/server";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { db } from "@/server/db";
@@ -146,10 +151,10 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths(
req.body,
application.bitbucketOwner,
application.bitbucket?.appPassword || "",
application.bitbucket,
application.bitbucketRepository || "",
);
const shouldDeployPaths = shouldDeploy(
application.watchPaths,
commitedPaths,
@@ -354,9 +359,8 @@ export const getProviderByHeader = (headers: any) => {
export const extractCommitedPaths = async (
body: any,
bitbucketUsername: string | null,
bitbucketAppPassword: string | null,
repository: string | null,
bitbucket: Bitbucket | null,
repository: string,
) => {
const changes = body.push?.changes || [];
@@ -365,18 +369,16 @@ export const extractCommitedPaths = async (
.filter(Boolean);
const commitedPaths: string[] = [];
for (const commit of commitHashes) {
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucketUsername}/${repository}/diffstat/${commit}`;
const url = `https://api.bitbucket.org/2.0/repositories/${bitbucket?.bitbucketUsername}/${repository}/diffstat/${commit}`;
try {
const response = await fetch(url, {
headers: {
Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`,
},
headers: getBitbucketHeaders(bitbucket!),
});
const data = await response.json();
for (const value of data.values) {
commitedPaths.push(value.new?.path);
if (value?.new?.path) commitedPaths.push(value.new.path);
}
} catch (error) {
console.error(

View File

@@ -99,8 +99,7 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths(
req.body,
composeResult.bitbucketOwner,
composeResult.bitbucket?.appPassword || "",
composeResult.bitbucket,
composeResult.bitbucketRepository || "",
);

View File

@@ -80,7 +80,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
@@ -96,6 +95,7 @@ import {
import { cn } from "@/lib/utils";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
export type Services = {
appName: string;
@@ -1197,7 +1197,7 @@ const EnvironmentPage = (
<div className="flex flex-col gap-2 lg:flex-row lg:gap-4 lg:items-center">
<div className="w-full relative">
<Input
<FocusShortcutInput
placeholder="Filter services..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}

View File

@@ -24,6 +24,7 @@ import {
unzipDrop,
updateApplication,
updateApplicationStatus,
updateDeploymentStatus,
writeConfig,
writeConfigRemote,
// uploadFileSchema
@@ -40,8 +41,10 @@ import {
import { db } from "@/server/db";
import {
apiCreateApplication,
apiDeployApplication,
apiFindMonitoringStats,
apiFindOneApplication,
apiRedeployApplication,
apiReloadApplication,
apiSaveBitbucketProvider,
apiSaveBuildType,
@@ -56,7 +59,7 @@ import {
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { uploadFileSchema } from "@/utils/schema";
export const applicationRouter = createTRPCRouter({
@@ -306,7 +309,7 @@ export const applicationRouter = createTRPCRouter({
}),
redeploy: protectedProcedure
.input(apiFindOneApplication)
.input(apiRedeployApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
@@ -320,8 +323,8 @@ export const applicationRouter = createTRPCRouter({
}
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Rebuild deployment",
descriptionLog: "",
titleLog: input.title || "Rebuild deployment",
descriptionLog: input.description || "",
type: "redeploy",
applicationType: "application",
server: !!application.serverId,
@@ -670,7 +673,7 @@ export const applicationRouter = createTRPCRouter({
return true;
}),
deploy: protectedProcedure
.input(apiFindOneApplication)
.input(apiDeployApplication)
.mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId);
if (
@@ -684,8 +687,8 @@ export const applicationRouter = createTRPCRouter({
}
const jobData: DeploymentJob = {
applicationId: input.applicationId,
titleLog: "Manual deployment",
descriptionLog: "",
titleLog: input.title || "Manual deployment",
descriptionLog: input.description || "",
type: "deploy",
applicationType: "application",
server: !!application.serverId,
@@ -894,4 +897,55 @@ export const applicationRouter = createTRPCRouter({
return updatedApplication;
}),
cancelDeployment: 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 cancel this deployment",
});
}
if (IS_CLOUD && application.serverId) {
try {
await updateApplicationStatus(input.applicationId, "idle");
if (application.deployments[0]) {
await updateDeploymentStatus(
application.deployments[0].deploymentId,
"done",
);
}
await cancelDeployment({
applicationId: input.applicationId,
applicationType: "application",
});
return {
success: true,
message: "Deployment cancellation requested",
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to cancel deployment",
});
}
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment cancellation only available in cloud version",
});
}),
});

View File

@@ -7,7 +7,7 @@ import {
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import { getPublicIpWithFallback } from "@/server/wss/terminal";
import { getLocalServerIp } from "@/server/wss/terminal";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const clusterRouter = createTRPCRouter({
getNodes: protectedProcedure
@@ -61,7 +61,7 @@ export const clusterRouter = createTRPCRouter({
const result = await docker.swarmInspect();
const docker_version = await docker.version();
let ip = await getPublicIpWithFallback();
let ip = await getLocalServerIp();
if (input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;
@@ -85,7 +85,7 @@ export const clusterRouter = createTRPCRouter({
const result = await docker.swarmInspect();
const docker_version = await docker.version();
let ip = await getPublicIpWithFallback();
let ip = await getLocalServerIp();
if (input.serverId) {
const server = await findServerById(input.serverId);
ip = server?.ipAddress;

View File

@@ -29,6 +29,7 @@ import {
startCompose,
stopCompose,
updateCompose,
updateDeploymentStatus,
} from "@dokploy/server";
import {
type CompleteTemplate,
@@ -38,25 +39,27 @@ import {
import { processTemplate } from "@dokploy/server/templates/processors";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { dump } from "js-yaml";
import _ from "lodash";
import { nanoid } from "nanoid";
import { parse } from "toml";
import { stringify } from "yaml";
import { z } from "zod";
import { slugify } from "@/lib/slug";
import { db } from "@/server/db";
import {
apiCreateCompose,
apiDeleteCompose,
apiDeployCompose,
apiFetchServices,
apiFindCompose,
apiRandomizeCompose,
apiRedeployCompose,
apiUpdateCompose,
compose as composeTable,
} from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
import { deploy } from "@/server/utils/deploy";
import { cancelDeployment, deploy } from "@/server/utils/deploy";
import { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
@@ -361,13 +364,13 @@ export const composeRouter = createTRPCRouter({
}
const domains = await findDomainsByComposeId(input.composeId);
const composeFile = await addDomainToCompose(compose, domains);
return dump(composeFile, {
return stringify(composeFile, {
lineWidth: 1000,
});
}),
deploy: protectedProcedure
.input(apiFindCompose)
.input(apiDeployCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
@@ -382,10 +385,10 @@ export const composeRouter = createTRPCRouter({
}
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Manual deployment",
titleLog: input.title || "Manual deployment",
type: "deploy",
applicationType: "compose",
descriptionLog: "",
descriptionLog: input.description || "",
server: !!compose.serverId,
};
@@ -404,7 +407,7 @@ export const composeRouter = createTRPCRouter({
);
}),
redeploy: protectedProcedure
.input(apiFindCompose)
.input(apiRedeployCompose)
.mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId);
if (
@@ -418,10 +421,10 @@ export const composeRouter = createTRPCRouter({
}
const jobData: DeploymentJob = {
composeId: input.composeId,
titleLog: "Rebuild deployment",
titleLog: input.title || "Rebuild deployment",
type: "redeploy",
applicationType: "compose",
descriptionLog: "",
descriptionLog: input.description || "",
server: !!compose.serverId,
};
if (IS_CLOUD && compose.serverId) {
@@ -926,4 +929,57 @@ export const composeRouter = createTRPCRouter({
});
}
}),
cancelDeployment: 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 cancel this deployment",
});
}
if (IS_CLOUD && compose.serverId) {
try {
await updateCompose(input.composeId, {
composeStatus: "idle",
});
if (compose.deployments[0]) {
await updateDeploymentStatus(
compose.deployments[0].deploymentId,
"done",
);
}
await cancelDeployment({
composeId: input.composeId,
applicationType: "compose",
});
return {
success: true,
message: "Deployment cancellation requested",
};
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error
? error.message
: "Failed to cancel deployment",
});
}
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Deployment cancellation only available in cloud version",
});
}),
});

View File

@@ -3,6 +3,7 @@ import {
getGithubBranches,
getGithubRepositories,
haveGithubRequirements,
updateGithub,
updateGitProvider,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -134,5 +135,9 @@ export const githubRouter = createTRPCRouter({
name: input.name,
organizationId: ctx.session.activeOrganizationId,
});
await updateGithub(input.githubId, {
...input,
});
}),
});

View File

@@ -2,6 +2,7 @@ import {
createDiscordNotification,
createEmailNotification,
createGotifyNotification,
createNtfyNotification,
createSlackNotification,
createTelegramNotification,
findNotificationById,
@@ -10,12 +11,14 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendNtfyNotification,
sendServerThresholdNotifications,
sendSlackNotification,
sendTelegramNotification,
updateDiscordNotification,
updateEmailNotification,
updateGotifyNotification,
updateNtfyNotification,
updateSlackNotification,
updateTelegramNotification,
} from "@dokploy/server";
@@ -33,17 +36,20 @@ import {
apiCreateDiscord,
apiCreateEmail,
apiCreateGotify,
apiCreateNtfy,
apiCreateSlack,
apiCreateTelegram,
apiFindOneNotification,
apiTestDiscordConnection,
apiTestEmailConnection,
apiTestGotifyConnection,
apiTestNtfyConnection,
apiTestSlackConnection,
apiTestTelegramConnection,
apiUpdateDiscord,
apiUpdateEmail,
apiUpdateGotify,
apiUpdateNtfy,
apiUpdateSlack,
apiUpdateTelegram,
notifications,
@@ -321,6 +327,7 @@ export const notificationRouter = createTRPCRouter({
discord: true,
email: true,
gotify: true,
ntfy: true,
},
orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),
@@ -446,6 +453,64 @@ export const notificationRouter = createTRPCRouter({
});
}
}),
createNtfy: adminProcedure
.input(apiCreateNtfy)
.mutation(async ({ input, ctx }) => {
try {
return await createNtfyNotification(
input,
ctx.session.activeOrganizationId,
);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the notification",
cause: error,
});
}
}),
updateNtfy: adminProcedure
.input(apiUpdateNtfy)
.mutation(async ({ input, ctx }) => {
try {
const notification = await findNotificationById(input.notificationId);
if (
IS_CLOUD &&
notification.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this notification",
});
}
return await updateNtfyNotification({
...input,
organizationId: ctx.session.activeOrganizationId,
});
} catch (error) {
throw error;
}
}),
testNtfyConnection: adminProcedure
.input(apiTestNtfyConnection)
.mutation(async ({ input }) => {
try {
await sendNtfyNotification(
input,
"Test Notification",
"",
"view, visit Dokploy on Github, https://github.com/dokploy/dokploy, clear=true;",
"Hi, From Dokploy 👋",
);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error testing the notification",
cause: error,
});
}
}),
getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId),

View File

@@ -46,8 +46,8 @@ import {
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { TRPCError } from "@trpc/server";
import { eq, sql } from "drizzle-orm";
import { dump, load } from "js-yaml";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { parse, stringify } from "yaml";
import { z } from "zod";
import { db } from "@/server/db";
import {
@@ -657,7 +657,7 @@ export const settingsRouter = createTRPCRouter({
const config = readMainConfig();
if (!config) return false;
const parsedConfig = load(config) as {
const parsedConfig = parse(config) as {
accessLog?: {
filePath: string;
};
@@ -678,7 +678,7 @@ export const settingsRouter = createTRPCRouter({
const mainConfig = readMainConfig();
if (!mainConfig) return false;
const currentConfig = load(mainConfig) as {
const currentConfig = parse(mainConfig) as {
accessLog?: {
filePath: string;
};
@@ -701,7 +701,7 @@ export const settingsRouter = createTRPCRouter({
currentConfig.accessLog = undefined;
}
writeMainConfig(dump(currentConfig));
writeMainConfig(stringify(currentConfig));
return true;
}),

View File

@@ -192,7 +192,16 @@ export const userRouter = createTRPCRouter({
})
.where(eq(account.userId, ctx.user.id));
}
return await updateUser(ctx.user.id, input);
try {
return await updateUser(ctx.user.id, input);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error ? error.message : "Failed to update user",
});
}
}),
getUserByToken: publicProcedure
.input(apiFindOneToken)

View File

@@ -23,3 +23,30 @@ export const deploy = async (jobData: DeploymentJob) => {
throw error;
}
};
type CancelDeploymentData =
| { applicationId: string; applicationType: "application" }
| { composeId: string; applicationType: "compose" };
export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
try {
const result = await fetch(`${process.env.SERVER_URL}/cancel-deployment`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": process.env.API_KEY || "NO-DEFINED",
},
body: JSON.stringify(cancelData),
});
if (!result.ok) {
const errorData = await result.json().catch(() => ({}));
throw new Error(errorData.message || "Failed to cancel deployment");
}
const data = await result.json();
return data;
} catch (error) {
throw error;
}
};

View File

@@ -1,5 +1,10 @@
import type http from "node:http";
import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import {
execAsync,
findServerById,
IS_CLOUD,
validateRequest,
} from "@dokploy/server";
import { publicIpv4, publicIpv6 } from "public-ip";
import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws";
@@ -44,6 +49,21 @@ export const getPublicIpWithFallback = async () => {
return ip;
};
export const getLocalServerIp = async () => {
try {
const command = `ip addr show | grep -E "inet (192\.168\.|10\.|172\.1[6-9]\.|172\.2[0-9]\.|172\.3[0-1]\.)" | head -n1 | awk '{print $2}' | cut -d/ -f1`;
const { stdout } = await execAsync(command);
const ip = stdout.trim();
return (
ip ||
"We were unable to obtain the local server IP, please use your private IP address"
);
} catch (error) {
console.error("Error to obtain local server IP", error);
return "We were unable to obtain the local server IP, please use your private IP address";
}
};
export const setupTerminalWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
) => {

View File

@@ -1,3 +1,4 @@
import { exit } from "node:process";
import { execAsync } from "@dokploy/server";
import { setupDirectories } from "@dokploy/server/setup/config-paths";
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
@@ -25,6 +26,8 @@ import {
await initializeStandaloneTraefik();
await initializeRedis();
await initializePostgres();
console.log("Dokploy setup completed");
exit(0);
} catch (e) {
console.error("Error in dokploy setup", e);
}

View File

@@ -11,7 +11,7 @@ import {
} from "./queue.js";
import { jobQueueSchema } from "./schema.js";
import { initializeJobs } from "./utils.js";
import { firstWorker, secondWorker } from "./workers.js";
import { firstWorker, secondWorker, thirdWorker } from "./workers.js";
const app = new Hono();
@@ -91,6 +91,7 @@ export const gracefulShutdown = async (signal: string) => {
logger.warn(`Received ${signal}, closing server...`);
await firstWorker.close();
await secondWorker.close();
await thirdWorker.close();
process.exit(0);
};

View File

@@ -7,22 +7,34 @@ import { runJobs } from "./utils.js";
export const firstWorker = new Worker(
"backupQueue",
async (job: Job<QueueJob>) => {
logger.info({ data: job.data }, "Running job");
logger.info({ data: job.data }, "Running job first worker");
await runJobs(job.data);
},
{
concurrency: 50,
concurrency: 100,
connection,
},
);
export const secondWorker = new Worker(
"backupQueue",
async (job: Job<QueueJob>) => {
logger.info({ data: job.data }, "Running job");
logger.info({ data: job.data }, "Running job second worker");
await runJobs(job.data);
},
{
concurrency: 50,
concurrency: 100,
connection,
},
);
export const thirdWorker = new Worker(
"backupQueue",
async (job: Job<QueueJob>) => {
logger.info({ data: job.data }, "Running job third worker");
await runJobs(job.data);
},
{
concurrency: 100,
connection,
},
);

View File

@@ -57,7 +57,7 @@
"drizzle-orm": "^0.39.3",
"drizzle-zod": "0.5.1",
"hi-base32": "^0.5.1",
"js-yaml": "4.1.0",
"yaml": "2.8.1",
"lodash": "4.17.21",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
@@ -85,7 +85,6 @@
"@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2",
"@types/dockerode": "3.3.23",
"@types/js-yaml": "4.0.9",
"@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9",
"@types/node": "^18.19.104",

View File

@@ -331,6 +331,26 @@ export const apiFindOneApplication = createSchema
})
.required();
export const apiDeployApplication = createSchema
.pick({
applicationId: true,
})
.extend({
applicationId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiRedeployApplication = createSchema
.pick({
applicationId: true,
})
.extend({
applicationId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiReloadApplication = createSchema
.pick({
appName: true,

View File

@@ -11,7 +11,9 @@ export const bitbucket = pgTable("bitbucket", {
.primaryKey()
.$defaultFn(() => nanoid()),
bitbucketUsername: text("bitbucketUsername"),
bitbucketEmail: text("bitbucketEmail"),
appPassword: text("appPassword"),
apiToken: text("apiToken"),
bitbucketWorkspaceName: text("bitbucketWorkspaceName"),
gitProviderId: text("gitProviderId")
.notNull()
@@ -29,7 +31,9 @@ const createSchema = createInsertSchema(bitbucket);
export const apiCreateBitbucket = createSchema.extend({
bitbucketUsername: z.string().optional(),
bitbucketEmail: z.string().email().optional(),
appPassword: z.string().optional(),
apiToken: z.string().optional(),
bitbucketWorkspaceName: z.string().optional(),
gitProviderId: z.string().optional(),
authId: z.string().min(1),
@@ -46,9 +50,19 @@ export const apiBitbucketTestConnection = createSchema
.extend({
bitbucketId: z.string().min(1),
bitbucketUsername: z.string().optional(),
bitbucketEmail: z.string().email().optional(),
workspaceName: z.string().optional(),
apiToken: z.string().optional(),
appPassword: z.string().optional(),
})
.pick({ bitbucketId: true, bitbucketUsername: true, workspaceName: true });
.pick({
bitbucketId: true,
bitbucketUsername: true,
bitbucketEmail: true,
workspaceName: true,
apiToken: true,
appPassword: true,
});
export const apiFindBitbucketBranches = z.object({
owner: z.string(),
@@ -60,6 +74,9 @@ export const apiUpdateBitbucket = createSchema.extend({
bitbucketId: z.string().min(1),
name: z.string().min(1),
bitbucketUsername: z.string().optional(),
bitbucketEmail: z.string().email().optional(),
appPassword: z.string().optional(),
apiToken: z.string().optional(),
bitbucketWorkspaceName: z.string().optional(),
organizationId: z.string().optional(),
});

View File

@@ -181,6 +181,18 @@ export const apiFindCompose = z.object({
composeId: z.string().min(1),
});
export const apiDeployCompose = z.object({
composeId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiRedeployCompose = z.object({
composeId: z.string().min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export const apiDeleteCompose = z.object({
composeId: z.string().min(1),
deleteVolumes: z.boolean(),

View File

@@ -58,4 +58,5 @@ export const apiUpdateGithub = createSchema.extend({
githubId: z.string().min(1),
name: z.string().min(1),
gitProviderId: z.string().min(1),
githubAppName: z.string().min(1),
});

View File

@@ -11,6 +11,7 @@ export const notificationType = pgEnum("notificationType", [
"discord",
"email",
"gotify",
"ntfy",
]);
export const notifications = pgTable("notification", {
@@ -44,6 +45,9 @@ export const notifications = pgTable("notification", {
gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
onDelete: "cascade",
}),
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
@@ -101,6 +105,17 @@ export const gotify = pgTable("gotify", {
decoration: boolean("decoration"),
});
export const ntfy = pgTable("ntfy", {
ntfyId: text("ntfyId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
serverUrl: text("serverUrl").notNull(),
topic: text("topic").notNull(),
accessToken: text("accessToken").notNull(),
priority: integer("priority").notNull().default(3),
});
export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, {
fields: [notifications.slackId],
@@ -122,6 +137,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.gotifyId],
references: [gotify.gotifyId],
}),
ntfy: one(ntfy, {
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
organization: one(organization, {
fields: [notifications.organizationId],
references: [organization.id],
@@ -284,6 +303,36 @@ export const apiTestGotifyConnection = apiCreateGotify
decoration: z.boolean().optional(),
});
export const apiCreateNtfy = notificationsSchema
.pick({
appBuildError: true,
databaseBackup: true,
dokployRestart: true,
name: true,
appDeploy: true,
dockerCleanup: true,
})
.extend({
serverUrl: z.string().min(1),
topic: z.string().min(1),
accessToken: z.string().min(1),
priority: z.number().min(1),
})
.required();
export const apiUpdateNtfy = apiCreateNtfy.partial().extend({
notificationId: z.string().min(1),
ntfyId: z.string().min(1),
organizationId: z.string().optional(),
});
export const apiTestNtfyConnection = apiCreateNtfy.pick({
serverUrl: true,
topic: true,
accessToken: true,
priority: true,
});
export const apiFindOneNotification = notificationsSchema
.pick({
notificationId: true,
@@ -303,7 +352,9 @@ export const apiSendTest = notificationsSchema
password: z.string(),
toAddresses: z.array(z.string()),
serverUrl: z.string(),
topic: z.string(),
appToken: z.string(),
accessToken: z.string(),
priority: z.number(),
})
.partial();

View File

@@ -322,6 +322,11 @@ export const apiUpdateWebServerMonitoring = z.object({
});
export const apiUpdateUser = createSchema.partial().extend({
email: z
.string()
.email("Please enter a valid email address")
.min(1, "Email is required")
.optional(),
password: z.string().optional(),
currentPassword: z.string().optional(),
name: z.string().optional(),

View File

@@ -92,31 +92,48 @@ export const suggestVariants = async ({
const { object } = await generateObject({
model,
output: "array",
output: "object",
schema: z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
suggestions: z.array(
z.object({
id: z.string(),
name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
}),
prompt: `
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items), the suggestion
should include id, name, shortDescription, and description. Use slug of title for id.
Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items).
Return your response as a JSON object with the following structure:
{
"suggestions": [
{
"id": "project-slug",
"name": "Project Name",
"shortDescription": "Brief one-line description",
"description": "Detailed description"
}
]
}
Important rules for the response:
1. The description field should ONLY contain a plain text description of the project, its features, and use cases
2. Do NOT include any code snippets, configuration examples, or installation instructions in the description
3. The shortDescription should be a single-line summary focusing on the main technologies
1. Use slug format for the id field (lowercase, hyphenated)
2. The description field should ONLY contain a plain text description of the project, its features, and use cases
3. Do NOT include any code snippets, configuration examples, or installation instructions in the description
4. The shortDescription should be a single-line summary focusing on the main technologies
5. All projects should be installable in docker and have docker compose support
User wants to create a new project with the following details, it should be installable in docker and can be docker compose generated for it:
User wants to create a new project with the following details:
${input}
`,
});
if (object?.length) {
if (object?.suggestions?.length) {
const result = [];
for (const suggestion of object) {
for (const suggestion of object.suggestions) {
try {
const { object: docker } = await generateObject({
model,
@@ -136,16 +153,29 @@ export const suggestVariants = async ({
serviceName: z.string(),
}),
),
configFiles: z.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
),
configFiles: z
.array(
z.object({
content: z.string(),
filePath: z.string(),
}),
)
.optional(),
}),
prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project.
Return the docker compose as a YAML string and environment variables configuration. Follow these rules:
Return your response as a JSON object with this structure:
{
"dockerCompose": "yaml string here",
"envVariables": [{"name": "VAR_NAME", "value": "example_value"}],
"domains": [{"host": "domain.com", "port": 3000, "serviceName": "service"}],
"configFiles": [{"content": "file content", "filePath": "path/to/file"}]
}
Note: configFiles is optional - only include it if configuration files are absolutely required.
Follow these rules:
Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml
@@ -198,6 +228,7 @@ export const suggestVariants = async ({
console.error("Error in docker compose generation:", error);
}
}
return result;
}

View File

@@ -68,10 +68,26 @@ export const updateBitbucket = async (
input: typeof apiUpdateBitbucket._type,
) => {
return await db.transaction(async (tx) => {
// First get the current bitbucket provider to get gitProviderId
const currentProvider = await tx.query.bitbucket.findFirst({
where: eq(bitbucket.bitbucketId, bitbucketId),
});
if (!currentProvider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Bitbucket provider not found",
});
}
const result = await tx
.update(bitbucket)
.set({
...input,
bitbucketUsername: input.bitbucketUsername,
bitbucketEmail: input.bitbucketEmail,
appPassword: input.appPassword,
apiToken: input.apiToken,
bitbucketWorkspaceName: input.bitbucketWorkspaceName,
})
.where(eq(bitbucket.bitbucketId, bitbucketId))
.returning();
@@ -83,7 +99,7 @@ export const updateBitbucket = async (
name: input.name,
organizationId: input.organizationId,
})
.where(eq(gitProvider.gitProviderId, input.gitProviderId))
.where(eq(gitProvider.gitProviderId, currentProvider.gitProviderId))
.returning();
}

View File

@@ -9,7 +9,7 @@ import {
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { dump } from "js-yaml";
import { stringify } from "yaml";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
import { execAsyncRemote } from "../utils/process/execAsync";
@@ -101,7 +101,7 @@ const createCertificateFiles = async (certificate: Certificate) => {
],
},
};
const yamlConfig = dump(traefikConfig);
const yamlConfig = stringify(traefikConfig);
const configFile = path.join(certDir, "certificate.yml");
if (certificate.serverId) {

View File

@@ -3,17 +3,20 @@ import {
type apiCreateDiscord,
type apiCreateEmail,
type apiCreateGotify,
type apiCreateNtfy,
type apiCreateSlack,
type apiCreateTelegram,
type apiUpdateDiscord,
type apiUpdateEmail,
type apiUpdateGotify,
type apiUpdateNtfy,
type apiUpdateSlack,
type apiUpdateTelegram,
discord,
email,
gotify,
notifications,
ntfy,
slack,
telegram,
} from "@dokploy/server/db/schema";
@@ -482,6 +485,96 @@ export const updateGotifyNotification = async (
});
};
export const createNtfyNotification = async (
input: typeof apiCreateNtfy._type,
organizationId: string,
) => {
await db.transaction(async (tx) => {
const newNtfy = await tx
.insert(ntfy)
.values({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
priority: input.priority,
})
.returning()
.then((value) => value[0]);
if (!newNtfy) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting ntfy",
});
}
const newDestination = await tx
.insert(notifications)
.values({
ntfyId: newNtfy.ntfyId,
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
notificationType: "ntfy",
organizationId: organizationId,
})
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error input: Inserting notification",
});
}
return newDestination;
});
};
export const updateNtfyNotification = async (
input: typeof apiUpdateNtfy._type,
) => {
await db.transaction(async (tx) => {
const newDestination = await tx
.update(notifications)
.set({
name: input.name,
appDeploy: input.appDeploy,
appBuildError: input.appBuildError,
databaseBackup: input.databaseBackup,
dokployRestart: input.dokployRestart,
dockerCleanup: input.dockerCleanup,
organizationId: input.organizationId,
})
.where(eq(notifications.notificationId, input.notificationId))
.returning()
.then((value) => value[0]);
if (!newDestination) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error Updating notification",
});
}
await tx
.update(ntfy)
.set({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
priority: input.priority,
})
.where(eq(ntfy.ntfyId, input.ntfyId));
return newDestination;
});
};
export const findNotificationById = async (notificationId: string) => {
const notification = await db.query.notifications.findFirst({
where: eq(notifications.notificationId, notificationId),
@@ -491,6 +584,7 @@ export const findNotificationById = async (notificationId: string) => {
discord: true,
email: true,
gotify: true,
ntfy: true,
},
});
if (!notification) {

View File

@@ -10,6 +10,22 @@ import { IS_CLOUD } from "../constants";
export type Registry = typeof registry.$inferSelect;
function shEscape(s: string | undefined): string {
if (!s) return "''";
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function safeDockerLoginCommand(
registry: string | undefined,
user: string | undefined,
pass: string | undefined,
) {
const escapedRegistry = shEscape(registry);
const escapedUser = shEscape(user);
const escapedPassword = shEscape(pass);
return `printf %s ${escapedPassword} | docker login ${escapedRegistry} -u ${escapedUser} --password-stdin`;
}
export const createRegistry = async (
input: typeof apiCreateRegistry._type,
organizationId: string,
@@ -37,7 +53,11 @@ export const createRegistry = async (
message: "Select a server to add the registry",
});
}
const loginCommand = `echo ${input.password} | docker login ${input.registryUrl} --username ${input.username} --password-stdin`;
const loginCommand = safeDockerLoginCommand(
input.registryUrl,
input.username,
input.password,
);
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") {
@@ -91,7 +111,11 @@ export const updateRegistry = async (
.returning()
.then((res) => res[0]);
const loginCommand = `echo ${response?.password} | docker login ${response?.registryUrl} --username ${response?.username} --password-stdin`;
const loginCommand = safeDockerLoginCommand(
response?.registryUrl,
response?.username,
response?.password,
);
if (
IS_CLOUD &&

View File

@@ -342,6 +342,8 @@ export const readPorts = async (
command = `docker service inspect ${resourceName} --format '{{json .Spec.EndpointSpec.Ports}}'`;
} else if (resourceType === "standalone") {
command = `docker container inspect ${resourceName} --format '{{json .NetworkSettings.Ports}}'`;
} else {
throw new Error("Resource type not found");
}
let result = "";
if (serverId) {
@@ -397,17 +399,20 @@ export const writeTraefikSetup = async (input: TraefikOptions) => {
"dokploy-traefik",
input.serverId,
);
if (resourceType === "service") {
await initializeTraefikService({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
});
} else {
} else if (resourceType === "standalone") {
await initializeStandaloneTraefik({
env: input.env,
additionalPorts: input.additionalPorts,
serverId: input.serverId,
});
} else {
throw new Error("Traefik resource type not found");
}
};

View File

@@ -296,6 +296,19 @@ export const findMemberById = async (
};
export const updateUser = async (userId: string, userData: Partial<User>) => {
// Validate email if it's being updated
if (userData.email !== undefined) {
if (!userData.email || userData.email.trim() === "") {
throw new Error("Email is required and cannot be empty");
}
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(userData.email)) {
throw new Error("Please enter a valid email address");
}
}
const user = await db
.update(users_temp)
.set({

View File

@@ -1,7 +1,14 @@
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import path from "node:path";
import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode";
import { dump } from "js-yaml";
import { stringify } from "yaml";
import { paths } from "../constants";
import { getRemoteDocker } from "../utils/servers/remote-docker";
import type { FileConfig } from "../utils/traefik/file-types";
@@ -87,16 +94,27 @@ export const initializeStandaloneTraefik = async ({
};
const docker = await getRemoteDocker(serverId);
try {
await docker.pull(imageName);
await new Promise((resolve) => setTimeout(resolve, 3000));
console.log("Traefik Image Pulled ✅");
} catch (error) {
console.log("Traefik Image Not Found: Pulling ", error);
}
try {
const container = docker.getContainer(containerName);
await container.remove({ force: true });
await new Promise((resolve) => setTimeout(resolve, 5000));
} catch {}
await docker.createContainer(settings);
const newContainer = docker.getContainer(containerName);
await newContainer.start();
console.log("Traefik Started ✅");
try {
await docker.createContainer(settings);
const newContainer = docker.getContainer(containerName);
await newContainer.start();
console.log("Traefik Started ✅");
} catch (error) {
console.log("Traefik Not Found: Starting ", error);
}
};
export const initializeTraefikService = async ({
@@ -223,7 +241,7 @@ export const createDefaultServerTraefikConfig = () => {
},
};
const yamlStr = dump(config);
const yamlStr = stringify(config);
mkdirSync(DYNAMIC_TRAEFIK_PATH, { recursive: true });
writeFileSync(
path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`),
@@ -297,7 +315,7 @@ export const getDefaultTraefikConfig = () => {
}),
};
const yamlStr = dump(configObject);
const yamlStr = stringify(configObject);
return yamlStr;
};
@@ -351,7 +369,7 @@ export const getDefaultServerTraefikConfig = () => {
},
};
const yamlStr = dump(configObject);
const yamlStr = stringify(configObject);
return yamlStr;
};
@@ -364,13 +382,26 @@ export const createDefaultTraefikConfig = () => {
if (existsSync(acmeJsonPath)) {
chmodSync(acmeJsonPath, "600");
}
if (existsSync(mainConfig)) {
console.log("Main config already exists");
return;
}
const yamlStr = getDefaultTraefikConfig();
// Create the traefik directory first
mkdirSync(MAIN_TRAEFIK_PATH, { recursive: true });
// Check if traefik.yml exists and handle the case where it might be a directory
if (existsSync(mainConfig)) {
const stats = statSync(mainConfig);
if (stats.isDirectory()) {
// If traefik.yml is a directory, remove it
console.log("Found traefik.yml as directory, removing it...");
rmSync(mainConfig, { recursive: true, force: true });
} else if (stats.isFile()) {
console.log("Main config already exists");
return;
}
}
const yamlStr = getDefaultTraefikConfig();
writeFileSync(mainConfig, yamlStr, "utf8");
console.log("Traefik config created successfully");
};
export const getDefaultMiddlewares = () => {
@@ -386,7 +417,7 @@ export const getDefaultMiddlewares = () => {
},
},
};
const yamlStr = dump(defaultMiddlewares);
const yamlStr = stringify(defaultMiddlewares);
return yamlStr;
};
export const createDefaultMiddlewares = () => {

View File

@@ -89,7 +89,7 @@ export const getMariadbBackupCommand = (
databaseUser: string,
databasePassword: string,
) => {
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --databases ${database} | gzip"`;
return `docker exec -i $CONTAINER_ID bash -c "set -o pipefail; mariadb-dump --user='${databaseUser}' --password='${databasePassword}' --single-transaction --quick --databases ${database} | gzip"`;
};
export const getMysqlBackupCommand = (

View File

@@ -222,8 +222,8 @@ const getImageName = (application: ApplicationNested) => {
if (registry) {
const { registryUrl, imagePrefix, username } = registry;
const registryTag = imagePrefix
? `${registryUrl}/${imagePrefix}/${imageName}`
: `${registryUrl}/${username}/${imageName}`;
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
return registryTag;
}

View File

@@ -22,8 +22,8 @@ export const uploadImage = async (
// For ghcr.io: ghcr.io/username/image:tag
// For docker.io: docker.io/username/image:tag
const registryTag = imagePrefix
? `${registryUrl}/${imagePrefix}/${imageName}`
: `${registryUrl}/${username}/${imageName}`;
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
try {
writeStream.write(

View File

@@ -1,5 +1,5 @@
import { findComposeById } from "@dokploy/server/services/compose";
import { dump } from "js-yaml";
import { stringify } from "yaml";
import { addAppNameToAllServiceNames } from "./collision/root-network";
import { generateRandomHash } from "./compose";
import { addSuffixToAllVolumes } from "./compose/volume";
@@ -59,7 +59,7 @@ export const randomizeIsolatedDeploymentComposeFile = async (
)
: composeData;
return dump(newComposeFile);
return stringify(newComposeFile);
};
export const randomizeDeployableSpecificationFile = (

View File

@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import { findComposeById } from "@dokploy/server/services/compose";
import { dump, load } from "js-yaml";
import { parse, stringify } from "yaml";
import { addSuffixToAllConfigs } from "./compose/configs";
import { addSuffixToAllNetworks } from "./compose/network";
import { addSuffixToAllSecrets } from "./compose/secrets";
@@ -18,13 +18,13 @@ export const randomizeComposeFile = async (
) => {
const compose = await findComposeById(composeId);
const composeFile = compose.composeFile;
const composeData = load(composeFile) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const randomSuffix = suffix || generateRandomHash();
const newComposeFile = addSuffixToAllProperties(composeData, randomSuffix);
return dump(newComposeFile);
return stringify(newComposeFile);
};
export const randomizeSpecificationFile = (

View File

@@ -4,7 +4,7 @@ import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { Compose } from "@dokploy/server/services/compose";
import type { Domain } from "@dokploy/server/services/domain";
import { dump, load } from "js-yaml";
import { parse, stringify } from "yaml";
import { execAsyncRemote } from "../process/execAsync";
import {
cloneRawBitbucketRepository,
@@ -92,7 +92,7 @@ export const loadDockerCompose = async (
if (existsSync(path)) {
const yamlStr = readFileSync(path, "utf8");
const parsedConfig = load(yamlStr) as ComposeSpecification;
const parsedConfig = parse(yamlStr) as ComposeSpecification;
return parsedConfig;
}
return null;
@@ -115,7 +115,7 @@ export const loadDockerComposeRemote = async (
return null;
}
if (!stdout) return null;
const parsedConfig = load(stdout) as ComposeSpecification;
const parsedConfig = parse(stdout) as ComposeSpecification;
return parsedConfig;
} catch {
return null;
@@ -141,7 +141,7 @@ export const writeDomainsToCompose = async (
const composeConverted = await addDomainToCompose(compose, domains);
const path = getComposePath(compose);
const composeString = dump(composeConverted, { lineWidth: 1000 });
const composeString = stringify(composeConverted, { lineWidth: 1000 });
try {
await writeFile(path, composeString, "utf8");
} catch (error) {
@@ -169,7 +169,7 @@ exit 1;
`;
}
if (compose.serverId) {
const composeString = dump(composeConverted, { lineWidth: 1000 });
const composeString = stringify(composeConverted, { lineWidth: 1000 });
const encodedContent = encodeBase64(composeString);
return `echo "${encodedContent}" | base64 -d > "${path}";`;
}
@@ -251,11 +251,15 @@ export const addDomainToCompose = async (
}
labels.unshift(...httpLabels);
if (!compose.isolatedDeployment) {
if (!labels.includes("traefik.docker.network=dokploy-network")) {
labels.unshift("traefik.docker.network=dokploy-network");
}
if (!labels.includes("traefik.swarm.network=dokploy-network")) {
labels.unshift("traefik.swarm.network=dokploy-network");
if (compose.composeType === "docker-compose") {
if (!labels.includes("traefik.docker.network=dokploy-network")) {
labels.unshift("traefik.docker.network=dokploy-network");
}
} else {
// Stack Case
if (!labels.includes("traefik.swarm.network=dokploy-network")) {
labels.unshift("traefik.swarm.network=dokploy-network");
}
}
}
}
@@ -283,7 +287,7 @@ export const writeComposeFile = async (
const path = getComposePath(compose);
try {
const composeFile = dump(composeSpec, {
const composeFile = stringify(composeSpec, {
lineWidth: 1000,
});
fs.writeFileSync(path, composeFile, "utf8");

View File

@@ -8,6 +8,7 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -42,11 +43,12 @@ export const sendBuildErrorNotifications = async ({
telegram: true,
slack: true,
gotify: true,
ntfy: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification;
const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) {
const template = await renderAsync(
BuildFailedEmail({
@@ -132,6 +134,20 @@ export const sendBuildErrorNotifications = async ({
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Build Failed",
"warning",
`view, Build details, ${buildLink}, clear=true;`,
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`❔Type: ${applicationType}\n` +
`🕒Date: ${date.toLocaleString()}\n` +
`Error:\n${errorMessage}`,
);
}
if (telegram) {
const inlineButton = [
[

View File

@@ -9,6 +9,7 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -43,11 +44,12 @@ export const sendBuildSuccessNotifications = async ({
telegram: true,
slack: true,
gotify: true,
ntfy: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification;
const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) {
const template = await renderAsync(
@@ -126,6 +128,19 @@ export const sendBuildSuccessNotifications = async ({
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Build Success",
"white_check_mark",
`view, Build details, ${buildLink}, clear=true;`,
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`❔Type: ${applicationType}\n` +
`🕒Date: ${date.toLocaleString()}`,
);
}
if (telegram) {
const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>

View File

@@ -8,6 +8,7 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -42,11 +43,12 @@ export const sendDatabaseBackupNotifications = async ({
telegram: true,
slack: true,
gotify: true,
ntfy: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification;
const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) {
const template = await renderAsync(
@@ -149,6 +151,21 @@ export const sendDatabaseBackupNotifications = async ({
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
`Database Backup ${type === "success" ? "Successful" : "Failed"}`,
`${type === "success" ? "white_check_mark" : "x"}`,
"",
`🛠Project: ${projectName}\n` +
`Application: ${applicationName}\n` +
`❔Type: ${databaseType}\n` +
`📂Database Name: ${databaseName}` +
`🕒Date: ${date.toLocaleString()}\n` +
`${type === "error" && errorMessage ? `❌Error:\n${errorMessage}` : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;

View File

@@ -8,6 +8,7 @@ import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -29,11 +30,12 @@ export const sendDockerCleanupNotifications = async (
telegram: true,
slack: true,
gotify: true,
ntfy: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification;
const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) {
const template = await renderAsync(
@@ -93,6 +95,16 @@ export const sendDockerCleanupNotifications = async (
);
}
if (ntfy) {
await sendNtfyNotification(
ntfy,
"Docker Cleanup",
"white_check_mark",
"",
`🕒Date: ${date.toLocaleString()}\n` + `📜Message:\n${message}`,
);
}
if (telegram) {
await sendTelegramNotification(
telegram,

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