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 { Inngest } from "inngest";
import { serve as serveInngest } from "inngest/hono"; import { serve as serveInngest } from "inngest/hono";
import { logger } from "./logger.js"; 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"; import { deploy } from "./utils.js";
const app = new Hono(); const app = new Hono();
@@ -27,6 +31,13 @@ export const deploymentFunction = inngest.createFunction(
}, },
], ],
retries: 0, 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" }, { 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) => { app.get("/health", async (c) => {
return c.json({ status: "ok" }); return c.json({ status: "ok" });
}); });

View File

@@ -3,8 +3,8 @@ import { z } from "zod";
export const deployJobSchema = z.discriminatedUnion("applicationType", [ export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({ z.object({
applicationId: z.string(), applicationId: z.string(),
titleLog: z.string(), titleLog: z.string().optional(),
descriptionLog: z.string(), descriptionLog: z.string().optional(),
server: z.boolean().optional(), server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]), type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("application"), applicationType: z.literal("application"),
@@ -12,8 +12,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
}), }),
z.object({ z.object({
composeId: z.string(), composeId: z.string(),
titleLog: z.string(), titleLog: z.string().optional(),
descriptionLog: z.string(), descriptionLog: z.string().optional(),
server: z.boolean().optional(), server: z.boolean().optional(),
type: z.enum(["deploy", "redeploy"]), type: z.enum(["deploy", "redeploy"]),
applicationType: z.literal("compose"), applicationType: z.literal("compose"),
@@ -22,8 +22,8 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
z.object({ z.object({
applicationId: z.string(), applicationId: z.string(),
previewDeploymentId: z.string(), previewDeploymentId: z.string(),
titleLog: z.string(), titleLog: z.string().optional(),
descriptionLog: z.string(), descriptionLog: z.string().optional(),
server: z.boolean().optional(), server: z.boolean().optional(),
type: z.enum(["deploy"]), type: z.enum(["deploy"]),
applicationType: z.literal("application-preview"), applicationType: z.literal("application-preview"),
@@ -32,3 +32,16 @@ export const deployJobSchema = z.discriminatedUnion("applicationType", [
]); ]);
export type DeployJob = z.infer<typeof deployJobSchema>; 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") { if (job.type === "redeploy") {
await rebuildRemoteApplication({ await rebuildRemoteApplication({
applicationId: job.applicationId, applicationId: job.applicationId,
titleLog: job.titleLog, titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} else if (job.type === "deploy") { } else if (job.type === "deploy") {
await deployRemoteApplication({ await deployRemoteApplication({
applicationId: job.applicationId, applicationId: job.applicationId,
titleLog: job.titleLog, titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} }
} }
@@ -38,14 +38,14 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "redeploy") { if (job.type === "redeploy") {
await rebuildRemoteCompose({ await rebuildRemoteCompose({
composeId: job.composeId, composeId: job.composeId,
titleLog: job.titleLog, titleLog: job.titleLog || "Rebuild deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} else if (job.type === "deploy") { } else if (job.type === "deploy") {
await deployRemoteCompose({ await deployRemoteCompose({
composeId: job.composeId, composeId: job.composeId,
titleLog: job.titleLog, titleLog: job.titleLog || "Manual deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
}); });
} }
} }
@@ -57,8 +57,8 @@ export const deploy = async (job: DeployJob) => {
if (job.type === "deploy") { if (job.type === "deploy") {
await deployRemotePreviewApplication({ await deployRemotePreviewApplication({
applicationId: job.applicationId, applicationId: job.applicationId,
titleLog: job.titleLog, titleLog: job.titleLog || "Preview Deployment",
descriptionLog: job.descriptionLog, descriptionLog: job.descriptionLog || "",
previewDeploymentId: job.previewDeploymentId, previewDeploymentId: job.previewDeploymentId,
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
import type { ComposeSpecification } from "@dokploy/server"; import type { ComposeSpecification } from "@dokploy/server";
import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server"; import { addSuffixToServiceNames, generateRandomHash } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = ` const composeFile = `
version: "3.8" 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", () => { 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(); const suffix = generateRandomHash();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,8 +4,8 @@ import {
addSuffixToVolumesRoot, addSuffixToVolumesRoot,
generateRandomHash, generateRandomHash,
} from "@dokploy/server"; } from "@dokploy/server";
import { load } from "js-yaml";
import { expect, test } from "vitest"; import { expect, test } from "vitest";
import { parse } from "yaml";
const composeFile = ` const composeFile = `
services: services:
@@ -70,7 +70,7 @@ volumes:
driver: local driver: local
`; `;
const expectedDockerCompose = load(` const expectedDockerCompose = parse(`
services: services:
mail: mail:
image: bytemark/smtp 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 // 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 // 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", () => { test("Add suffix to volumes root property", () => {
const composeData = load(composeFile) as ComposeSpecification; const composeData = parse(composeFile) as ComposeSpecification;
const suffix = generateRandomHash(); 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", () => { 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 suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -195,7 +195,7 @@ volumes:
mongo-data: mongo-data:
`; `;
const expectedDockerCompose2 = load(` const expectedDockerCompose2 = parse(`
version: '3.8' version: '3.8'
services: services:
app: app:
@@ -218,7 +218,7 @@ volumes:
`) as ComposeSpecification; `) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (2 Try)", () => { 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 suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -248,7 +248,7 @@ volumes:
mongo-data: mongo-data:
`; `;
const expectedDockerCompose3 = load(` const expectedDockerCompose3 = parse(`
version: '3.8' version: '3.8'
services: services:
app: app:
@@ -271,7 +271,7 @@ volumes:
`) as ComposeSpecification; `) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (3 Try)", () => { 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 suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -645,7 +645,7 @@ volumes:
db-config: db-config:
`; `;
const expectedDockerComposeComplex = load(` const expectedDockerComposeComplex = parse(`
version: "3.8" version: "3.8"
services: services:
studio: studio:
@@ -1012,7 +1012,7 @@ volumes:
`); `);
test("Expect to change the suffix in all the possible places (4 Try)", () => { 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 suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1065,7 +1065,7 @@ volumes:
db-data: db-data:
`; `;
const expectedDockerComposeExample1 = load(` const expectedDockerComposeExample1 = parse(`
version: "3.8" version: "3.8"
services: services:
web: web:
@@ -1111,7 +1111,7 @@ volumes:
`) as ComposeSpecification; `) as ComposeSpecification;
test("Expect to change the suffix in all the possible places (5 Try)", () => { 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 suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);
@@ -1143,7 +1143,7 @@ volumes:
backrest-cache: backrest-cache:
`; `;
const expectedDockerComposeBackrest = load(` const expectedDockerComposeBackrest = parse(`
services: services:
backrest: backrest:
image: garethgeorge/backrest:v1.7.3 image: garethgeorge/backrest:v1.7.3
@@ -1168,7 +1168,7 @@ volumes:
`) as ComposeSpecification; `) as ComposeSpecification;
test("Should handle volume paths with subdirectories correctly", () => { test("Should handle volume paths with subdirectories correctly", () => {
const composeData = load(composeFileBackrest) as ComposeSpecification; const composeData = parse(composeFileBackrest) as ComposeSpecification;
const suffix = "testhash"; const suffix = "testhash";
const updatedComposeData = addSuffixToAllVolumes(composeData, suffix); const updatedComposeData = addSuffixToAllVolumes(composeData, suffix);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; 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 { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip"; import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action"; import { DialogAction } from "@/components/shared/dialog-action";
import { StatusTooltip } from "@/components/shared/status-tooltip"; 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 } = const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation(); api.rollback.rollback.useMutation();
const { mutateAsync: killProcess, isLoading: isKillingProcess } = const { mutateAsync: killProcess, isLoading: isKillingProcess } =
api.deployment.killProcess.useMutation(); 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(""); 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(() => { useEffect(() => {
setUrl(document.location.origin); setUrl(document.location.origin);
}, []); }, []);
@@ -94,6 +131,54 @@ export const ShowDeployments = ({
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <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 && ( {refreshToken && (
<div className="flex flex-col gap-2 text-sm"> <div className="flex flex-col gap-2 text-sm">
<span> <span>

View File

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

View File

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

View File

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

View File

@@ -104,9 +104,9 @@ export const ShowExternalPostgresCredentials = ({ postgresId }: Props) => {
<CardHeader> <CardHeader>
<CardTitle className="text-xl">External Credentials</CardTitle> <CardTitle className="text-xl">External Credentials</CardTitle>
<CardDescription> <CardDescription>
In order to make the database reachable trought internet is In order to make the database reachable through the internet, you
required to set a port, make sure the port is not used by another must set a port and ensure that the port is not being used by
application or database another application or database
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex w-full flex-col gap-4"> <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 { data: servers } = api.server.withSSHKey.useQuery();
const hasServers = servers && servers.length > 0; 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 } = const { mutateAsync, isLoading, error, isError } =
api.application.create.useMutation(); api.application.create.useMutation();
@@ -94,8 +98,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
name: data.name, name: data.name,
appName: data.appName, appName: data.appName,
description: data.description, description: data.description,
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId, environmentId,
serverId: data.serverId,
}) })
.then(async () => { .then(async () => {
toast.success("Service Created"); toast.success("Service Created");
@@ -157,7 +161,7 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{hasServers && ( {shouldShowServerDropdown && (
<FormField <FormField
control={form.control} control={form.control}
name="serverId" name="serverId"
@@ -186,13 +190,27 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <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) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -206,7 +224,9 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</span> </span>
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -82,6 +82,10 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const { data: environment } = api.environment.one.useQuery({ environmentId }); const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0; 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>({ const form = useForm<AddCompose>({
defaultValues: { defaultValues: {
@@ -104,7 +108,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
environmentId, environmentId,
composeType: data.composeType, composeType: data.composeType,
appName: data.appName, appName: data.appName,
serverId: data.serverId, serverId: data.serverId === "dokploy" ? undefined : data.serverId,
}) })
.then(async () => { .then(async () => {
toast.success("Compose Created"); toast.success("Compose Created");
@@ -169,7 +173,7 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
)} )}
/> />
</div> </div>
{hasServers && ( {shouldShowServerDropdown && (
<FormField <FormField
control={form.control} control={form.control}
name="serverId" name="serverId"
@@ -198,13 +202,27 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <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) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -218,7 +236,9 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
</span> </span>
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@@ -179,6 +179,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils(); const utils = api.useUtils();
const [visible, setVisible] = useState(false); const [visible, setVisible] = useState(false);
const slug = slugify(projectName); const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const postgresMutation = api.postgres.create.useMutation(); const postgresMutation = api.postgres.create.useMutation();
const mongoMutation = api.mongo.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 { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0; 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>({ const form = useForm<AddDatabase>({
defaultValues: { defaultValues: {
@@ -223,9 +228,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
name: data.name, name: data.name,
appName: data.appName, appName: data.appName,
dockerImage: defaultDockerImage, dockerImage: defaultDockerImage,
projectId: environment?.projectId || "", serverId: data.serverId === "dokploy" ? undefined : data.serverId,
environmentId, environmentId,
serverId: data.serverId,
description: data.description, description: data.description,
}; };
@@ -237,7 +241,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
}); });
} else if (data.type === "mongo") { } else if (data.type === "mongo") {
promise = mongoMutation.mutateAsync({ promise = mongoMutation.mutateAsync({
@@ -245,14 +249,14 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databasePassword: data.databasePassword, databasePassword: data.databasePassword,
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
replicaSets: data.replicaSets, replicaSets: data.replicaSets,
}); });
} else if (data.type === "redis") { } else if (data.type === "redis") {
promise = redisMutation.mutateAsync({ promise = redisMutation.mutateAsync({
...commonParams, ...commonParams,
databasePassword: data.databasePassword, databasePassword: data.databasePassword,
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
}); });
} else if (data.type === "mariadb") { } else if (data.type === "mariadb") {
promise = mariadbMutation.mutateAsync({ promise = mariadbMutation.mutateAsync({
@@ -262,7 +266,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseName: data.databaseName || "mariadb", databaseName: data.databaseName || "mariadb",
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId, serverId: data.serverId === "dokploy" ? null : data.serverId,
}); });
} else if (data.type === "mysql") { } else if (data.type === "mysql") {
promise = mysqlMutation.mutateAsync({ promise = mysqlMutation.mutateAsync({
@@ -271,8 +275,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
databaseName: data.databaseName || "mysql", databaseName: data.databaseName || "mysql",
databaseUser: databaseUser:
data.databaseUser || databasesUserDefaultPlaceholder[data.type], data.databaseUser || databasesUserDefaultPlaceholder[data.type],
serverId: data.serverId === "dokploy" ? null : data.serverId,
databaseRootPassword: data.databaseRootPassword || "", databaseRootPassword: data.databaseRootPassword || "",
serverId: data.serverId,
}); });
} }
@@ -403,7 +407,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</FormItem> </FormItem>
)} )}
/> />
{hasServers && ( {shouldShowServerDropdown && (
<FormField <FormField
control={form.control} control={form.control}
name="serverId" name="serverId"
@@ -412,13 +416,29 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<FormLabel>Select a Server</FormLabel> <FormLabel>Select a Server</FormLabel>
<Select <Select
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value || ""} defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <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) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -428,7 +448,7 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</SelectItem> </SelectItem>
))} ))}
<SelectLabel> <SelectLabel>
Servers ({servers?.length}) Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel> </SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>

View File

@@ -141,6 +141,10 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
}) || []; }) || [];
const hasServers = servers && servers.length > 0; 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 ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -167,7 +171,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
<Input <Input
placeholder="Search Template" placeholder="Search Template"
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
className="w-full sm:w-[200px]" className="w-full"
value={query} value={query}
/> />
<Input <Input
@@ -244,7 +248,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onClick={() => onClick={() =>
setViewMode(viewMode === "detailed" ? "icon" : "detailed") setViewMode(viewMode === "detailed" ? "icon" : "detailed")
} }
className="h-9 w-9" className="h-9 w-9 flex-shrink-0"
> >
{viewMode === "detailed" ? ( {viewMode === "detailed" ? (
<LayoutGrid className="size-4" /> <LayoutGrid className="size-4" />
@@ -430,7 +434,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
project. project.
</AlertDialogDescription> </AlertDialogDescription>
{hasServers && ( {shouldShowServerDropdown && (
<div> <div>
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<Tooltip> <Tooltip>
@@ -459,12 +463,29 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
onValueChange={(e) => { onValueChange={(e) => {
setServerId(e); setServerId(e);
}} }}
defaultValue={
!isCloud ? "dokploy" : undefined
}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select a Server" /> <SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <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) => ( {servers?.map((server) => (
<SelectItem <SelectItem
key={server.serverId} key={server.serverId}
@@ -479,7 +500,8 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</SelectItem> </SelectItem>
))} ))}
<SelectLabel> <SelectLabel>
Servers ({servers?.length}) Servers (
{servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel> </SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
@@ -493,8 +515,11 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
disabled={isLoading} disabled={isLoading}
onClick={async () => { onClick={async () => {
const promise = mutateAsync({ const promise = mutateAsync({
serverId:
serverId === "dokploy"
? undefined
: serverId,
environmentId, environmentId,
serverId: serverId || undefined,
id: template.id, id: template.id,
baseUrl: customBaseUrl, baseUrl: customBaseUrl,
}); });

View File

@@ -25,7 +25,12 @@ const examples = [
export const StepOne = ({ setTemplateInfo, templateInfo }: any) => { export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
// Get servers from the API // Get servers from the API
const { data: servers } = api.server.withSSHKey.useQuery(); const { data: servers } = api.server.withSSHKey.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const hasServers = servers && servers.length > 0; 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) => { const handleExampleClick = (example: string) => {
setTemplateInfo({ ...templateInfo, userInput: example }); setTemplateInfo({ ...templateInfo, userInput: example });
@@ -48,34 +53,58 @@ export const StepOne = ({ setTemplateInfo, templateInfo }: any) => {
/> />
</div> </div>
{hasServers && ( {shouldShowServerDropdown && (
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="server-deploy"> <Label htmlFor="server-deploy">
Select the server where you want to deploy (optional) Select the server where you want to deploy (optional)
</Label> </Label>
<Select <Select
value={templateInfo.server?.serverId} value={
templateInfo.server?.serverId ||
(!isCloud ? "dokploy" : undefined)
}
onValueChange={(value) => { onValueChange={(value) => {
const server = servers?.find((s) => s.serverId === value); if (value === "dokploy") {
if (server) {
setTemplateInfo({ setTemplateInfo({
...templateInfo, ...templateInfo,
server: server, server: undefined,
}); });
} else {
const server = servers?.find((s) => s.serverId === value);
if (server) {
setTemplateInfo({
...templateInfo,
server: server,
});
}
} }
}} }}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Select a server" /> <SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectGroup> <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) => ( {servers?.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}> <SelectItem key={server.serverId} value={server.serverId}>
{server.name} {server.name}
</SelectItem> </SelectItem>
))} ))}
<SelectLabel>Servers ({servers?.length})</SelectLabel> <SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup> </SelectGroup>
</SelectContent> </SelectContent>
</Select> </Select>

View File

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

View File

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

View File

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

View File

@@ -80,6 +80,29 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({ api.project.duplicate.useMutation({
onSuccess: async (newProject) => { onSuccess: async (newProject) => {
await utils.project.all.invalidate(); 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( toast.success(
duplicateType === "new-project" duplicateType === "new-project"
? "Project duplicated successfully" ? "Project duplicated successfully"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -27,18 +26,12 @@ import {
} from "@/components/ui/form"; } from "@/components/ui/form";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const Schema = z.object({ const Schema = z.object({
name: z.string().min(1, { name: z.string().min(1, { message: "Name is required" }),
message: "Name is required", username: z.string().min(1, { message: "Username is required" }),
}), email: z.string().email().optional(),
username: z.string().min(1, { apiToken: z.string().min(1, { message: "API Token is required" }),
message: "Username is required",
}),
password: z.string().min(1, {
message: "App Password is required",
}),
workspaceName: z.string().optional(), workspaceName: z.string().optional(),
}); });
@@ -47,14 +40,12 @@ type Schema = z.infer<typeof Schema>;
export const AddBitbucketProvider = () => { export const AddBitbucketProvider = () => {
const utils = api.useUtils(); const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const _url = useUrl();
const { mutateAsync, error, isError } = api.bitbucket.create.useMutation(); const { mutateAsync, error, isError } = api.bitbucket.create.useMutation();
const { data: auth } = api.user.get.useQuery(); const { data: auth } = api.user.get.useQuery();
const _router = useRouter();
const form = useForm<Schema>({ const form = useForm<Schema>({
defaultValues: { defaultValues: {
username: "", username: "",
password: "", apiToken: "",
workspaceName: "", workspaceName: "",
}, },
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
@@ -63,7 +54,8 @@ export const AddBitbucketProvider = () => {
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
username: "", username: "",
password: "", email: "",
apiToken: "",
workspaceName: "", workspaceName: "",
}); });
}, [form, isOpen]); }, [form, isOpen]);
@@ -71,10 +63,11 @@ export const AddBitbucketProvider = () => {
const onSubmit = async (data: Schema) => { const onSubmit = async (data: Schema) => {
await mutateAsync({ await mutateAsync({
bitbucketUsername: data.username, bitbucketUsername: data.username,
appPassword: data.password, apiToken: data.apiToken,
bitbucketWorkspaceName: data.workspaceName || "", bitbucketWorkspaceName: data.workspaceName || "",
authId: auth?.id || "", authId: auth?.id || "",
name: data.name || "", name: data.name || "",
bitbucketEmail: data.email || "",
}) })
.then(async () => { .then(async () => {
await utils.gitProvider.getAll.invalidate(); await utils.gitProvider.getAll.invalidate();
@@ -113,37 +106,46 @@ export const AddBitbucketProvider = () => {
> >
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col gap-4"> <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"> <p className="text-muted-foreground text-sm">
To integrate your Bitbucket account, you need to create a new Select the following scopes:
App Password in your Bitbucket settings. Follow these steps:
</p> </p>
<ol className="list-decimal list-inside text-sm text-muted-foreground">
<li className="flex flex-row gap-2 items-center"> <ul className="list-disc list-inside ml-4 text-sm text-muted-foreground">
Create new App Password{" "} <li>read:repository:bitbucket</li>
<Link <li>read:pullrequest:bitbucket</li>
href="https://bitbucket.org/account/settings/app-passwords/new" <li>read:webhook:bitbucket</li>
target="_blank" <li>read:workspace:bitbucket</li>
> <li>write:webhook:bitbucket</li>
<ExternalLink className="w-fit text-primary size-4" /> </ul>
</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>
<FormField <FormField
control={form.control} control={form.control}
name="name" name="name"
@@ -152,7 +154,7 @@ export const AddBitbucketProvider = () => {
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Random Name eg(my-personal-account)" placeholder="Your Bitbucket Provider, eg: my-personal-account"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -179,14 +181,27 @@ export const AddBitbucketProvider = () => {
<FormField <FormField
control={form.control} control={form.control}
name="password" name="email"
render={({ field }) => ( render={({ field }) => (
<FormItem> <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> <FormControl>
<Input <Input
type="password" placeholder="Paste your Bitbucket API token"
placeholder="ATBBPDYUC94nR96Nj7Cqpp4pfwKk03573DD2"
{...field} {...field}
/> />
</FormControl> </FormControl>
@@ -200,7 +215,7 @@ export const AddBitbucketProvider = () => {
name="workspaceName" name="workspaceName"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Workspace Name (Optional)</FormLabel> <FormLabel>Workspace Name (optional)</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="For organization accounts" placeholder="For organization accounts"

View File

@@ -33,7 +33,10 @@ const Schema = z.object({
username: z.string().min(1, { username: z.string().min(1, {
message: "Username is required", message: "Username is required",
}), }),
email: z.string().email().optional(),
workspaceName: z.string().optional(), workspaceName: z.string().optional(),
apiToken: z.string().optional(),
appPassword: z.string().optional(),
}); });
type Schema = z.infer<typeof Schema>; type Schema = z.infer<typeof Schema>;
@@ -60,19 +63,28 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
const form = useForm<Schema>({ const form = useForm<Schema>({
defaultValues: { defaultValues: {
username: "", username: "",
email: "",
workspaceName: "", workspaceName: "",
apiToken: "",
appPassword: "",
}, },
resolver: zodResolver(Schema), resolver: zodResolver(Schema),
}); });
const username = form.watch("username"); const username = form.watch("username");
const email = form.watch("email");
const workspaceName = form.watch("workspaceName"); const workspaceName = form.watch("workspaceName");
const apiToken = form.watch("apiToken");
const appPassword = form.watch("appPassword");
useEffect(() => { useEffect(() => {
form.reset({ form.reset({
username: bitbucket?.bitbucketUsername || "", username: bitbucket?.bitbucketUsername || "",
email: bitbucket?.bitbucketEmail || "",
workspaceName: bitbucket?.bitbucketWorkspaceName || "", workspaceName: bitbucket?.bitbucketWorkspaceName || "",
name: bitbucket?.gitProvider.name || "", name: bitbucket?.gitProvider.name || "",
apiToken: bitbucket?.apiToken || "",
appPassword: bitbucket?.appPassword || "",
}); });
}, [form, isOpen, bitbucket]); }, [form, isOpen, bitbucket]);
@@ -81,8 +93,11 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
bitbucketId, bitbucketId,
gitProviderId: bitbucket?.gitProviderId || "", gitProviderId: bitbucket?.gitProviderId || "",
bitbucketUsername: data.username, bitbucketUsername: data.username,
bitbucketEmail: data.email || "",
bitbucketWorkspaceName: data.workspaceName || "", bitbucketWorkspaceName: data.workspaceName || "",
name: data.name || "", name: data.name || "",
apiToken: data.apiToken || "",
appPassword: data.appPassword || "",
}) })
.then(async () => { .then(async () => {
await utils.gitProvider.getAll.invalidate(); await utils.gitProvider.getAll.invalidate();
@@ -121,6 +136,12 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
> >
<CardContent className="p-0"> <CardContent className="p-0">
<div className="flex flex-col gap-4"> <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 <FormField
control={form.control} control={form.control}
name="name" 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 <FormField
control={form.control} control={form.control}
name="workspaceName" 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"> <div className="flex w-full justify-between gap-4 mt-4">
<Button <Button
type="button" type="button"
@@ -180,7 +262,10 @@ export const EditBitbucketProvider = ({ bitbucketId }: Props) => {
await testConnection({ await testConnection({
bitbucketId, bitbucketId,
bitbucketUsername: username, bitbucketUsername: username,
bitbucketEmail: email,
workspaceName: workspaceName, workspaceName: workspaceName,
apiToken: apiToken,
appPassword: appPassword,
}) })
.then(async (message) => { .then(async (message) => {
toast.info(`Message: ${message}`); toast.info(`Message: ${message}`);

View File

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

View File

@@ -157,7 +157,13 @@ export const ShowGitProviders = () => {
</div> </div>
</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 && ( {!haveGithubRequirements && isGithub && (
<div className="flex flex-row gap-1 items-center"> <div className="flex flex-row gap-1 items-center">
<Badge <Badge

View File

@@ -101,6 +101,15 @@ export const notificationSchema = z.discriminatedUnion("type", [
decoration: z.boolean().default(true), decoration: z.boolean().default(true),
}) })
.merge(notificationBaseSchema), .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 = { export const notificationsMap = {
@@ -124,6 +133,10 @@ export const notificationsMap = {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />, icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "Gotify", label: "Gotify",
}, },
ntfy: {
icon: <MessageCircleMore size={29} className="text-muted-foreground" />,
label: "ntfy",
},
}; };
export type NotificationSchema = z.infer<typeof notificationSchema>; export type NotificationSchema = z.infer<typeof notificationSchema>;
@@ -155,6 +168,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
api.notification.testEmailConnection.useMutation(); api.notification.testEmailConnection.useMutation();
const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } = const { mutateAsync: testGotifyConnection, isLoading: isLoadingGotify } =
api.notification.testGotifyConnection.useMutation(); api.notification.testGotifyConnection.useMutation();
const { mutateAsync: testNtfyConnection, isLoading: isLoadingNtfy } =
api.notification.testNtfyConnection.useMutation();
const slackMutation = notificationId const slackMutation = notificationId
? api.notification.updateSlack.useMutation() ? api.notification.updateSlack.useMutation()
: api.notification.createSlack.useMutation(); : api.notification.createSlack.useMutation();
@@ -170,6 +185,9 @@ export const HandleNotifications = ({ notificationId }: Props) => {
const gotifyMutation = notificationId const gotifyMutation = notificationId
? api.notification.updateGotify.useMutation() ? api.notification.updateGotify.useMutation()
: api.notification.createGotify.useMutation(); : api.notification.createGotify.useMutation();
const ntfyMutation = notificationId
? api.notification.updateNtfy.useMutation()
: api.notification.createNtfy.useMutation();
const form = useForm<NotificationSchema>({ const form = useForm<NotificationSchema>({
defaultValues: { defaultValues: {
@@ -266,6 +284,20 @@ export const HandleNotifications = ({ notificationId }: Props) => {
name: notification.name, name: notification.name,
dockerCleanup: notification.dockerCleanup, 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 { } else {
form.reset(); form.reset();
@@ -278,6 +310,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
discord: discordMutation, discord: discordMutation,
email: emailMutation, email: emailMutation,
gotify: gotifyMutation, gotify: gotifyMutation,
ntfy: ntfyMutation,
}; };
const onSubmit = async (data: NotificationSchema) => { const onSubmit = async (data: NotificationSchema) => {
@@ -366,6 +399,21 @@ export const HandleNotifications = ({ notificationId }: Props) => {
notificationId: notificationId || "", notificationId: notificationId || "",
gotifyId: notification?.gotifyId || "", 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) { 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> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -1024,7 +1149,8 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingTelegram || isLoadingTelegram ||
isLoadingDiscord || isLoadingDiscord ||
isLoadingEmail || isLoadingEmail ||
isLoadingGotify isLoadingGotify ||
isLoadingNtfy
} }
variant="secondary" variant="secondary"
onClick={async () => { onClick={async () => {
@@ -1061,6 +1187,13 @@ export const HandleNotifications = ({ notificationId }: Props) => {
priority: form.getValues("priority"), priority: form.getValues("priority"),
decoration: form.getValues("decoration"), 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"); toast.success("Connection Success");
} catch { } catch {

View File

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

View File

@@ -33,7 +33,10 @@ import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa"; import { Enable2FA } from "./enable-2fa";
const profileSchema = z.object({ 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(), password: z.string().nullable(),
currentPassword: z.string().nullable(), currentPassword: z.string().nullable(),
image: z.string().optional(), image: z.string().optional(),

View File

@@ -97,11 +97,7 @@ export const ShowTraefikActions = ({ serverId }: Props) => {
); );
refetchDashboard(); refetchDashboard();
}) })
.catch(() => { .catch(() => {});
toast.error(
`${haveTraefikDashboardPortEnabled ? "Disabled" : "Enabled"} Dashboard`,
);
});
}} }}
className="w-full cursor-pointer space-x-3" 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", "prevId": "b5e88b4f-1396-4456-b338-65c93e2194fa",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -1249,12 +1249,6 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"stopGracePeriodSwarm": {
"name": "stopGracePeriodSwarm",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"replicas": { "replicas": {
"name": "replicas", "name": "replicas",
"type": "integer", "type": "integer",
@@ -4222,6 +4216,12 @@
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": false
}, },
"ntfyId": {
"name": "ntfyId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"organizationId": { "organizationId": {
"name": "organizationId", "name": "organizationId",
"type": "text", "type": "text",
@@ -4296,6 +4296,19 @@
"onDelete": "cascade", "onDelete": "cascade",
"onUpdate": "no action" "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": { "notification_organizationId_organization_id_fk": {
"name": "notification_organizationId_organization_id_fk", "name": "notification_organizationId_organization_id_fk",
"tableFrom": "notification", "tableFrom": "notification",
@@ -4316,6 +4329,50 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "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": { "public.slack": {
"name": "slack", "name": "slack",
"schema": "", "schema": "",
@@ -6407,7 +6464,8 @@
"telegram", "telegram",
"discord", "discord",
"email", "email",
"gotify" "gotify",
"ntfy"
] ]
}, },
"public.protocolType": { "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, "idx": 110,
"version": "7", "version": "7",
"when": 1757088700712, "when": 1757189541734,
"tag": "0110_vengeful_maddog", "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 "breakpoints": true
} }
] ]

View File

@@ -1,6 +1,6 @@
{ {
"name": "dokploy", "name": "dokploy",
"version": "v0.25.0", "version": "v0.25.3",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
@@ -112,7 +112,7 @@
"i18next": "^23.16.8", "i18next": "^23.16.8",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"js-yaml": "4.1.0", "yaml": "2.8.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"micromatch": "4.0.8", "micromatch": "4.0.8",
@@ -160,7 +160,6 @@
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/bcrypt": "5.0.2", "@types/bcrypt": "5.0.2",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/js-yaml": "4.0.9",
"@types/lodash": "4.17.4", "@types/lodash": "4.17.4",
"@types/micromatch": "4.0.9", "@types/micromatch": "4.0.9",
"@types/node": "^18.19.104", "@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 { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next"; import type { NextApiRequest, NextApiResponse } from "next";
import { db } from "@/server/db"; import { db } from "@/server/db";
@@ -146,10 +151,10 @@ export default async function handler(
const commitedPaths = await extractCommitedPaths( const commitedPaths = await extractCommitedPaths(
req.body, req.body,
application.bitbucketOwner, application.bitbucket,
application.bitbucket?.appPassword || "",
application.bitbucketRepository || "", application.bitbucketRepository || "",
); );
const shouldDeployPaths = shouldDeploy( const shouldDeployPaths = shouldDeploy(
application.watchPaths, application.watchPaths,
commitedPaths, commitedPaths,
@@ -354,9 +359,8 @@ export const getProviderByHeader = (headers: any) => {
export const extractCommitedPaths = async ( export const extractCommitedPaths = async (
body: any, body: any,
bitbucketUsername: string | null, bitbucket: Bitbucket | null,
bitbucketAppPassword: string | null, repository: string,
repository: string | null,
) => { ) => {
const changes = body.push?.changes || []; const changes = body.push?.changes || [];
@@ -365,18 +369,16 @@ export const extractCommitedPaths = async (
.filter(Boolean); .filter(Boolean);
const commitedPaths: string[] = []; const commitedPaths: string[] = [];
for (const commit of commitHashes) { 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 { try {
const response = await fetch(url, { const response = await fetch(url, {
headers: { headers: getBitbucketHeaders(bitbucket!),
Authorization: `Basic ${Buffer.from(`${bitbucketUsername}:${bitbucketAppPassword}`).toString("base64")}`,
},
}); });
const data = await response.json(); const data = await response.json();
for (const value of data.values) { for (const value of data.values) {
commitedPaths.push(value.new?.path); if (value?.new?.path) commitedPaths.push(value.new.path);
} }
} catch (error) { } catch (error) {
console.error( console.error(

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ import {
unzipDrop, unzipDrop,
updateApplication, updateApplication,
updateApplicationStatus, updateApplicationStatus,
updateDeploymentStatus,
writeConfig, writeConfig,
writeConfigRemote, writeConfigRemote,
// uploadFileSchema // uploadFileSchema
@@ -40,8 +41,10 @@ import {
import { db } from "@/server/db"; import { db } from "@/server/db";
import { import {
apiCreateApplication, apiCreateApplication,
apiDeployApplication,
apiFindMonitoringStats, apiFindMonitoringStats,
apiFindOneApplication, apiFindOneApplication,
apiRedeployApplication,
apiReloadApplication, apiReloadApplication,
apiSaveBitbucketProvider, apiSaveBitbucketProvider,
apiSaveBuildType, apiSaveBuildType,
@@ -56,7 +59,7 @@ import {
} from "@/server/db/schema"; } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByApplication, myQueue } from "@/server/queues/queueSetup"; 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"; import { uploadFileSchema } from "@/utils/schema";
export const applicationRouter = createTRPCRouter({ export const applicationRouter = createTRPCRouter({
@@ -306,7 +309,7 @@ export const applicationRouter = createTRPCRouter({
}), }),
redeploy: protectedProcedure redeploy: protectedProcedure
.input(apiFindOneApplication) .input(apiRedeployApplication)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
if ( if (
@@ -320,8 +323,8 @@ export const applicationRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
applicationId: input.applicationId, applicationId: input.applicationId,
titleLog: "Rebuild deployment", titleLog: input.title || "Rebuild deployment",
descriptionLog: "", descriptionLog: input.description || "",
type: "redeploy", type: "redeploy",
applicationType: "application", applicationType: "application",
server: !!application.serverId, server: !!application.serverId,
@@ -670,7 +673,7 @@ export const applicationRouter = createTRPCRouter({
return true; return true;
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiFindOneApplication) .input(apiDeployApplication)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const application = await findApplicationById(input.applicationId); const application = await findApplicationById(input.applicationId);
if ( if (
@@ -684,8 +687,8 @@ export const applicationRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
applicationId: input.applicationId, applicationId: input.applicationId,
titleLog: "Manual deployment", titleLog: input.title || "Manual deployment",
descriptionLog: "", descriptionLog: input.description || "",
type: "deploy", type: "deploy",
applicationType: "application", applicationType: "application",
server: !!application.serverId, server: !!application.serverId,
@@ -894,4 +897,55 @@ export const applicationRouter = createTRPCRouter({
return updatedApplication; 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"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { z } from "zod"; import { z } from "zod";
import { getPublicIpWithFallback } from "@/server/wss/terminal"; import { getLocalServerIp } from "@/server/wss/terminal";
import { createTRPCRouter, protectedProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure } from "../trpc";
export const clusterRouter = createTRPCRouter({ export const clusterRouter = createTRPCRouter({
getNodes: protectedProcedure getNodes: protectedProcedure
@@ -61,7 +61,7 @@ export const clusterRouter = createTRPCRouter({
const result = await docker.swarmInspect(); const result = await docker.swarmInspect();
const docker_version = await docker.version(); const docker_version = await docker.version();
let ip = await getPublicIpWithFallback(); let ip = await getLocalServerIp();
if (input.serverId) { if (input.serverId) {
const server = await findServerById(input.serverId); const server = await findServerById(input.serverId);
ip = server?.ipAddress; ip = server?.ipAddress;
@@ -85,7 +85,7 @@ export const clusterRouter = createTRPCRouter({
const result = await docker.swarmInspect(); const result = await docker.swarmInspect();
const docker_version = await docker.version(); const docker_version = await docker.version();
let ip = await getPublicIpWithFallback(); let ip = await getLocalServerIp();
if (input.serverId) { if (input.serverId) {
const server = await findServerById(input.serverId); const server = await findServerById(input.serverId);
ip = server?.ipAddress; ip = server?.ipAddress;

View File

@@ -29,6 +29,7 @@ import {
startCompose, startCompose,
stopCompose, stopCompose,
updateCompose, updateCompose,
updateDeploymentStatus,
} from "@dokploy/server"; } from "@dokploy/server";
import { import {
type CompleteTemplate, type CompleteTemplate,
@@ -38,25 +39,27 @@ import {
import { processTemplate } from "@dokploy/server/templates/processors"; import { processTemplate } from "@dokploy/server/templates/processors";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { dump } from "js-yaml";
import _ from "lodash"; import _ from "lodash";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { parse } from "toml"; import { parse } from "toml";
import { stringify } from "yaml";
import { z } from "zod"; import { z } from "zod";
import { slugify } from "@/lib/slug"; import { slugify } from "@/lib/slug";
import { db } from "@/server/db"; import { db } from "@/server/db";
import { import {
apiCreateCompose, apiCreateCompose,
apiDeleteCompose, apiDeleteCompose,
apiDeployCompose,
apiFetchServices, apiFetchServices,
apiFindCompose, apiFindCompose,
apiRandomizeCompose, apiRandomizeCompose,
apiRedeployCompose,
apiUpdateCompose, apiUpdateCompose,
compose as composeTable, compose as composeTable,
} from "@/server/db/schema"; } from "@/server/db/schema";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup"; 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 { generatePassword } from "@/templates/utils";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
@@ -361,13 +364,13 @@ export const composeRouter = createTRPCRouter({
} }
const domains = await findDomainsByComposeId(input.composeId); const domains = await findDomainsByComposeId(input.composeId);
const composeFile = await addDomainToCompose(compose, domains); const composeFile = await addDomainToCompose(compose, domains);
return dump(composeFile, { return stringify(composeFile, {
lineWidth: 1000, lineWidth: 1000,
}); });
}), }),
deploy: protectedProcedure deploy: protectedProcedure
.input(apiFindCompose) .input(apiDeployCompose)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId); const compose = await findComposeById(input.composeId);
@@ -382,10 +385,10 @@ export const composeRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
composeId: input.composeId, composeId: input.composeId,
titleLog: "Manual deployment", titleLog: input.title || "Manual deployment",
type: "deploy", type: "deploy",
applicationType: "compose", applicationType: "compose",
descriptionLog: "", descriptionLog: input.description || "",
server: !!compose.serverId, server: !!compose.serverId,
}; };
@@ -404,7 +407,7 @@ export const composeRouter = createTRPCRouter({
); );
}), }),
redeploy: protectedProcedure redeploy: protectedProcedure
.input(apiFindCompose) .input(apiRedeployCompose)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
const compose = await findComposeById(input.composeId); const compose = await findComposeById(input.composeId);
if ( if (
@@ -418,10 +421,10 @@ export const composeRouter = createTRPCRouter({
} }
const jobData: DeploymentJob = { const jobData: DeploymentJob = {
composeId: input.composeId, composeId: input.composeId,
titleLog: "Rebuild deployment", titleLog: input.title || "Rebuild deployment",
type: "redeploy", type: "redeploy",
applicationType: "compose", applicationType: "compose",
descriptionLog: "", descriptionLog: input.description || "",
server: !!compose.serverId, server: !!compose.serverId,
}; };
if (IS_CLOUD && 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, getGithubBranches,
getGithubRepositories, getGithubRepositories,
haveGithubRequirements, haveGithubRequirements,
updateGithub,
updateGitProvider, updateGitProvider,
} from "@dokploy/server"; } from "@dokploy/server";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
@@ -134,5 +135,9 @@ export const githubRouter = createTRPCRouter({
name: input.name, name: input.name,
organizationId: ctx.session.activeOrganizationId, organizationId: ctx.session.activeOrganizationId,
}); });
await updateGithub(input.githubId, {
...input,
});
}), }),
}); });

View File

@@ -2,6 +2,7 @@ import {
createDiscordNotification, createDiscordNotification,
createEmailNotification, createEmailNotification,
createGotifyNotification, createGotifyNotification,
createNtfyNotification,
createSlackNotification, createSlackNotification,
createTelegramNotification, createTelegramNotification,
findNotificationById, findNotificationById,
@@ -10,12 +11,14 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendServerThresholdNotifications, sendServerThresholdNotifications,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
updateDiscordNotification, updateDiscordNotification,
updateEmailNotification, updateEmailNotification,
updateGotifyNotification, updateGotifyNotification,
updateNtfyNotification,
updateSlackNotification, updateSlackNotification,
updateTelegramNotification, updateTelegramNotification,
} from "@dokploy/server"; } from "@dokploy/server";
@@ -33,17 +36,20 @@ import {
apiCreateDiscord, apiCreateDiscord,
apiCreateEmail, apiCreateEmail,
apiCreateGotify, apiCreateGotify,
apiCreateNtfy,
apiCreateSlack, apiCreateSlack,
apiCreateTelegram, apiCreateTelegram,
apiFindOneNotification, apiFindOneNotification,
apiTestDiscordConnection, apiTestDiscordConnection,
apiTestEmailConnection, apiTestEmailConnection,
apiTestGotifyConnection, apiTestGotifyConnection,
apiTestNtfyConnection,
apiTestSlackConnection, apiTestSlackConnection,
apiTestTelegramConnection, apiTestTelegramConnection,
apiUpdateDiscord, apiUpdateDiscord,
apiUpdateEmail, apiUpdateEmail,
apiUpdateGotify, apiUpdateGotify,
apiUpdateNtfy,
apiUpdateSlack, apiUpdateSlack,
apiUpdateTelegram, apiUpdateTelegram,
notifications, notifications,
@@ -321,6 +327,7 @@ export const notificationRouter = createTRPCRouter({
discord: true, discord: true,
email: true, email: true,
gotify: true, gotify: true,
ntfy: true,
}, },
orderBy: desc(notifications.createdAt), orderBy: desc(notifications.createdAt),
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), 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 }) => { getEmailProviders: adminProcedure.query(async ({ ctx }) => {
return await db.query.notifications.findMany({ return await db.query.notifications.findMany({
where: eq(notifications.organizationId, ctx.session.activeOrganizationId), where: eq(notifications.organizationId, ctx.session.activeOrganizationId),

View File

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

View File

@@ -192,7 +192,16 @@ export const userRouter = createTRPCRouter({
}) })
.where(eq(account.userId, ctx.user.id)); .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 getUserByToken: publicProcedure
.input(apiFindOneToken) .input(apiFindOneToken)

View File

@@ -23,3 +23,30 @@ export const deploy = async (jobData: DeploymentJob) => {
throw error; 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 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 { publicIpv4, publicIpv6 } from "public-ip";
import { Client, type ConnectConfig } from "ssh2"; import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws"; import { WebSocketServer } from "ws";
@@ -44,6 +49,21 @@ export const getPublicIpWithFallback = async () => {
return ip; 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 = ( export const setupTerminalWebSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>, 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 { execAsync } from "@dokploy/server";
import { setupDirectories } from "@dokploy/server/setup/config-paths"; import { setupDirectories } from "@dokploy/server/setup/config-paths";
import { initializePostgres } from "@dokploy/server/setup/postgres-setup"; import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
@@ -25,6 +26,8 @@ import {
await initializeStandaloneTraefik(); await initializeStandaloneTraefik();
await initializeRedis(); await initializeRedis();
await initializePostgres(); await initializePostgres();
console.log("Dokploy setup completed");
exit(0);
} catch (e) { } catch (e) {
console.error("Error in dokploy setup", e); console.error("Error in dokploy setup", e);
} }

View File

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

View File

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

View File

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

View File

@@ -331,6 +331,26 @@ export const apiFindOneApplication = createSchema
}) })
.required(); .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 export const apiReloadApplication = createSchema
.pick({ .pick({
appName: true, appName: true,

View File

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

View File

@@ -181,6 +181,18 @@ export const apiFindCompose = z.object({
composeId: z.string().min(1), 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({ export const apiDeleteCompose = z.object({
composeId: z.string().min(1), composeId: z.string().min(1),
deleteVolumes: z.boolean(), deleteVolumes: z.boolean(),

View File

@@ -58,4 +58,5 @@ export const apiUpdateGithub = createSchema.extend({
githubId: z.string().min(1), githubId: z.string().min(1),
name: z.string().min(1), name: z.string().min(1),
gitProviderId: 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", "discord",
"email", "email",
"gotify", "gotify",
"ntfy",
]); ]);
export const notifications = pgTable("notification", { export const notifications = pgTable("notification", {
@@ -44,6 +45,9 @@ export const notifications = pgTable("notification", {
gotifyId: text("gotifyId").references(() => gotify.gotifyId, { gotifyId: text("gotifyId").references(() => gotify.gotifyId, {
onDelete: "cascade", onDelete: "cascade",
}), }),
ntfyId: text("ntfyId").references(() => ntfy.ntfyId, {
onDelete: "cascade",
}),
organizationId: text("organizationId") organizationId: text("organizationId")
.notNull() .notNull()
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
@@ -101,6 +105,17 @@ export const gotify = pgTable("gotify", {
decoration: boolean("decoration"), 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 }) => ({ export const notificationsRelations = relations(notifications, ({ one }) => ({
slack: one(slack, { slack: one(slack, {
fields: [notifications.slackId], fields: [notifications.slackId],
@@ -122,6 +137,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
fields: [notifications.gotifyId], fields: [notifications.gotifyId],
references: [gotify.gotifyId], references: [gotify.gotifyId],
}), }),
ntfy: one(ntfy, {
fields: [notifications.ntfyId],
references: [ntfy.ntfyId],
}),
organization: one(organization, { organization: one(organization, {
fields: [notifications.organizationId], fields: [notifications.organizationId],
references: [organization.id], references: [organization.id],
@@ -284,6 +303,36 @@ export const apiTestGotifyConnection = apiCreateGotify
decoration: z.boolean().optional(), 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 export const apiFindOneNotification = notificationsSchema
.pick({ .pick({
notificationId: true, notificationId: true,
@@ -303,7 +352,9 @@ export const apiSendTest = notificationsSchema
password: z.string(), password: z.string(),
toAddresses: z.array(z.string()), toAddresses: z.array(z.string()),
serverUrl: z.string(), serverUrl: z.string(),
topic: z.string(),
appToken: z.string(), appToken: z.string(),
accessToken: z.string(),
priority: z.number(), priority: z.number(),
}) })
.partial(); .partial();

View File

@@ -322,6 +322,11 @@ export const apiUpdateWebServerMonitoring = z.object({
}); });
export const apiUpdateUser = createSchema.partial().extend({ 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(), password: z.string().optional(),
currentPassword: z.string().optional(), currentPassword: z.string().optional(),
name: z.string().optional(), name: z.string().optional(),

View File

@@ -92,31 +92,48 @@ export const suggestVariants = async ({
const { object } = await generateObject({ const { object } = await generateObject({
model, model,
output: "array", output: "object",
schema: z.object({ schema: z.object({
id: z.string(), suggestions: z.array(
name: z.string(), z.object({
shortDescription: z.string(), id: z.string(),
description: z.string(), name: z.string(),
shortDescription: z.string(),
description: z.string(),
}),
),
}), }),
prompt: ` 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 Act as advanced DevOps engineer and generate a list of open source projects what can cover users needs(up to 3 items).
should include id, name, shortDescription, and description. Use slug of title for id.
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: Important rules for the response:
1. The description field should ONLY contain a plain text description of the project, its features, and use cases 1. Use slug format for the id field (lowercase, hyphenated)
2. Do NOT include any code snippets, configuration examples, or installation instructions in the description 2. The description field should ONLY contain a plain text description of the project, its features, and use cases
3. The shortDescription should be a single-line summary focusing on the main technologies 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} ${input}
`, `,
}); });
if (object?.length) { if (object?.suggestions?.length) {
const result = []; const result = [];
for (const suggestion of object) { for (const suggestion of object.suggestions) {
try { try {
const { object: docker } = await generateObject({ const { object: docker } = await generateObject({
model, model,
@@ -136,16 +153,29 @@ export const suggestVariants = async ({
serviceName: z.string(), serviceName: z.string(),
}), }),
), ),
configFiles: z.array( configFiles: z
z.object({ .array(
content: z.string(), z.object({
filePath: z.string(), content: z.string(),
}), filePath: z.string(),
), }),
)
.optional(),
}), }),
prompt: ` prompt: `
Act as advanced DevOps engineer and generate docker compose with environment variables and domain configurations needed to install the following project. 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: Docker Compose Rules:
1. Use placeholder like \${VARIABLE_NAME-default} for generated variables in the docker-compose.yml 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); console.error("Error in docker compose generation:", error);
} }
} }
return result; return result;
} }

View File

@@ -68,10 +68,26 @@ export const updateBitbucket = async (
input: typeof apiUpdateBitbucket._type, input: typeof apiUpdateBitbucket._type,
) => { ) => {
return await db.transaction(async (tx) => { 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 const result = await tx
.update(bitbucket) .update(bitbucket)
.set({ .set({
...input, bitbucketUsername: input.bitbucketUsername,
bitbucketEmail: input.bitbucketEmail,
appPassword: input.appPassword,
apiToken: input.apiToken,
bitbucketWorkspaceName: input.bitbucketWorkspaceName,
}) })
.where(eq(bitbucket.bitbucketId, bitbucketId)) .where(eq(bitbucket.bitbucketId, bitbucketId))
.returning(); .returning();
@@ -83,7 +99,7 @@ export const updateBitbucket = async (
name: input.name, name: input.name,
organizationId: input.organizationId, organizationId: input.organizationId,
}) })
.where(eq(gitProvider.gitProviderId, input.gitProviderId)) .where(eq(gitProvider.gitProviderId, currentProvider.gitProviderId))
.returning(); .returning();
} }

View File

@@ -9,7 +9,7 @@ import {
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { dump } from "js-yaml"; import { stringify } from "yaml";
import type { z } from "zod"; import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils"; import { encodeBase64 } from "../utils/docker/utils";
import { execAsyncRemote } from "../utils/process/execAsync"; 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"); const configFile = path.join(certDir, "certificate.yml");
if (certificate.serverId) { if (certificate.serverId) {

View File

@@ -3,17 +3,20 @@ import {
type apiCreateDiscord, type apiCreateDiscord,
type apiCreateEmail, type apiCreateEmail,
type apiCreateGotify, type apiCreateGotify,
type apiCreateNtfy,
type apiCreateSlack, type apiCreateSlack,
type apiCreateTelegram, type apiCreateTelegram,
type apiUpdateDiscord, type apiUpdateDiscord,
type apiUpdateEmail, type apiUpdateEmail,
type apiUpdateGotify, type apiUpdateGotify,
type apiUpdateNtfy,
type apiUpdateSlack, type apiUpdateSlack,
type apiUpdateTelegram, type apiUpdateTelegram,
discord, discord,
email, email,
gotify, gotify,
notifications, notifications,
ntfy,
slack, slack,
telegram, telegram,
} from "@dokploy/server/db/schema"; } 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) => { export const findNotificationById = async (notificationId: string) => {
const notification = await db.query.notifications.findFirst({ const notification = await db.query.notifications.findFirst({
where: eq(notifications.notificationId, notificationId), where: eq(notifications.notificationId, notificationId),
@@ -491,6 +584,7 @@ export const findNotificationById = async (notificationId: string) => {
discord: true, discord: true,
email: true, email: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
if (!notification) { if (!notification) {

View File

@@ -10,6 +10,22 @@ import { IS_CLOUD } from "../constants";
export type Registry = typeof registry.$inferSelect; 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 ( export const createRegistry = async (
input: typeof apiCreateRegistry._type, input: typeof apiCreateRegistry._type,
organizationId: string, organizationId: string,
@@ -37,7 +53,11 @@ export const createRegistry = async (
message: "Select a server to add the registry", 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") { if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(input.serverId, loginCommand); await execAsyncRemote(input.serverId, loginCommand);
} else if (newRegistry.registryType === "cloud") { } else if (newRegistry.registryType === "cloud") {
@@ -91,7 +111,11 @@ export const updateRegistry = async (
.returning() .returning()
.then((res) => res[0]); .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 ( if (
IS_CLOUD && IS_CLOUD &&

View File

@@ -342,6 +342,8 @@ export const readPorts = async (
command = `docker service inspect ${resourceName} --format '{{json .Spec.EndpointSpec.Ports}}'`; command = `docker service inspect ${resourceName} --format '{{json .Spec.EndpointSpec.Ports}}'`;
} else if (resourceType === "standalone") { } else if (resourceType === "standalone") {
command = `docker container inspect ${resourceName} --format '{{json .NetworkSettings.Ports}}'`; command = `docker container inspect ${resourceName} --format '{{json .NetworkSettings.Ports}}'`;
} else {
throw new Error("Resource type not found");
} }
let result = ""; let result = "";
if (serverId) { if (serverId) {
@@ -397,17 +399,20 @@ export const writeTraefikSetup = async (input: TraefikOptions) => {
"dokploy-traefik", "dokploy-traefik",
input.serverId, input.serverId,
); );
if (resourceType === "service") { if (resourceType === "service") {
await initializeTraefikService({ await initializeTraefikService({
env: input.env, env: input.env,
additionalPorts: input.additionalPorts, additionalPorts: input.additionalPorts,
serverId: input.serverId, serverId: input.serverId,
}); });
} else { } else if (resourceType === "standalone") {
await initializeStandaloneTraefik({ await initializeStandaloneTraefik({
env: input.env, env: input.env,
additionalPorts: input.additionalPorts, additionalPorts: input.additionalPorts,
serverId: input.serverId, 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>) => { 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 const user = await db
.update(users_temp) .update(users_temp)
.set({ .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 path from "node:path";
import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode"; import type { ContainerCreateOptions, CreateServiceOptions } from "dockerode";
import { dump } from "js-yaml"; import { stringify } from "yaml";
import { paths } from "../constants"; import { paths } from "../constants";
import { getRemoteDocker } from "../utils/servers/remote-docker"; import { getRemoteDocker } from "../utils/servers/remote-docker";
import type { FileConfig } from "../utils/traefik/file-types"; import type { FileConfig } from "../utils/traefik/file-types";
@@ -87,16 +94,27 @@ export const initializeStandaloneTraefik = async ({
}; };
const docker = await getRemoteDocker(serverId); 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 { try {
const container = docker.getContainer(containerName); const container = docker.getContainer(containerName);
await container.remove({ force: true }); await container.remove({ force: true });
await new Promise((resolve) => setTimeout(resolve, 5000)); await new Promise((resolve) => setTimeout(resolve, 5000));
} catch {} } catch {}
await docker.createContainer(settings); try {
const newContainer = docker.getContainer(containerName); await docker.createContainer(settings);
await newContainer.start(); const newContainer = docker.getContainer(containerName);
console.log("Traefik Started ✅"); await newContainer.start();
console.log("Traefik Started ✅");
} catch (error) {
console.log("Traefik Not Found: Starting ", error);
}
}; };
export const initializeTraefikService = async ({ 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 }); mkdirSync(DYNAMIC_TRAEFIK_PATH, { recursive: true });
writeFileSync( writeFileSync(
path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`), path.join(DYNAMIC_TRAEFIK_PATH, `${appName}.yml`),
@@ -297,7 +315,7 @@ export const getDefaultTraefikConfig = () => {
}), }),
}; };
const yamlStr = dump(configObject); const yamlStr = stringify(configObject);
return yamlStr; return yamlStr;
}; };
@@ -351,7 +369,7 @@ export const getDefaultServerTraefikConfig = () => {
}, },
}; };
const yamlStr = dump(configObject); const yamlStr = stringify(configObject);
return yamlStr; return yamlStr;
}; };
@@ -364,13 +382,26 @@ export const createDefaultTraefikConfig = () => {
if (existsSync(acmeJsonPath)) { if (existsSync(acmeJsonPath)) {
chmodSync(acmeJsonPath, "600"); chmodSync(acmeJsonPath, "600");
} }
if (existsSync(mainConfig)) {
console.log("Main config already exists"); // Create the traefik directory first
return;
}
const yamlStr = getDefaultTraefikConfig();
mkdirSync(MAIN_TRAEFIK_PATH, { recursive: true }); 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"); writeFileSync(mainConfig, yamlStr, "utf8");
console.log("Traefik config created successfully");
}; };
export const getDefaultMiddlewares = () => { export const getDefaultMiddlewares = () => {
@@ -386,7 +417,7 @@ export const getDefaultMiddlewares = () => {
}, },
}, },
}; };
const yamlStr = dump(defaultMiddlewares); const yamlStr = stringify(defaultMiddlewares);
return yamlStr; return yamlStr;
}; };
export const createDefaultMiddlewares = () => { export const createDefaultMiddlewares = () => {

View File

@@ -89,7 +89,7 @@ export const getMariadbBackupCommand = (
databaseUser: string, databaseUser: string,
databasePassword: 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 = ( export const getMysqlBackupCommand = (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -42,11 +43,12 @@ export const sendBuildErrorNotifications = async ({
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( const template = await renderAsync(
BuildFailedEmail({ 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) { if (telegram) {
const inlineButton = [ const inlineButton = [
[ [

View File

@@ -9,6 +9,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -43,11 +44,12 @@ export const sendBuildSuccessNotifications = async ({
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( 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) { if (telegram) {
const chunkArray = <T>(array: T[], chunkSize: number): T[][] => const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) => Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>

View File

@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -42,11 +43,12 @@ export const sendDatabaseBackupNotifications = async ({
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( 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) { if (telegram) {
const isError = type === "error" && errorMessage; const isError = type === "error" && errorMessage;

View File

@@ -8,6 +8,7 @@ import {
sendDiscordNotification, sendDiscordNotification,
sendEmailNotification, sendEmailNotification,
sendGotifyNotification, sendGotifyNotification,
sendNtfyNotification,
sendSlackNotification, sendSlackNotification,
sendTelegramNotification, sendTelegramNotification,
} from "./utils"; } from "./utils";
@@ -29,11 +30,12 @@ export const sendDockerCleanupNotifications = async (
telegram: true, telegram: true,
slack: true, slack: true,
gotify: true, gotify: true,
ntfy: true,
}, },
}); });
for (const notification of notificationList) { for (const notification of notificationList) {
const { email, discord, telegram, slack, gotify } = notification; const { email, discord, telegram, slack, gotify, ntfy } = notification;
if (email) { if (email) {
const template = await renderAsync( 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) { if (telegram) {
await sendTelegramNotification( await sendTelegramNotification(
telegram, telegram,

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