Merge branch 'canary' into feat/stack-env-support

This commit is contained in:
Mauricio Siu
2025-01-30 23:39:54 -06:00
614 changed files with 71918 additions and 18508 deletions

View File

@@ -111,7 +111,7 @@ class LogRotationManager {
);
console.log("USR1 Signal send to Traefik");
} catch (error) {
console.error("Error to send USR1 Signal to Traefik:", error);
console.error("Error sending USR1 Signal to Traefik:", error);
}
}
public async getStatus(): Promise<boolean> {

View File

@@ -7,6 +7,7 @@ import {
cleanUpSystemPrune,
cleanUpUnusedImages,
} from "../docker/utils";
import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup";
import { runMariadbBackup } from "./mariadb";
import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql";
@@ -25,21 +26,26 @@ export const initCronJobs = async () => {
await cleanUpUnusedImages();
await cleanUpDockerBuilder();
await cleanUpSystemPrune();
await sendDockerCleanupNotifications(admin.adminId);
});
}
const servers = await getAllServers();
for (const server of servers) {
const { appName, serverId } = server;
if (serverId) {
const { serverId, enableDockerCleanup, name } = server;
if (enableDockerCleanup) {
scheduleJob(serverId, "0 0 * * *", async () => {
console.log(
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${appName}`,
`SERVER-BACKUP[${new Date().toLocaleString()}] Running Cleanup ${name}`,
);
await cleanUpUnusedImages(serverId);
await cleanUpDockerBuilder(serverId);
await cleanUpSystemPrune(serverId);
await sendDockerCleanupNotifications(
admin.adminId,
`Docker cleanup for Server ${name} (${serverId})`,
);
});
}
}
@@ -59,8 +65,11 @@ export const initCronJobs = async () => {
});
for (const pg of pgs) {
for (const backup of pg.backups) {
const { schedule, backupId, enabled } = backup;
const { schedule, backupId, enabled, database } = backup;
if (enabled) {
console.log(
`[Backup] Postgres DB ${pg.name} for ${database} Activated`,
);
scheduleJob(backupId, schedule, async () => {
console.log(
`PG-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
@@ -87,8 +96,11 @@ export const initCronJobs = async () => {
for (const maria of mariadbs) {
for (const backup of maria.backups) {
const { schedule, backupId, enabled } = backup;
const { schedule, backupId, enabled, database } = backup;
if (enabled) {
console.log(
`[Backup] MariaDB DB ${maria.name} for ${database} Activated`,
);
scheduleJob(backupId, schedule, async () => {
console.log(
`MARIADB-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
@@ -117,6 +129,7 @@ export const initCronJobs = async () => {
for (const backup of mongo.backups) {
const { schedule, backupId, enabled } = backup;
if (enabled) {
console.log(`[Backup] MongoDB DB ${mongo.name} Activated`);
scheduleJob(backupId, schedule, async () => {
console.log(
`MONGO-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,
@@ -145,6 +158,7 @@ export const initCronJobs = async () => {
for (const backup of mysql.backups) {
const { schedule, backupId, enabled } = backup;
if (enabled) {
console.log(`[Backup] MySQL DB ${mysql.name} Activated`);
scheduleJob(backupId, schedule, async () => {
console.log(
`MYSQL-SERVER[${new Date().toLocaleString()}] Running Backup ${backupId}`,

View File

@@ -49,6 +49,7 @@ Compose Type: ${composeType} ✅`;
writeStream.write(`\n${logBox}\n`);
const projectPath = join(COMPOSE_PATH, compose.appName, "code");
await spawnAsync(
"docker",
[...command.split(" ")],
@@ -68,7 +69,7 @@ Compose Type: ${composeType} ✅`;
writeStream.write("Docker Compose Deployed: ✅");
} catch (error) {
writeStream.write("Error ❌");
writeStream.write(`Error ❌ ${(error as Error).message}`);
throw error;
} finally {
writeStream.end();
@@ -148,6 +149,10 @@ const sanitizeCommand = (command: string) => {
export const createCommand = (compose: ComposeNested) => {
const { composeType, appName, sourceType } = compose;
if (compose.command) {
return `${sanitizeCommand(compose.command)}`;
}
const path =
sourceType === "raw"
? composeType === "stack"
@@ -161,7 +166,6 @@ export const createCommand = (compose: ComposeNested) => {
composeType === "docker-compose"
? `compose -p ${appName} -f ${path} up -d --build --remove-orphans`
: `stack deploy -c ${path} ${appName} --prune`;
const customCommand = sanitizeCommand(compose.command);
return customCommand ? `${baseCommand} ${customCommand}` : baseCommand;
};

View File

@@ -27,7 +27,9 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
const buffer = Buffer.from(arrayBuffer);
const zip = new AdmZip(buffer);
const zipEntries = zip.getEntries();
const zipEntries = zip
.getEntries()
.filter((entry) => !entry.entryName.startsWith("__MACOSX"));
const rootEntries = zipEntries.filter(
(entry) =>
@@ -59,14 +61,22 @@ export const unzipDrop = async (zipFile: File, application: Application) => {
if (!filePath) continue;
const fullPath = path.join(outputPath, filePath);
const fullPath = path.join(outputPath, filePath).replace(/\\/g, "/");
if (application.serverId) {
if (entry.isDirectory) {
await execAsyncRemote(application.serverId, `mkdir -p ${fullPath}`);
} else {
if (!entry.isDirectory) {
if (sftp === null) throw new Error("No SFTP connection available");
await uploadFileToServer(sftp, entry.getData(), fullPath);
try {
const dirPath = path.dirname(fullPath);
await execAsyncRemote(
application.serverId,
`mkdir -p "${dirPath}"`,
);
await uploadFileToServer(sftp, entry.getData(), fullPath);
} catch (err) {
console.error(`Error uploading file ${fullPath}:`, err);
throw err;
}
}
} else {
if (entry.isDirectory) {
@@ -103,7 +113,6 @@ const getSFTPConnection = async (serverId: string): Promise<SFTPWrapper> => {
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
});
};
@@ -115,7 +124,10 @@ const uploadFileToServer = (
): Promise<void> => {
return new Promise((resolve, reject) => {
sftp.writeFile(remotePath, data, (err) => {
if (err) return reject(err);
if (err) {
console.error(`SFTP write error for ${remotePath}:`, err);
return reject(err);
}
resolve();
});
});

View File

@@ -17,7 +17,6 @@ import { buildHeroku, getHerokuCommand } from "./heroku";
import { buildNixpacks, getNixpacksCommand } from "./nixpacks";
import { buildPaketo, getPaketoCommand } from "./paketo";
import { buildStatic, getStaticCommand } from "./static";
import { nanoid } from "nanoid";
// NIXPACKS codeDirectory = where is the path of the code directory
// HEROKU codeDirectory = where is the path of the code directory
@@ -211,21 +210,21 @@ const getImageName = (application: ApplicationNested) => {
}
if (registry) {
return join(registry.imagePrefix || "", appName);
return join(registry.registryUrl, registry.imagePrefix || "", appName);
}
return `${appName}:latest`;
};
const getAuthConfig = (application: ApplicationNested) => {
const { registry, username, password, sourceType } = application;
const { registry, username, password, sourceType, registryUrl } = application;
if (sourceType === "docker") {
if (username && password) {
return {
password,
username,
serveraddress: "https://index.docker.io/v1/",
serveraddress: registryUrl || "",
};
}
} else if (registry) {

View File

@@ -1,5 +1,5 @@
import type { WriteStream } from "node:fs";
import { join } from "node:path";
import path, { join } from "node:path";
import type { ApplicationNested } from "../builders";
import { spawnAsync } from "../process/spawnAsync";
@@ -13,27 +13,32 @@ export const uploadImage = async (
throw new Error("Registry not found");
}
const { registryUrl, imagePrefix, registryType } = registry;
const { registryUrl, imagePrefix } = registry;
const { appName } = application;
const imageName = `${appName}:latest`;
const finalURL = registryUrl;
const registryTag = join(imagePrefix || "", imageName);
const registryTag = path
.join(registryUrl, join(imagePrefix || "", imageName))
.replace(/\/+/g, "/");
try {
writeStream.write(
`📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${registryTag} | ${finalURL}\n`,
`📦 [Enabled Registry] Uploading image to ${registry.registryType} | ${imageName} | ${finalURL}\n`,
);
await spawnAsync(
const loginCommand = spawnAsync(
"docker",
["login", finalURL, "-u", registry.username, "-p", registry.password],
["login", finalURL, "-u", registry.username, "--password-stdin"],
(data) => {
if (writeStream.writable) {
writeStream.write(data);
}
},
);
loginCommand.child?.stdin?.write(registry.password);
loginCommand.child?.stdin?.end();
await loginCommand;
await spawnAsync("docker", ["tag", imageName, registryTag], (data) => {
if (writeStream.writable) {
@@ -68,22 +73,23 @@ export const uploadImageRemoteCommand = (
const finalURL = registryUrl;
const registryTag = join(imagePrefix || "", imageName);
const registryTag = path
.join(registryUrl, join(imagePrefix || "", imageName))
.replace(/\/+/g, "/");
try {
const command = `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" >> ${logPath};
docker login ${finalURL} -u ${registry.username} -p ${registry.password} >> ${logPath} 2>> ${logPath} || {
echo "${registry.password}" | docker login ${finalURL} -u ${registry.username} --password-stdin >> ${logPath} 2>> ${logPath} || {
echo "❌ DockerHub Failed" >> ${logPath};
exit 1;
}
echo "✅ DockerHub Login Success" >> ${logPath};
echo "✅ Registry Login Success" >> ${logPath};
docker tag ${imageName} ${registryTag} >> ${logPath} 2>> ${logPath} || {
echo "❌ Error tagging image" >> ${logPath};
exit 1;
}
echo "✅ Image Tagged" >> ${logPath};
echo "✅ Image Tagged" >> ${logPath};
docker push ${registryTag} 2>> ${logPath} || {
echo "❌ Error pushing image" >> ${logPath};
exit 1;
@@ -92,7 +98,6 @@ export const uploadImageRemoteCommand = (
`;
return command;
} catch (error) {
console.log(error);
throw error;
}
};

View File

@@ -28,17 +28,66 @@ export const buildMongo = async (mongo: MongoNested) => {
databasePassword,
command,
mounts,
replicaSets,
} = mongo;
const defaultMongoEnv = `MONGO_INITDB_ROOT_USERNAME=${databaseUser}\nMONGO_INITDB_ROOT_PASSWORD=${databasePassword}${
const startupScript = `
#!/bin/bash
${
replicaSets
? `
mongod --port 27017 --replSet rs0 --bind_ip_all &
MONGOD_PID=$!
# Wait for MongoDB to be ready
while ! mongosh --eval "db.adminCommand('ping')" > /dev/null 2>&1; do
sleep 2
done
# Check if replica set is already initialized
REPLICA_STATUS=$(mongosh --quiet --eval "rs.status().ok || 0")
if [ "$REPLICA_STATUS" != "1" ]; then
echo "Initializing replica set..."
mongosh --eval '
rs.initiate({
_id: "rs0",
members: [{ _id: 0, host: "localhost:27017", priority: 1 }]
});
// Wait for the replica set to initialize
while (!rs.isMaster().ismaster) {
sleep(1000);
}
// Create root user after replica set is initialized and we are primary
db.getSiblingDB("admin").createUser({
user: "${databaseUser}",
pwd: "${databasePassword}",
roles: ["root"]
});
'
else
echo "Replica set already initialized."
fi
`
: ""
}
${command ?? "wait $MONGOD_PID"}`;
const defaultMongoEnv = `MONGO_INITDB_ROOT_USERNAME=${databaseUser}\nMONGO_INITDB_ROOT_PASSWORD=${databasePassword}${replicaSets ? "\nMONGO_INITDB_DATABASE=admin" : ""}${
env ? `\n${env}` : ""
}`;
const resources = calculateResources({
memoryLimit,
memoryReservation,
cpuLimit,
cpuReservation,
});
const envVariables = prepareEnvironmentVariables(
defaultMongoEnv,
mongo.project.env,
@@ -56,12 +105,17 @@ export const buildMongo = async (mongo: MongoNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
...(replicaSets
? {
Command: ["/bin/sh"],
Args: ["-c", command],
Command: ["/bin/bash"],
Args: ["-c", startupScript],
}
: {}),
: {
...(command && {
Command: ["/bin/bash"],
Args: ["-c", command],
}),
}),
},
Networks: [{ Target: "dokploy-network" }],
Resources: {
@@ -90,6 +144,7 @@ export const buildMongo = async (mongo: MongoNested) => {
: [],
},
};
try {
const service = docker.getService(appName);
const inspect = await service.inspect();

View File

@@ -144,10 +144,11 @@ export const getContainerByName = (name: string): Promise<ContainerInfo> => {
};
export const cleanUpUnusedImages = async (serverId?: string) => {
try {
const command = "docker image prune --force";
if (serverId) {
await execAsyncRemote(serverId, "docker image prune --all --force");
await execAsyncRemote(serverId, command);
} else {
await execAsync("docker image prune --all --force");
await execAsync(command);
}
} catch (error) {
console.error(error);
@@ -157,10 +158,11 @@ export const cleanUpUnusedImages = async (serverId?: string) => {
export const cleanStoppedContainers = async (serverId?: string) => {
try {
const command = "docker container prune --force";
if (serverId) {
await execAsyncRemote(serverId, "docker container prune --force");
await execAsyncRemote(serverId, command);
} else {
await execAsync("docker container prune --force");
await execAsync(command);
}
} catch (error) {
console.error(error);
@@ -170,10 +172,11 @@ export const cleanStoppedContainers = async (serverId?: string) => {
export const cleanUpUnusedVolumes = async (serverId?: string) => {
try {
const command = "docker volume prune --force";
if (serverId) {
await execAsyncRemote(serverId, "docker volume prune --all --force");
await execAsyncRemote(serverId, command);
} else {
await execAsync("docker volume prune --all --force");
await execAsync(command);
}
} catch (error) {
console.error(error);
@@ -199,21 +202,20 @@ export const cleanUpInactiveContainers = async () => {
};
export const cleanUpDockerBuilder = async (serverId?: string) => {
const command = "docker builder prune --all --force";
if (serverId) {
await execAsyncRemote(serverId, "docker builder prune --all --force");
await execAsyncRemote(serverId, command);
} else {
await execAsync("docker builder prune --all --force");
await execAsync(command);
}
};
export const cleanUpSystemPrune = async (serverId?: string) => {
const command = "docker system prune --all --force --volumes";
if (serverId) {
await execAsyncRemote(
serverId,
"docker system prune --all --force --volumes",
);
await execAsyncRemote(serverId, command);
} else {
await execAsync("docker system prune --all --force --volumes");
await execAsync(command);
}
};
@@ -238,9 +240,11 @@ export const startServiceRemote = async (serverId: string, appName: string) => {
export const removeService = async (
appName: string,
serverId?: string | null,
deleteVolumes = false,
) => {
try {
const command = `docker service rm ${appName}`;
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
@@ -304,10 +308,10 @@ export const generateVolumeMounts = (mounts: ApplicationNested["mounts"]) => {
};
type Resources = {
memoryLimit: number | null;
memoryReservation: number | null;
cpuLimit: number | null;
cpuReservation: number | null;
memoryLimit: string | null;
memoryReservation: string | null;
cpuLimit: string | null;
cpuReservation: string | null;
};
export const calculateResources = ({
memoryLimit,
@@ -317,16 +321,14 @@ export const calculateResources = ({
}: Resources): ResourceRequirements => {
return {
Limits: {
MemoryBytes: memoryLimit ? memoryLimit * 1024 * 1024 : undefined,
NanoCPUs: memoryLimit ? (cpuLimit || 1) * 1000 * 1000 * 1000 : undefined,
MemoryBytes: memoryLimit ? Number.parseInt(memoryLimit) : undefined,
NanoCPUs: cpuLimit ? Number.parseInt(cpuLimit) : undefined,
},
Reservations: {
MemoryBytes: memoryLimit
? (memoryReservation || 1) * 1024 * 1024
: undefined,
NanoCPUs: memoryLimit
? (cpuReservation || 1) * 1000 * 1000 * 1000
MemoryBytes: memoryReservation
? Number.parseInt(memoryReservation)
: undefined,
NanoCPUs: cpuReservation ? Number.parseInt(cpuReservation) : undefined,
},
};
};

View File

@@ -39,7 +39,7 @@ export const removeFileOrDirectory = async (path: string) => {
try {
await execAsync(`rm -rf ${path}`);
} catch (error) {
console.error(`Error to remove ${path}: ${error}`);
console.error(`Error removing ${path}: ${error}`);
throw error;
}
};
@@ -58,7 +58,7 @@ export const removeDirectoryCode = async (
await execAsync(command);
}
} catch (error) {
console.error(`Error to remove ${directoryPath}: ${error}`);
console.error(`Error removing ${directoryPath}: ${error}`);
throw error;
}
};
@@ -77,7 +77,7 @@ export const removeComposeDirectory = async (
await execAsync(command);
}
} catch (error) {
console.error(`Error to remove ${directoryPath}: ${error}`);
console.error(`Error removing ${directoryPath}: ${error}`);
throw error;
}
};
@@ -96,7 +96,7 @@ export const removeMonitoringDirectory = async (
await execAsync(command);
}
} catch (error) {
console.error(`Error to remove ${directoryPath}: ${error}`);
console.error(`Error removing ${directoryPath}: ${error}`);
throw error;
}
};

View File

@@ -35,7 +35,6 @@ export async function checkGPUStatus(serverId?: string): Promise<GPUInfo> {
...cudaInfo,
};
} catch (error) {
console.error("Error in checkGPUStatus:", error);
return {
driverInstalled: false,
driverVersion: undefined,
@@ -303,7 +302,7 @@ const setupLocalServer = async (daemonConfig: any) => {
await fs.writeFile(configFile, JSON.stringify(daemonConfig, null, 2));
const setupCommands = [
`pkexec sh -c '
`sudo sh -c '
cp ${configFile} /etc/docker/daemon.json &&
mkdir -p /etc/nvidia-container-runtime &&
sed -i "/swarm-resource/d" /etc/nvidia-container-runtime/config.toml &&
@@ -314,7 +313,13 @@ const setupLocalServer = async (daemonConfig: any) => {
`rm ${configFile}`,
].join(" && ");
await execAsync(setupCommands);
try {
await execAsync(setupCommands);
} catch (error) {
throw new Error(
"Failed to configure GPU support. Please ensure you have sudo privileges and try again.",
);
}
};
const addGpuLabel = async (nodeId: string, serverId?: string) => {
@@ -337,11 +342,10 @@ const verifySetup = async (nodeId: string, serverId?: string) => {
"cat /etc/nvidia-container-runtime/config.toml",
].join(" && ");
const { stdout: diagnostics } = serverId
? await execAsyncRemote(serverId, diagnosticCommands)
: await execAsync(diagnosticCommands);
await (serverId
? execAsyncRemote(serverId, diagnosticCommands)
: execAsync(diagnosticCommands));
console.error("Diagnostic Information:", diagnostics);
throw new Error("GPU support not detected in swarm after setup");
}

View File

@@ -2,10 +2,12 @@ import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import BuildFailedEmail from "@dokploy/server/emails/emails/build-failed";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -28,6 +30,7 @@ export const sendBuildErrorNotifications = async ({
adminId,
}: Props) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: and(
eq(notifications.appBuildError, true),
@@ -38,11 +41,12 @@ export const sendBuildErrorNotifications = async ({
discord: true,
telegram: true,
slack: true,
gotify: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack } = notification;
const { email, discord, telegram, slack, gotify } = notification;
if (email) {
const template = await renderAsync(
BuildFailedEmail({
@@ -58,46 +62,49 @@ export const sendBuildErrorNotifications = async ({
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: "> `⚠️` - Build Failed",
title: decorate(">", "`⚠️` Build Failed"),
color: 0xed4245,
fields: [
{
name: "`🛠️`・Project",
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: "`⚙️`・Application",
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: "`❔`・Type",
name: decorate("`❔`", "Type"),
value: applicationType,
inline: true,
},
{
name: "`📅`・Date",
value: date.toLocaleDateString(),
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: "`⌚`・Time",
value: date.toLocaleTimeString(),
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: "`❓`・Type",
name: decorate("`❓`", "Type"),
value: "Failed",
inline: true,
},
{
name: "`⚠️`・Error Message",
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
{
name: "`🧷`・Build Link",
name: decorate("`🧷`", "Build Link"),
value: `[Click here to access build link](${buildLink})`,
},
],
@@ -108,22 +115,35 @@ export const sendBuildErrorNotifications = async ({
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("⚠️", "Build Failed"),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("❔", `Type: ${applicationType}`)}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${decorate("⚠️", `Error:\n${errorMessage}`)}` +
`${decorate("🔗", `Build details:\n${buildLink}`)}`,
);
}
if (telegram) {
const inlineButton = [
[
{
text: "Deployment Logs",
url: buildLink,
},
],
];
await sendTelegramNotification(
telegram,
`
<b>⚠️ Build Failed</b>
<b>Project:</b> ${projectName}
<b>Application:</b> ${applicationName}
<b>Type:</b> ${applicationType}
<b>Time:</b> ${date.toLocaleString()}
<b>Error:</b>
<pre>${errorMessage}</pre>
<b>Build Details:</b> ${buildLink}
`,
`<b>⚠️ Build Failed</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`,
inlineButton,
);
}

View File

@@ -1,11 +1,14 @@
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import BuildSuccessEmail from "@dokploy/server/emails/emails/build-success";
import type { Domain } from "@dokploy/server/services/domain";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -16,6 +19,7 @@ interface Props {
applicationType: string;
buildLink: string;
adminId: string;
domains: Domain[];
}
export const sendBuildSuccessNotifications = async ({
@@ -24,8 +28,10 @@ export const sendBuildSuccessNotifications = async ({
applicationType,
buildLink,
adminId,
domains,
}: Props) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: and(
eq(notifications.appDeploy, true),
@@ -36,11 +42,12 @@ export const sendBuildSuccessNotifications = async ({
discord: true,
telegram: true,
slack: true,
gotify: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack } = notification;
const { email, discord, telegram, slack, gotify } = notification;
if (email) {
const template = await renderAsync(
@@ -56,42 +63,45 @@ export const sendBuildSuccessNotifications = async ({
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: "> `✅` - Build Success",
title: decorate(">", "`✅` Build Success"),
color: 0x57f287,
fields: [
{
name: "`🛠️`・Project",
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: "`⚙️`・Application",
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: "`❔`・Application Type",
name: decorate("`❔`", "Type"),
value: applicationType,
inline: true,
},
{
name: "`📅`・Date",
value: date.toLocaleDateString(),
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: "`⌚`・Time",
value: date.toLocaleTimeString(),
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: "`❓`・Type",
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
{
name: "`🧷`・Build Link",
name: decorate("`🧷`", "Build Link"),
value: `[Click here to access build link](${buildLink})`,
},
],
@@ -102,19 +112,45 @@ export const sendBuildSuccessNotifications = async ({
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("✅", "Build Success"),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("❔", `Type: ${applicationType}`)}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${decorate("🔗", `Build details:\n${buildLink}`)}`,
);
}
if (telegram) {
const chunkArray = <T>(array: T[], chunkSize: number): T[][] =>
Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>
array.slice(i * chunkSize, i * chunkSize + chunkSize),
);
const inlineButton = [
[
{
text: "Deployment Logs",
url: buildLink,
},
],
...chunkArray(domains, 2).map((chunk) =>
chunk.map((data) => ({
text: data.host,
url: `${data.https ? "https" : "http"}://${data.host}`,
})),
),
];
await sendTelegramNotification(
telegram,
`
<b>✅ Build Success</b>
<b>Project:</b> ${projectName}
<b>Application:</b> ${applicationName}
<b>Type:</b> ${applicationType}
<b>Time:</b> ${date.toLocaleString()}
<b>Build Details:</b> ${buildLink}
`,
`<b>✅ Build Success</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${applicationType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
inlineButton,
);
}

View File

@@ -1,11 +1,14 @@
import { error } from "node:console";
import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DatabaseBackupEmail from "@dokploy/server/emails/emails/database-backup";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -26,6 +29,7 @@ export const sendDatabaseBackupNotifications = async ({
errorMessage?: string;
}) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: and(
eq(notifications.databaseBackup, true),
@@ -36,11 +40,12 @@ export const sendDatabaseBackupNotifications = async ({
discord: true,
telegram: true,
slack: true,
gotify: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack } = notification;
const { email, discord, telegram, slack, gotify } = notification;
if (email) {
const template = await renderAsync(
@@ -61,40 +66,43 @@ export const sendDatabaseBackupNotifications = async ({
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title:
type === "success"
? "> `✅` - Database Backup Successful"
: "> `❌` - Database Backup Failed",
? decorate(">", "`✅` Database Backup Successful")
: decorate(">", "`❌` Database Backup Failed"),
color: type === "success" ? 0x57f287 : 0xed4245,
fields: [
{
name: "`🛠️`・Project",
name: decorate("`🛠️`", "Project"),
value: projectName,
inline: true,
},
{
name: "`⚙️`・Application",
name: decorate("`⚙️`", "Application"),
value: applicationName,
inline: true,
},
{
name: "`❔`・Database",
name: decorate("`❔`", "Database"),
value: databaseType,
inline: true,
},
{
name: "`📅`・Date",
value: date.toLocaleDateString(),
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: "`⌚`・Time",
value: date.toLocaleTimeString(),
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: "`❓`・Type",
name: decorate("`❓`", "Type"),
value: type
.replace("error", "Failed")
.replace("success", "Successful"),
@@ -103,7 +111,7 @@ export const sendDatabaseBackupNotifications = async ({
...(type === "error" && errorMessage
? [
{
name: "`⚠️`・Error Message",
name: decorate("`⚠️`", "Error Message"),
value: `\`\`\`${errorMessage}\`\`\``,
},
]
@@ -116,19 +124,35 @@ export const sendDatabaseBackupNotifications = async ({
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate(
type === "success" ? "✅" : "❌",
`Database Backup ${type === "success" ? "Successful" : "Failed"}`,
),
`${decorate("🛠️", `Project: ${projectName}`)}` +
`${decorate("⚙️", `Application: ${applicationName}`)}` +
`${decorate("❔", `Type: ${databaseType}`)}` +
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${type === "error" && errorMessage ? decorate("❌", `Error:\n${errorMessage}`) : ""}`,
);
}
if (telegram) {
const isError = type === "error" && errorMessage;
const statusEmoji = type === "success" ? "✅" : "❌";
const messageText = `
<b>${statusEmoji} Database Backup ${type === "success" ? "Successful" : "Failed"}</b>
<b>Project:</b> ${projectName}
<b>Application:</b> ${applicationName}
<b>Type:</b> ${databaseType}
<b>Time:</b> ${date.toLocaleString()}
<b>Status:</b> ${type === "success" ? "Successful" : "Failed"}
${type === "error" && errorMessage ? `<b>Error:</b> ${errorMessage}` : ""}
`;
const typeStatus = type === "success" ? "Successful" : "Failed";
const errorMsg = isError
? `\n\n<b>Error:</b>\n<pre>${errorMessage}</pre>`
: "";
const messageText = `<b>${statusEmoji} Database Backup ${typeStatus}</b>\n\n<b>Project:</b> ${projectName}\n<b>Application:</b> ${applicationName}\n<b>Type:</b> ${databaseType}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}${isError ? errorMsg : ""}`;
await sendTelegramNotification(telegram, messageText);
}

View File

@@ -2,10 +2,12 @@ import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DockerCleanupEmail from "@dokploy/server/emails/emails/docker-cleanup";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { and, eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
@@ -15,6 +17,7 @@ export const sendDockerCleanupNotifications = async (
message = "Docker cleanup for dokploy",
) => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: and(
eq(notifications.dockerCleanup, true),
@@ -25,11 +28,12 @@ export const sendDockerCleanupNotifications = async (
discord: true,
telegram: true,
slack: true,
gotify: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack } = notification;
const { email, discord, telegram, slack, gotify } = notification;
if (email) {
const template = await renderAsync(
@@ -44,27 +48,30 @@ export const sendDockerCleanupNotifications = async (
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: "> `✅` - Docker Cleanup",
title: decorate(">", "`✅` Docker Cleanup"),
color: 0x57f287,
fields: [
{
name: "`📅`・Date",
value: date.toLocaleDateString(),
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: "`⌚`・Time",
value: date.toLocaleTimeString(),
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: "`❓`・Type",
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
{
name: "`📜`・Message",
name: decorate("`📜`", "Message"),
value: `\`\`\`${message}\`\`\``,
},
],
@@ -75,14 +82,21 @@ export const sendDockerCleanupNotifications = async (
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("✅", "Docker Cleanup"),
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}` +
`${decorate("📜", `Message:\n${message}`)}`,
);
}
if (telegram) {
await sendTelegramNotification(
telegram,
`
<b>✅ Docker Cleanup</b>
<b>Message:</b> ${message}
<b>Time:</b> ${date.toLocaleString()}
`,
`<b>✅ Docker Cleanup</b>\n\n<b>Message:</b> ${message}\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
);
}

View File

@@ -2,16 +2,19 @@ import { db } from "@dokploy/server/db";
import { notifications } from "@dokploy/server/db/schema";
import DokployRestartEmail from "@dokploy/server/emails/emails/dokploy-restart";
import { renderAsync } from "@react-email/components";
import { format } from "date-fns";
import { eq } from "drizzle-orm";
import {
sendDiscordNotification,
sendEmailNotification,
sendGotifyNotification,
sendSlackNotification,
sendTelegramNotification,
} from "./utils";
export const sendDokployRestartNotifications = async () => {
const date = new Date();
const unixDate = ~~(Number(date) / 1000);
const notificationList = await db.query.notifications.findMany({
where: eq(notifications.dokployRestart, true),
with: {
@@ -19,11 +22,12 @@ export const sendDokployRestartNotifications = async () => {
discord: true,
telegram: true,
slack: true,
gotify: true,
},
});
for (const notification of notificationList) {
const { email, discord, telegram, slack } = notification;
const { email, discord, telegram, slack, gotify } = notification;
if (email) {
const template = await renderAsync(
@@ -33,22 +37,25 @@ export const sendDokployRestartNotifications = async () => {
}
if (discord) {
const decorate = (decoration: string, text: string) =>
`${discord.decoration ? decoration : ""} ${text}`.trim();
await sendDiscordNotification(discord, {
title: "> `✅` - Dokploy Server Restarted",
title: decorate(">", "`✅` Dokploy Server Restarted"),
color: 0x57f287,
fields: [
{
name: "`📅`・Date",
value: date.toLocaleDateString(),
name: decorate("`📅`", "Date"),
value: `<t:${unixDate}:D>`,
inline: true,
},
{
name: "`⌚`・Time",
value: date.toLocaleTimeString(),
name: decorate("`⌚`", "Time"),
value: `<t:${unixDate}:t>`,
inline: true,
},
{
name: "`❓`・Type",
name: decorate("`❓`", "Type"),
value: "Successful",
inline: true,
},
@@ -60,13 +67,20 @@ export const sendDokployRestartNotifications = async () => {
});
}
if (gotify) {
const decorate = (decoration: string, text: string) =>
`${gotify.decoration ? decoration : ""} ${text}\n`;
await sendGotifyNotification(
gotify,
decorate("✅", "Dokploy Server Restarted"),
`${decorate("🕒", `Date: ${date.toLocaleString()}`)}`,
);
}
if (telegram) {
await sendTelegramNotification(
telegram,
`
<b>✅ Dokploy Serverd Restarted</b>
<b>Time:</b> ${date.toLocaleString()}
`,
`<b>✅ Dokploy Server Restarted</b>\n\n<b>Date:</b> ${format(date, "PP")}\n<b>Time:</b> ${format(date, "pp")}`,
);
}

View File

@@ -1,6 +1,7 @@
import type {
discord,
email,
gotify,
slack,
telegram,
} from "@dokploy/server/db/schema";
@@ -41,20 +42,24 @@ export const sendDiscordNotification = async (
connection: typeof discord.$inferInsert,
embed: any,
) => {
try {
await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ embeds: [embed] }),
});
} catch (err) {
console.log(err);
}
// try {
await fetch(connection.webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ embeds: [embed] }),
});
// } catch (err) {
// console.log(err);
// }
};
export const sendTelegramNotification = async (
connection: typeof telegram.$inferInsert,
messageText: string,
inlineButton?: {
text: string;
url: string;
}[][],
) => {
try {
const url = `https://api.telegram.org/bot${connection.botToken}/sendMessage`;
@@ -66,6 +71,9 @@ export const sendTelegramNotification = async (
text: messageText,
parse_mode: "HTML",
disable_web_page_preview: true,
reply_markup: {
inline_keyboard: inlineButton,
},
}),
});
} catch (err) {
@@ -87,3 +95,33 @@ export const sendSlackNotification = async (
console.log(err);
}
};
export const sendGotifyNotification = async (
connection: typeof gotify.$inferInsert,
title: string,
message: string,
) => {
const response = await fetch(`${connection.serverUrl}/message`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Gotify-Key": connection.appToken,
},
body: JSON.stringify({
title: title,
message: message,
priority: connection.priority,
extras: {
"client::display": {
contentType: "text/plain",
},
},
}),
});
if (!response.ok) {
throw new Error(
`Failed to send Gotify notification: ${response.statusText}`,
);
}
};

View File

@@ -7,6 +7,7 @@ export const execAsync = util.promisify(exec);
export const execAsyncRemote = async (
serverId: string | null,
command: string,
onData?: (data: string) => void,
): Promise<{ stdout: string; stderr: string }> => {
if (!serverId) return { stdout: "", stderr: "" };
const server = await findServerById(serverId);
@@ -21,7 +22,10 @@ export const execAsyncRemote = async (
conn
.once("ready", () => {
conn.exec(command, (err, stream) => {
if (err) throw err;
if (err) {
onData?.(err.message);
throw err;
}
stream
.on("close", (code: number, signal: string) => {
conn.end();
@@ -37,21 +41,27 @@ export const execAsyncRemote = async (
})
.on("data", (data: string) => {
stdout += data.toString();
onData?.(data.toString());
})
.stderr.on("data", (data) => {
stderr += data.toString();
onData?.(data.toString());
});
});
})
.on("error", (err) => {
conn.end();
if (err.level === "client-authentication") {
onData?.(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
);
reject(
new Error(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
),
);
} else {
onData?.(`SSH connection error: ${err.message}`);
reject(new Error(`SSH connection error: ${err.message}`));
}
})

View File

@@ -53,7 +53,7 @@ export const buildRemoteDocker = async (
application: ApplicationNested,
logPath: string,
) => {
const { sourceType, dockerImage, username, password } = application;
const { registryUrl, dockerImage, username, password } = application;
try {
if (!dockerImage) {
@@ -65,7 +65,7 @@ echo "Pulling ${dockerImage}" >> ${logPath};
if (username && password) {
command += `
if ! docker login --username ${username} --password ${password} https://index.docker.io/v1/ >> ${logPath} 2>&1; then
if ! echo "${password}" | docker login --username "${username}" --password-stdin "${registryUrl || ""}" >> ${logPath} 2>&1; then
echo "❌ Login failed" >> ${logPath};
exit 1;
fi

View File

@@ -69,6 +69,7 @@ export const cloneGitRepository = async (
});
}
const { port } = sanitizeRepoPathSSH(customGitUrl);
await spawnAsync(
"git",
[
@@ -91,7 +92,7 @@ export const cloneGitRepository = async (
env: {
...process.env,
...(customGitSSHKeyId && {
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath}`,
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath}${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`,
}),
},
},
@@ -168,7 +169,8 @@ export const getCustomGitCloneCommand = async (
);
if (customGitSSHKeyId) {
const sshKey = await findSSHKeyById(customGitSSHKeyId);
const gitSshCommand = `ssh -i /tmp/id_rsa -o UserKnownHostsFile=${knownHostsPath}`;
const { port } = sanitizeRepoPathSSH(customGitUrl);
const gitSshCommand = `ssh -i /tmp/id_rsa${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`;
command.push(
`
echo "${sshKey.privateKey}" > /tmp/id_rsa
@@ -304,6 +306,7 @@ export const cloneGitRawRepository = async (entity: {
});
}
const { port } = sanitizeRepoPathSSH(customGitUrl);
await spawnAsync(
"git",
[
@@ -322,7 +325,7 @@ export const cloneGitRawRepository = async (entity: {
env: {
...process.env,
...(customGitSSHKeyId && {
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath} -o UserKnownHostsFile=${knownHostsPath}`,
GIT_SSH_COMMAND: `ssh -i ${temporalKeyPath}${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`,
}),
},
},
@@ -381,7 +384,8 @@ export const cloneRawGitRepositoryRemote = async (compose: Compose) => {
command.push(`mkdir -p ${outputPath};`);
if (customGitSSHKeyId) {
const sshKey = await findSSHKeyById(customGitSSHKeyId);
const gitSshCommand = `ssh -i /tmp/id_rsa -o UserKnownHostsFile=${knownHostsPath}`;
const { port } = sanitizeRepoPathSSH(customGitUrl);
const gitSshCommand = `ssh -i /tmp/id_rsa${port ? ` -p ${port}` : ""} -o UserKnownHostsFile=${knownHostsPath}`;
command.push(
`
echo "${sshKey.privateKey}" > /tmp/id_rsa

View File

@@ -26,7 +26,7 @@ export const refreshGitlabToken = async (gitlabProviderId: string) => {
return;
}
const response = await fetch("https://gitlab.com/oauth/token", {
const response = await fetch(`${gitlabProvider.gitlabUrl}/oauth/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -122,7 +122,7 @@ export const cloneGitlabRepository = async (
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `gitlab.com/${gitlabPathNamespace}.git`;
const repoclone = `${gitlab?.gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
const cloneUrl = `https://oauth2:${gitlab?.accessToken}@${repoclone}`;
try {
@@ -218,7 +218,7 @@ export const getGitlabCloneCommand = async (
const basePath = isCompose ? COMPOSE_PATH : APPLICATIONS_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `gitlab.com/${gitlabPathNamespace}.git`;
const repoclone = `${gitlab?.gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
const cloneUrl = `https://oauth2:${gitlab?.accessToken}@${repoclone}`;
const cloneCommand = `
@@ -244,7 +244,7 @@ export const getGitlabRepositories = async (gitlabId?: string) => {
const gitlabProvider = await findGitlabById(gitlabId);
const response = await fetch(
`https://gitlab.com/api/v4/projects?membership=true&owned=true&page=${0}&per_page=${100}`,
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&owned=true&page=${0}&per_page=${100}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
@@ -304,7 +304,7 @@ export const getGitlabBranches = async (input: {
const gitlabProvider = await findGitlabById(input.gitlabId);
const branchesResponse = await fetch(
`https://gitlab.com/api/v4/projects/${input.id}/repository/branches`,
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
@@ -350,7 +350,9 @@ export const cloneRawGitlabRepository = async (entity: Compose) => {
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
await recreateDirectory(outputPath);
const repoclone = `gitlab.com/${gitlabPathNamespace}.git`;
const gitlabUrl = gitlabProvider.gitlabUrl;
// What happen with oauth in self hosted instances?
const repoclone = `${gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
try {
@@ -390,7 +392,7 @@ export const cloneRawGitlabRepositoryRemote = async (compose: Compose) => {
await refreshGitlabToken(gitlabId);
const basePath = COMPOSE_PATH;
const outputPath = join(basePath, appName, "code");
const repoclone = `gitlab.com/${gitlabPathNamespace}.git`;
const repoclone = `${gitlabProvider.gitlabUrl.replace(/^https?:\/\//, "")}/${gitlabPathNamespace}.git`;
const cloneUrl = `https://oauth2:${gitlabProvider?.accessToken}@${repoclone}`;
try {
const command = `
@@ -417,7 +419,7 @@ export const testGitlabConnection = async (
const gitlabProvider = await findGitlabById(gitlabId);
const response = await fetch(
`https://gitlab.com/api/v4/projects?membership=true&owned=true&page=${0}&per_page=${100}`,
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&owned=true&page=${0}&per_page=${100}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,