Compare commits

..

10 Commits

46 changed files with 1794 additions and 7721 deletions

View File

@@ -206,38 +206,4 @@ describe("getRegistryTag", () => {
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("special characters in username", () => {
it("should handle Harbor robot account username with $ (e.g. robot$library+dokploy)", () => {
const registry = createMockRegistry({
username: "robot$library+dokploy",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/robot$library+dokploy/nginx");
});
it("should handle username with $ and other special characters", () => {
const registry = createMockRegistry({
username: "robot$test+app",
});
const result = getRegistryTag(registry, "myapp:latest");
expect(result).toBe("docker.io/robot$test+app/myapp:latest");
});
it("should handle username with multiple $ symbols", () => {
const registry = createMockRegistry({
username: "user$name$test",
});
const result = getRegistryTag(registry, "app");
expect(result).toBe("docker.io/user$name$test/app");
});
it("should handle username with + and - symbols", () => {
const registry = createMockRegistry({
username: "robot+test-user",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/robot+test-user/nginx:latest");
});
});
});

View File

@@ -1,7 +1,7 @@
import type { Domain } from "@dokploy/server";
import { createDomainLabels } from "@dokploy/server";
import { describe, expect, it } from "vitest";
import { parse, stringify } from "yaml";
import { describe, expect, it } from "vitest";
/**
* Regression tests for Traefik Host rule label format.

View File

@@ -5,27 +5,21 @@ vi.mock("node:fs", () => ({
default: fs,
}));
import type { FileConfig } from "@dokploy/server";
import type { FileConfig, User } from "@dokploy/server";
import {
createDefaultServerTraefikConfig,
loadOrCreateConfig,
updateServerTraefik,
} from "@dokploy/server";
import type { webServerSettings } from "@dokploy/server/db/schema";
import { beforeEach, expect, test, vi } from "vitest";
type WebServerSettings = typeof webServerSettings.$inferSelect;
const baseSettings: WebServerSettings = {
id: "",
const baseAdmin: User = {
https: false,
certificateType: "none",
host: null,
serverIp: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
firstName: "",
lastName: "",
metricsConfig: {
containers: {
refreshRate: 20,
@@ -51,8 +45,29 @@ const baseSettings: WebServerSettings = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
createdAt: null,
createdAt: new Date(),
serverIp: null,
certificateType: "none",
host: null,
letsEncryptEmail: null,
sshPrivateKey: null,
enableDockerCleanup: false,
logCleanupCron: null,
serversQuantity: 0,
stripeCustomerId: "",
stripeSubscriptionId: "",
banExpires: new Date(),
banned: true,
banReason: "",
email: "",
expirationDate: "",
id: "",
isRegistered: false,
createdAt2: new Date().toISOString(),
emailVerified: false,
image: "",
updatedAt: new Date(),
twoFactorEnabled: false,
};
beforeEach(() => {
@@ -70,7 +85,7 @@ test("Should read the configuration file", () => {
test("Should apply redirect-to-https", () => {
updateServerTraefik(
{
...baseSettings,
...baseAdmin,
https: true,
certificateType: "letsencrypt",
},
@@ -85,7 +100,7 @@ test("Should apply redirect-to-https", () => {
});
test("Should change only host when no certificate", () => {
updateServerTraefik(baseSettings, "example.com");
updateServerTraefik(baseAdmin, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -95,7 +110,7 @@ test("Should change only host when no certificate", () => {
test("Should not touch config without host", () => {
const originalConfig: FileConfig = loadOrCreateConfig("dokploy");
updateServerTraefik(baseSettings, null);
updateServerTraefik(baseAdmin, null);
const config: FileConfig = loadOrCreateConfig("dokploy");
@@ -104,14 +119,11 @@ test("Should not touch config without host", () => {
test("Should remove websecure if https rollback to http", () => {
updateServerTraefik(
{ ...baseSettings, certificateType: "letsencrypt" },
{ ...baseAdmin, certificateType: "letsencrypt" },
"example.com",
);
updateServerTraefik(
{ ...baseSettings, certificateType: "none" },
"example.com",
);
updateServerTraefik({ ...baseAdmin, certificateType: "none" }, "example.com");
const config: FileConfig = loadOrCreateConfig("dokploy");

View File

@@ -0,0 +1,332 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { paths } from "@dokploy/server/constants";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 300000;
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => ({
db: {
query: { volumeBackups: { findFirst: vi.fn() } },
},
}));
vi.mock("@dokploy/server/services/volume-backups", () => ({
findVolumeBackupById: vi.fn(),
}));
vi.mock("@dokploy/server/services/destination", () => ({
findDestinationById: vi.fn(),
}));
vi.mock("@dokploy/server/services/application", () => ({
findApplicationById: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeploymentVolumeBackup: vi.fn(),
updateDeploymentStatus: vi.fn(),
}));
import type * as volumeBackupService from "@dokploy/server/services/volume-backups";
import { backupVolume } from "@dokploy/server/utils/volume-backups/backup";
type VolumeBackupData = Awaited<
ReturnType<typeof volumeBackupService.findVolumeBackupById>
>;
const createMockDestination = () => ({
destinationId: "test-dest",
bucket: "test-bucket",
accessKey: "key",
secretAccessKey: "secret",
region: "us-east-1",
endpoint: "s3.amazonaws.com",
});
const createMockVolumeBackup = (volumeName: string, appName: string): any => ({
volumeBackupId: "id",
name: "Test",
volumeName,
appName,
serviceType: "application",
turnOff: false,
prefix: "backups/",
destination: createMockDestination() as any,
application: { appName, serverId: null } as any,
compose: null,
});
async function cleanupDocker(volumeName: string) {
try {
await execAsync(`docker volume rm ${volumeName} 2>/dev/null || true`);
console.log(`✅ Cleaned up volume: ${volumeName}`);
} catch {
// Ignore
}
}
async function cleanupFiles(appName: string) {
try {
const { LOGS_PATH, VOLUME_BACKUPS_PATH } = paths(false);
// Clean logs
const logPath = path.join(LOGS_PATH, appName);
await execAsync(`rm -rf "${logPath}" 2>/dev/null || true`);
// Clean volume backups directory
const backupPath = path.join(VOLUME_BACKUPS_PATH, appName);
await execAsync(`rm -rf "${backupPath}" 2>/dev/null || true`);
console.log(`✅ Cleaned up files for ${appName}`);
} catch (error) {
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
}
}
describe(
"Volume Backups - REAL Tests",
() => {
let currentVolumeName: string;
let currentAppName: string;
beforeEach(() => {
vi.clearAllMocks();
currentVolumeName = `test-vol-${Date.now()}`;
currentAppName = `test-backup-${Date.now()}`;
});
afterEach(async () => {
console.log(`\n🧹 Cleanup: ${currentVolumeName}`);
await cleanupDocker(currentVolumeName);
await cleanupFiles(currentAppName);
console.log("✅ Cleanup done\n");
});
it(
"should backup volume with tar ",
async () => {
console.log(`\n🚀 Test backup: ${currentVolumeName}`);
// Create volume with data
await execAsync(`docker volume create ${currentVolumeName}`);
await execAsync(`
docker run --rm -v ${currentVolumeName}:/data ubuntu bash -c "
echo 'test' > /data/file.txt
mkdir -p /data/dir
echo 'nested' > /data/dir/nested.txt
"
`);
console.log("✅ Volume created with data");
// Backup using tar (simulating what backupVolume does)
const backupVol = `backup-${Date.now()}`;
await execAsync(`docker volume create ${backupVol}`);
try {
await execAsync(`
docker run --rm -v ${currentVolumeName}:/source -v ${backupVol}:/backup ubuntu bash -c "
cd /source && tar cf /backup/test.tar .
"
`);
console.log("✅ Backup created");
// Verify tar contains files
const { stdout } = await execAsync(`
docker run --rm -v ${backupVol}:/backup ubuntu tar -tf /backup/test.tar
`);
expect(stdout).toContain("file.txt");
expect(stdout).toContain("dir/nested.txt");
console.log("✅ Backup verified");
} finally {
await execAsync(`docker volume rm ${backupVol} 2>/dev/null || true`);
}
},
REAL_TEST_TIMEOUT,
);
it(
"should verify backup command has proper logging",
async () => {
console.log(`\n🚀 Test logging: ${currentVolumeName}`);
const mock = createMockVolumeBackup(currentVolumeName, currentAppName);
const command = await backupVolume(mock);
// Verify logging messages
expect(command).toContain("Volume name:");
expect(command).toContain("Starting volume backup");
expect(command).toContain("Volume backup done ✅");
expect(command).toContain("Upload to S3 done ✅");
expect(command).toContain("tar cvf");
console.log("✅ All log messages present");
},
REAL_TEST_TIMEOUT,
);
it(
"should backup 1GB volume using real backupVolume",
async () => {
console.log(
`\n🚀 Test 1GB backup with real code: ${currentVolumeName}`,
);
// Create volume with ~1GB of data
await execAsync(`docker volume create ${currentVolumeName}`);
console.log("✅ Volume created");
const startTime = Date.now();
await execAsync(`
docker run --rm -v ${currentVolumeName}:/data ubuntu bash -c "
echo 'Creating 1GB of test data...'
dd if=/dev/zero of=/data/large-file-1.dat bs=1M count=250 2>/dev/null
dd if=/dev/zero of=/data/large-file-2.dat bs=1M count=250 2>/dev/null
dd if=/dev/zero of=/data/large-file-3.dat bs=1M count=250 2>/dev/null
dd if=/dev/zero of=/data/large-file-4.dat bs=1M count=250 2>/dev/null
mkdir -p /data/metadata
echo 'Large backup test - Issue 3301' > /data/metadata/info.txt
echo 'marker-67890' > /data/metadata/marker.txt
du -sh /data
ls -lh /data
"
`);
const createTime = ((Date.now() - startTime) / 1000).toFixed(2);
console.log(`✅ Created 1GB data in ${createTime}s`);
// Create backup directory (simulating what Dokploy does)
const { VOLUME_BACKUPS_PATH } = paths(false);
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, currentAppName);
await execAsync(`mkdir -p "${volumeBackupPath}"`);
// Use the REAL backupVolume function to generate the command
const mock = createMockVolumeBackup(currentVolumeName, currentAppName);
const fullCommand = await backupVolume(mock);
console.log("📦 Executing REAL Dokploy backupVolume() command...");
// Execute the REAL command (without S3 upload part)
const backupStartTime = Date.now();
const backupFileName = `${currentVolumeName}-${new Date().toISOString()}.tar`;
// Extract and execute just the backup part of the command (tar creation)
// This is what Dokploy really does
const commandWithoutS3 =
fullCommand?.replace(
/rclone copyto[^\n]+/g,
'echo "Skipping S3 upload - keeping file locally for test"',
) || "";
// Also prevent the cleanup of the backup file so we can verify it
const commandWithoutCleanup = commandWithoutS3.replace(
/rm "[^"]+\.tar"/g,
'echo "Skipping cleanup for test verification"',
);
try {
// Execute the real Dokploy backup command
await execAsync(commandWithoutCleanup);
const backupTime = ((Date.now() - backupStartTime) / 1000).toFixed(2);
console.log(`✅ Backup executed in ${backupTime}s`);
} catch (error: any) {
console.error("Backup command failed:", error.message);
throw error;
}
// Verify the backup file was actually created by Dokploy's command
const { stdout: backupFiles } = await execAsync(
`find "${volumeBackupPath}" -name "*.tar" -type f`,
);
const backupFilePath = backupFiles.trim().split("\n")[0];
if (!backupFilePath) {
throw new Error("No backup file found");
}
expect(existsSync(backupFilePath)).toBe(true);
console.log(`✅ Backup file created: ${path.basename(backupFilePath)}`);
// Verify file size
const { stdout: statOutput } = await execAsync(
`stat -f%z "${backupFilePath}" 2>/dev/null || stat -c%s "${backupFilePath}"`,
);
const sizeInMB = Number(statOutput.trim()) / (1024 * 1024);
expect(sizeInMB).toBeGreaterThan(1000); // Should be > 1GB
console.log(`✅ Backup file size: ${sizeInMB.toFixed(2)}MB`);
// Verify tar contents - this proves the backup worked
const { stdout: tarContents } = await execAsync(
`tar -tf "${backupFilePath}"`,
);
expect(tarContents).toContain("large-file-1.dat");
expect(tarContents).toContain("large-file-2.dat");
expect(tarContents).toContain("large-file-3.dat");
expect(tarContents).toContain("large-file-4.dat");
expect(tarContents).toContain("metadata/");
// Extract and verify one file to ensure data integrity
// First check if marker file exists in tar
if (tarContents.includes("marker.txt")) {
const tempDir = path.join(volumeBackupPath, "temp-extract");
await execAsync(`mkdir -p "${tempDir}"`);
// Extract entire tar to handle path variations (. vs ./ prefix)
await execAsync(`tar -xf "${backupFilePath}" -C "${tempDir}"`);
// Find marker file regardless of path
const { stdout: markerPath } = await execAsync(
`find "${tempDir}" -name "marker.txt" -type f`,
);
if (markerPath.trim()) {
const { stdout: markerContent } = await execAsync(
`cat "${markerPath.trim()}"`,
);
expect(markerContent.trim()).toBe("marker-67890");
}
await execAsync(`rm -rf "${tempDir}"`);
console.log("✅ Data integrity verified");
} else {
// Alternative: extract entire metadata folder
const tempDir = path.join(volumeBackupPath, "temp-extract");
await execAsync(`mkdir -p "${tempDir}"`);
await execAsync(`tar -xf "${backupFilePath}" -C "${tempDir}"`);
// Check what was extracted
const { stdout: extractedFiles } = await execAsync(
`find "${tempDir}" -type f`,
);
// Verify marker file exists somewhere
const markerFiles = extractedFiles
.split("\n")
.filter((f) => f.includes("marker.txt"));
expect(markerFiles.length).toBeGreaterThan(0);
const markerPath = markerFiles[0];
const { stdout: markerContent } = await execAsync(
`cat "${markerPath}"`,
);
expect(markerContent.trim()).toBe("marker-67890");
await execAsync(`rm -rf "${tempDir}"`);
console.log("✅ Data integrity verified (alternative path)");
}
console.log("\n📊 Performance Summary:");
console.log(` - Data creation: ${createTime}s`);
console.log(` - Size: ${sizeInMB.toFixed(2)}MB`);
console.log(
"✅ 1GB backup test PASSED - Real Dokploy backupVolume() works correctly",
);
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

File diff suppressed because it is too large Load Diff

View File

@@ -21,10 +21,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
createConverter,
NumberInputWithSteps,
} from "@/components/ui/number-input";
import { Input } from "@/components/ui/input";
import {
Tooltip,
TooltipContent,
@@ -33,23 +30,6 @@ import {
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
const CPU_STEP = 0.25;
const MEMORY_STEP_MB = 256;
const formatNumber = (value: number, decimals = 2): string =>
Number.isInteger(value) ? String(value) : value.toFixed(decimals);
const cpuConverter = createConverter(1_000_000_000, (cpu) =>
cpu <= 0 ? "" : `${formatNumber(cpu)} CPU`,
);
const memoryConverter = createConverter(1024 * 1024, (mb) => {
if (mb <= 0) return "";
return mb >= 1024
? `${formatNumber(mb / 1024)} GB`
: `${formatNumber(mb)} MB`;
});
const addResourcesSchema = z.object({
memoryReservation: z.string().optional(),
cpuLimit: z.string().optional(),
@@ -71,7 +51,6 @@ interface Props {
}
type AddResources = z.infer<typeof addResourcesSchema>;
export const ShowResources = ({ id, type }: Props) => {
const queryMap = {
postgres: () =>
@@ -184,20 +163,16 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
Memory hard limit in bytes. Example: 1GB =
1073741824 bytes. Use +/- buttons to adjust by
256 MB.
1073741824 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
<Input
placeholder="1073741824 (1GB in bytes)"
step={MEMORY_STEP_MB}
converter={memoryConverter}
{...field}
/>
</FormControl>
<FormMessage />
@@ -223,20 +198,16 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
Memory soft limit in bytes. Example: 256MB =
268435456 bytes. Use +/- buttons to adjust by 256
MB.
268435456 bytes
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
<Input
placeholder="268435456 (256MB in bytes)"
step={MEMORY_STEP_MB}
converter={memoryConverter}
{...field}
/>
</FormControl>
<FormMessage />
@@ -263,20 +234,17 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
CPU quota in units of 10^-9 CPUs. Example: 2
CPUs = 2000000000. Use +/- buttons to adjust by
0.25 CPU.
CPUs = 2000000000
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
<Input
placeholder="2000000000 (2 CPUs)"
step={CPU_STEP}
converter={cpuConverter}
{...field}
value={field.value?.toString() || ""}
/>
</FormControl>
<FormMessage />
@@ -303,21 +271,14 @@ export const ShowResources = ({ id, type }: Props) => {
<TooltipContent>
<p>
CPU shares (relative weight). Example: 1 CPU =
1000000000. Use +/- buttons to adjust by 0.25
CPU.
1000000000
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormControl>
<NumberInputWithSteps
value={field.value}
onChange={field.onChange}
placeholder="1000000000 (1 CPU)"
step={CPU_STEP}
converter={cpuConverter}
/>
<Input placeholder="1000000000 (1 CPU)" {...field} />
</FormControl>
<FormMessage />
</FormItem>

View File

@@ -108,8 +108,7 @@ export const getLogType = (message: string): LogStyle => {
/(?:might|may|could)\s+(?:not|cause|lead\s+to)/i.test(lowerMessage) ||
/(?:!+\s*(?:warning|caution|attention)\s*!+)/i.test(lowerMessage) ||
/\b(?:deprecated|obsolete)\b/i.test(lowerMessage) ||
/\b(?:unstable|experimental)\b/i.test(lowerMessage) ||
/⚠|⚠️/i.test(lowerMessage)
/\b(?:unstable|experimental)\b/i.test(lowerMessage)
) {
return LOG_STYLES.warning;
}

View File

@@ -1,6 +1,5 @@
import { Activity } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,6 +7,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowStorageActions } from "./show-storage-actions";
import { ShowTraefikActions } from "./show-traefik-actions";

View File

@@ -7,12 +7,9 @@ interface Props {
serverId?: string;
}
export const ToggleDockerCleanup = ({ serverId }: Props) => {
const { data, refetch } = api.settings.getWebServerSettings.useQuery(
undefined,
{
enabled: !serverId,
},
);
const { data, refetch } = api.user.get.useQuery(undefined, {
enabled: !serverId,
});
const { data: server, refetch: refetchServer } = api.server.one.useQuery(
{
@@ -25,7 +22,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
const enabled = serverId
? server?.enableDockerCleanup
: data?.enableDockerCleanup;
: data?.user.enableDockerCleanup;
const { mutateAsync } = api.settings.updateDockerCleanup.useMutation();
@@ -33,10 +30,7 @@ export const ToggleDockerCleanup = ({ serverId }: Props) => {
try {
await mutateAsync({
enableDockerCleanup: checked,
...(serverId && { serverId }),
} as {
enableDockerCleanup: boolean;
serverId?: string;
serverId: serverId,
});
if (serverId) {
await refetchServer();

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Pencil, PlusIcon } from "lucide-react";
import { PlusIcon, Pencil } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "next-i18next";
import { useEffect, useState } from "react";

View File

@@ -80,7 +80,7 @@ const Schema = z.object({
type Schema = z.infer<typeof Schema>;
export const SetupMonitoring = ({ serverId }: Props) => {
const { data: serverData } = serverId
const { data } = serverId
? api.server.one.useQuery(
{
serverId: serverId || "",
@@ -89,14 +89,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
enabled: !!serverId,
},
)
: { data: null };
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery(undefined, {
enabled: !serverId,
});
const data = serverId ? serverData : webServerSettings;
: api.user.getServerMetrics.useQuery();
const url = useUrl();

View File

@@ -1,17 +1,17 @@
import { format } from "date-fns";
import {
Clock,
Key,
KeyIcon,
Loader2,
MoreHorizontal,
Network,
Pencil,
ServerIcon,
Settings,
Terminal,
Trash2,
Clock,
User,
Key,
Network,
Terminal,
Settings,
Pencil,
Trash2,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";

View File

@@ -67,7 +67,7 @@ type AddServerDomain = z.infer<typeof addServerDomain>;
export const WebDomain = () => {
const { t } = useTranslation("settings");
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { data, refetch } = api.user.get.useQuery();
const { mutateAsync, isLoading } =
api.settings.assignDomainServer.useMutation();
@@ -82,15 +82,15 @@ export const WebDomain = () => {
});
const https = form.watch("https");
const domain = form.watch("domain") || "";
const host = data?.host || "";
const host = data?.user?.host || "";
const hasChanged = domain !== host;
useEffect(() => {
if (data) {
form.reset({
domain: data?.host || "",
certificateType: data?.certificateType || "none",
letsEncryptEmail: data?.letsEncryptEmail || "",
https: data?.https || false,
domain: data?.user?.host || "",
certificateType: data?.user?.certificateType,
letsEncryptEmail: data?.user?.letsEncryptEmail || "",
https: data?.user?.https || false,
});
}
}, [form, form.reset, data]);

View File

@@ -16,8 +16,7 @@ import { UpdateServer } from "./web-server/update-server";
export const WebServer = () => {
const { t } = useTranslation("settings");
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const { data } = api.user.get.useQuery();
const { data: dokployVersion } = api.settings.getDokployVersion.useQuery();
@@ -54,7 +53,7 @@ export const WebServer = () => {
<div className="flex items-center flex-wrap justify-between gap-4">
<span className="text-sm text-muted-foreground">
Server IP: {webServerSettings?.serverIp}
Server IP: {data?.user.serverIp}
</span>
<span className="text-sm text-muted-foreground">
Version: {dokployVersion}

View File

@@ -46,15 +46,15 @@ interface Props {
export const UpdateServerIp = ({ children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, refetch } = api.settings.getWebServerSettings.useQuery();
const { data } = api.user.get.useQuery();
const { data: ip } = api.server.publicIp.useQuery();
const { mutateAsync, isLoading, error, isError } =
api.settings.updateServerIp.useMutation();
api.user.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
serverIp: data?.serverIp || "",
serverIp: data?.user.serverIp || "",
},
resolver: zodResolver(schema),
});
@@ -62,11 +62,13 @@ export const UpdateServerIp = ({ children }: Props) => {
useEffect(() => {
if (data) {
form.reset({
serverIp: data.serverIp || "",
serverIp: data.user.serverIp || "",
});
}
}, [form, form.reset, data]);
const utils = api.useUtils();
const setCurrentIp = () => {
if (!ip) return;
form.setValue("serverIp", ip);
@@ -78,7 +80,7 @@ export const UpdateServerIp = ({ children }: Props) => {
})
.then(async () => {
toast.success("Server IP Updated");
await refetch();
await utils.user.get.invalidate();
setIsOpen(false);
})
.catch(() => {

View File

@@ -1,13 +1,13 @@
import { ChevronDown } from "lucide-react";
import Link from "next/link";
import { Fragment } from "react";
import { ChevronDown } from "lucide-react";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbPage,
} from "@/components/ui/breadcrumb";
import {
DropdownMenu,

View File

@@ -1,84 +0,0 @@
import { MinusIcon, PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
export interface UnitConverter {
toValue: (raw: string | undefined) => number;
fromValue: (value: number) => string;
formatDisplay: (value: number) => string;
}
export const createConverter = (
multiplier: number,
formatDisplay: (value: number) => string,
): UnitConverter => ({
toValue: (raw) => {
if (!raw) return 0;
const value = Number.parseInt(raw, 10);
return Number.isNaN(value) ? 0 : value / multiplier;
},
fromValue: (value) =>
value <= 0 ? "" : String(Math.round(value * multiplier)),
formatDisplay,
});
interface NumberInputWithStepsProps {
value: string | undefined;
onChange: (value: string) => void;
placeholder: string;
step: number;
converter: UnitConverter;
}
export const NumberInputWithSteps = ({
value,
onChange,
placeholder,
step,
converter,
}: NumberInputWithStepsProps) => {
const numericValue = converter.toValue(value);
const displayValue = converter.formatDisplay(numericValue);
const handleIncrement = () =>
onChange(converter.fromValue(numericValue + step));
const handleDecrement = () =>
onChange(converter.fromValue(Math.max(0, numericValue - step)));
return (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleDecrement}
disabled={numericValue <= 0}
>
<MinusIcon className="h-4 w-4" />
</Button>
<Input
placeholder={placeholder}
value={value || ""}
onChange={(e) => onChange(e.target.value)}
className="text-center"
/>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
onClick={handleIncrement}
>
<PlusIcon className="h-4 w-4" />
</Button>
</div>
{displayValue && (
<span className="text-xs text-muted-foreground text-center">
{displayValue}
</span>
)}
</div>
);
};

View File

@@ -1,114 +0,0 @@
CREATE TABLE "webServerSettings" (
"id" text PRIMARY KEY NOT NULL,
"serverIp" text,
"certificateType" "certificateType" DEFAULT 'none' NOT NULL,
"https" boolean DEFAULT false NOT NULL,
"host" text,
"letsEncryptEmail" text,
"sshPrivateKey" text,
"enableDockerCleanup" boolean DEFAULT true NOT NULL,
"logCleanupCron" text DEFAULT '0 0 * * *',
"metricsConfig" jsonb DEFAULT '{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb NOT NULL,
"cleanupCacheApplications" boolean DEFAULT false NOT NULL,
"cleanupCacheOnPreviews" boolean DEFAULT false NOT NULL,
"cleanupCacheOnCompose" boolean DEFAULT false NOT NULL,
"created_at" timestamp DEFAULT now(),
"updated_at" timestamp DEFAULT now() NOT NULL
);
-- Migrate data from user table to webServerSettings
-- Get the owner user's data and insert into webServerSettings
INSERT INTO "webServerSettings" (
"id",
"serverIp",
"certificateType",
"https",
"host",
"letsEncryptEmail",
"sshPrivateKey",
"enableDockerCleanup",
"logCleanupCron",
"metricsConfig",
"cleanupCacheApplications",
"cleanupCacheOnPreviews",
"cleanupCacheOnCompose",
"created_at",
"updated_at"
)
SELECT
gen_random_uuid()::text as "id",
u."serverIp",
COALESCE(u."certificateType", 'none') as "certificateType",
COALESCE(u."https", false) as "https",
u."host",
u."letsEncryptEmail",
u."sshPrivateKey",
COALESCE(u."enableDockerCleanup", true) as "enableDockerCleanup",
COALESCE(u."logCleanupCron", '0 0 * * *') as "logCleanupCron",
COALESCE(
u."metricsConfig",
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb
) as "metricsConfig",
COALESCE(u."cleanupCacheApplications", false) as "cleanupCacheApplications",
COALESCE(u."cleanupCacheOnPreviews", false) as "cleanupCacheOnPreviews",
COALESCE(u."cleanupCacheOnCompose", false) as "cleanupCacheOnCompose",
NOW() as "created_at",
NOW() as "updated_at"
FROM "user" u
INNER JOIN "member" m ON u."id" = m."user_id"
WHERE m."role" = 'owner'
ORDER BY m."created_at" ASC
LIMIT 1;
-- If no owner found, create a default entry
INSERT INTO "webServerSettings" (
"id",
"serverIp",
"certificateType",
"https",
"host",
"letsEncryptEmail",
"sshPrivateKey",
"enableDockerCleanup",
"logCleanupCron",
"metricsConfig",
"cleanupCacheApplications",
"cleanupCacheOnPreviews",
"cleanupCacheOnCompose",
"created_at",
"updated_at"
)
SELECT
gen_random_uuid()::text as "id",
NULL as "serverIp",
'none' as "certificateType",
false as "https",
NULL as "host",
NULL as "letsEncryptEmail",
NULL as "sshPrivateKey",
true as "enableDockerCleanup",
'0 0 * * *' as "logCleanupCron",
'{"server":{"type":"Dokploy","refreshRate":60,"port":4500,"token":"","retentionDays":2,"cronJob":"","urlCallback":"","thresholds":{"cpu":0,"memory":0}},"containers":{"refreshRate":60,"services":{"include":[],"exclude":[]}}}'::jsonb as "metricsConfig",
false as "cleanupCacheApplications",
false as "cleanupCacheOnPreviews",
false as "cleanupCacheOnCompose",
NOW() as "created_at",
NOW() as "updated_at"
WHERE NOT EXISTS (
SELECT 1 FROM "webServerSettings"
);
--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "serverIp";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "certificateType";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "https";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "host";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "letsEncryptEmail";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "sshPrivateKey";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "enableDockerCleanup";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "logCleanupCron";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "metricsConfig";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "cleanupCacheApplications";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnPreviews";--> statement-breakpoint
ALTER TABLE "user" DROP COLUMN "cleanupCacheOnCompose";

File diff suppressed because it is too large Load Diff

View File

@@ -932,13 +932,6 @@
"when": 1765346573500,
"tag": "0132_clean_layla_miller",
"breakpoints": true
},
{
"idx": 133,
"version": "7",
"when": 1766301478005,
"tag": "0133_striped_the_order",
"breakpoints": true
}
]
}

View File

@@ -140,6 +140,7 @@
"react-i18next": "^15.5.2",
"react-markdown": "^9.1.0",
"recharts": "^2.15.3",
"rotating-file-stream": "3.2.3",
"slugify": "^1.6.6",
"sonner": "^1.7.4",
"ssh2": "1.15.0",

View File

@@ -1,8 +1,8 @@
import {
getWebServerSettings,
findUserById,
IS_CLOUD,
setupWebMonitoring,
updateWebServerSettings,
updateUser,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
@@ -11,7 +11,7 @@ import { adminProcedure, createTRPCRouter } from "../trpc";
export const adminRouter = createTRPCRouter({
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
@@ -19,8 +19,15 @@ export const adminRouter = createTRPCRouter({
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.ownerId);
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateWebServerSettings({
await updateUser(user.id, {
metricsConfig: {
server: {
type: "Dokploy",
@@ -45,9 +52,8 @@ export const adminRouter = createTRPCRouter({
},
});
await setupWebMonitoring();
const settings = await getWebServerSettings();
return settings;
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
} catch (error) {
throw error;
}

View File

@@ -17,8 +17,8 @@ import {
findGitProviderById,
findProjectById,
findServerById,
findUserById,
getComposeContainer,
getWebServerSettings,
IS_CLOUD,
loadServices,
randomizeComposeFile,
@@ -569,7 +569,8 @@ export const composeRouter = createTRPCRouter({
const template = await fetchTemplateFiles(input.id, input.baseUrl);
let serverIp = "127.0.0.1";
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
const project = await findProjectById(environment.projectId);
@@ -578,9 +579,6 @@ export const composeRouter = createTRPCRouter({
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const projectName = slugify(`${project.name} ${input.id}`);
@@ -805,16 +803,14 @@ export const composeRouter = createTRPCRouter({
const decodedData = Buffer.from(input.base64, "base64").toString(
"utf-8",
);
let serverIp = "127.0.0.1";
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);
const config = parse(templateData.config) as CompleteTemplate;
@@ -884,16 +880,14 @@ export const composeRouter = createTRPCRouter({
await removeDomainById(domain.domainId);
}
let serverIp = "127.0.0.1";
const admin = await findUserById(ctx.user.ownerId);
let serverIp = admin.serverIp || "127.0.0.1";
if (compose.serverId) {
const server = await findServerById(compose.serverId);
serverIp = server.ipAddress;
} else if (process.env.NODE_ENV === "development") {
serverIp = "127.0.0.1";
} else {
const settings = await getWebServerSettings();
serverIp = settings?.serverIp || "127.0.0.1";
}
const templateData = JSON.parse(decodedData);

View File

@@ -9,7 +9,6 @@ import {
findPreviewDeploymentById,
findServerById,
generateTraefikMeDomain,
getWebServerSettings,
manageDomain,
removeDomain,
removeDomainById,
@@ -108,13 +107,16 @@ export const domainRouter = createTRPCRouter({
}),
canGenerateTraefikMeDomains: protectedProcedure
.input(z.object({ serverId: z.string() }))
.query(async ({ input }) => {
.query(async ({ input, ctx }) => {
const organization = await findOrganizationById(
ctx.session.activeOrganizationId,
);
if (input.serverId) {
const server = await findServerById(input.serverId);
return server.ipAddress;
}
const settings = await getWebServerSettings();
return settings?.serverIp || "";
return organization?.owner.serverIp;
}),
update: protectedProcedure

View File

@@ -8,7 +8,6 @@ import {
createSlackNotification,
createTelegramNotification,
findNotificationById,
getWebServerSettings,
IS_CLOUD,
removeNotificationById,
sendCustomNotification,
@@ -67,6 +66,7 @@ import {
apiUpdateTelegram,
notifications,
server,
user,
} from "@/server/db/schema";
export const notificationRouter = createTRPCRouter({
@@ -364,20 +364,21 @@ export const notificationRouter = createTRPCRouter({
let organizationId = "";
let ServerName = "";
if (input.ServerType === "Dokploy") {
const settings = await getWebServerSettings();
if (
!settings?.metricsConfig?.server?.token ||
settings.metricsConfig.server.token !== input.Token
) {
const result = await db
.select()
.from(user)
.where(
sql`${user.metricsConfig}::jsonb -> 'server' ->> 'token' = ${input.Token}`,
);
if (!result?.[0]?.id) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Token not found",
});
}
// For Dokploy server type, we don't have a specific organizationId
// This might need to be adjusted based on your business logic
organizationId = "";
organizationId = result?.[0]?.id;
ServerName = "Dokploy";
} else {
const result = await db

View File

@@ -12,11 +12,11 @@ import {
DEFAULT_UPDATE_DATA,
execAsync,
findServerById,
findUserById,
getDokployImage,
getDokployImageTag,
getLogCleanupStatus,
getUpdateData,
getWebServerSettings,
IS_CLOUD,
parseRawConfig,
paths,
@@ -40,7 +40,7 @@ import {
updateLetsEncryptEmail,
updateServerById,
updateServerTraefik,
updateWebServerSettings,
updateUser,
writeConfig,
writeMainConfig,
writeTraefikConfigInPath,
@@ -77,13 +77,6 @@ import {
} from "../trpc";
export const settingsRouter = createTRPCRouter({
getWebServerSettings: protectedProcedure.query(async () => {
if (IS_CLOUD) {
return null;
}
const settings = await getWebServerSettings();
return settings;
}),
reloadServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) {
return true;
@@ -216,11 +209,11 @@ export const settingsRouter = createTRPCRouter({
}),
saveSSHPrivateKey: adminProcedure
.input(apiSaveSSHKey)
.mutation(async ({ input }) => {
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
return true;
}
await updateWebServerSettings({
await updateUser(ctx.user.ownerId, {
sshPrivateKey: input.sshPrivateKey,
});
@@ -228,36 +221,36 @@ export const settingsRouter = createTRPCRouter({
}),
assignDomainServer: adminProcedure
.input(apiAssignDomain)
.mutation(async ({ input }) => {
.mutation(async ({ ctx, input }) => {
if (IS_CLOUD) {
return true;
}
const settings = await updateWebServerSettings({
const user = await updateUser(ctx.user.ownerId, {
host: input.host,
letsEncryptEmail: input.letsEncryptEmail,
certificateType: input.certificateType,
https: input.https,
});
if (!settings) {
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Web server settings not found",
message: "User not found",
});
}
updateServerTraefik(settings, input.host);
updateServerTraefik(user, input.host);
if (input.letsEncryptEmail) {
updateLetsEncryptEmail(input.letsEncryptEmail);
}
return settings;
return user;
}),
cleanSSHPrivateKey: adminProcedure.mutation(async () => {
cleanSSHPrivateKey: adminProcedure.mutation(async ({ ctx }) => {
if (IS_CLOUD) {
return true;
}
await updateWebServerSettings({
await updateUser(ctx.user.ownerId, {
sshPrivateKey: null,
});
return true;
@@ -317,11 +310,11 @@ export const settingsRouter = createTRPCRouter({
}
}
} else if (!IS_CLOUD) {
const settingsUpdated = await updateWebServerSettings({
const userUpdated = await updateUser(ctx.user.ownerId, {
enableDockerCleanup: input.enableDockerCleanup,
});
if (settingsUpdated?.enableDockerCleanup) {
if (userUpdated?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running...`,
@@ -495,28 +488,13 @@ export const settingsRouter = createTRPCRouter({
return readConfigInPath(input.path, input.serverId);
}),
getIp: protectedProcedure.query(async () => {
getIp: protectedProcedure.query(async ({ ctx }) => {
if (IS_CLOUD) {
return "";
return true;
}
const settings = await getWebServerSettings();
return settings?.serverIp || "";
const user = await findUserById(ctx.user.ownerId);
return user.serverIp;
}),
updateServerIp: adminProcedure
.input(
z.object({
serverIp: z.string(),
}),
)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
return true;
}
const settings = await updateWebServerSettings({
serverIp: input.serverIp,
});
return settings;
}),
getOpenApiDocument: protectedProcedure.query(
async ({ ctx }): Promise<unknown> => {

View File

@@ -5,7 +5,6 @@ import {
findUserById,
getDokployUrl,
getUserByToken,
getWebServerSettings,
IS_CLOUD,
removeUserById,
sendEmailNotification,
@@ -215,11 +214,10 @@ export const userRouter = createTRPCRouter({
}),
getMetricsToken: protectedProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const settings = await getWebServerSettings();
return {
serverIp: settings?.serverIp,
serverIp: user.serverIp,
enabledFeatures: user.enablePaidFeatures,
metricsConfig: settings?.metricsConfig,
metricsConfig: user?.metricsConfig,
};
}),
remove: protectedProcedure

View File

@@ -75,6 +75,7 @@
"qrcode": "^1.5.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"rotating-file-stream": "3.2.3",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6",
"ssh2": "1.15.0",

View File

@@ -35,4 +35,3 @@ export * from "./ssh-key";
export * from "./user";
export * from "./utils";
export * from "./volume-backups";
export * from "./web-server-settings";

View File

@@ -3,6 +3,7 @@ import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
@@ -14,6 +15,7 @@ import { account, apikey, organization } from "./account";
import { backups } from "./backups";
import { projects } from "./project";
import { schedules } from "./schedule";
import { certificateType } from "./shared";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
@@ -49,10 +51,73 @@ export const user = pgTable("user", {
banExpires: timestamp("ban_expires"),
updatedAt: timestamp("updated_at").notNull(),
// Admin
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
role: text("role").notNull().default("user"),
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
allowImpersonation: boolean("allowImpersonation").notNull().default(false),
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {
type: "Dokploy" | "Remote";
refreshRate: number;
port: number;
token: string;
urlCallback: string;
retentionDays: number;
cronJob: string;
thresholds: {
cpu: number;
memory: number;
};
};
containers: {
refreshRate: number;
services: {
include: string[];
exclude: string[];
};
};
}>()
.notNull()
.default({
server: {
type: "Dokploy",
refreshRate: 60,
port: 4500,
token: "",
retentionDays: 2,
cronJob: "",
urlCallback: "",
thresholds: {
cpu: 0,
memory: 0,
},
},
containers: {
refreshRate: 60,
services: {
include: [],
exclude: [],
},
},
}),
cleanupCacheApplications: boolean("cleanupCacheApplications")
.notNull()
.default(false),
cleanupCacheOnPreviews: boolean("cleanupCacheOnPreviews")
.notNull()
.default(false),
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
.notNull()
.default(false),
stripeCustomerId: text("stripeCustomerId"),
stripeSubscriptionId: text("stripeSubscriptionId"),
serversQuantity: integer("serversQuantity").notNull().default(0),
@@ -138,6 +203,33 @@ export const apiFindOneUserByAuth = createSchema
// authId: true,
})
.required();
export const apiSaveSSHKey = createSchema
.pick({
sshPrivateKey: true,
})
.required();
export const apiAssignDomain = createSchema
.pick({
host: true,
certificateType: true,
letsEncryptEmail: true,
https: true,
})
.required()
.partial({
letsEncryptEmail: true,
https: true,
});
export const apiUpdateDockerCleanup = createSchema
.pick({
enableDockerCleanup: true,
})
.required()
.extend({
serverId: z.string().optional(),
});
export const apiTraefikConfig = z.object({
traefikConfig: z.string().min(1),
@@ -206,6 +298,32 @@ export const apiReadStatsLogs = z.object({
.optional(),
});
export const apiUpdateWebServerMonitoring = z.object({
metricsConfig: z
.object({
server: z.object({
refreshRate: z.number().min(2),
port: z.number().min(1),
token: z.string(),
urlCallback: z.string().url(),
retentionDays: z.number().min(1),
cronJob: z.string().min(1),
thresholds: z.object({
cpu: z.number().min(0),
memory: z.number().min(0),
}),
}),
containers: z.object({
refreshRate: z.number().min(2),
services: z.object({
include: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
}),
}),
})
.required(),
});
export const apiUpdateUser = createSchema.partial().extend({
email: z
.string()
@@ -216,4 +334,29 @@ export const apiUpdateUser = createSchema.partial().extend({
currentPassword: z.string().optional(),
name: z.string().optional(),
lastName: z.string().optional(),
metricsConfig: z
.object({
server: z.object({
type: z.enum(["Dokploy", "Remote"]),
refreshRate: z.number(),
port: z.number(),
token: z.string(),
urlCallback: z.string(),
retentionDays: z.number(),
cronJob: z.string(),
thresholds: z.object({
cpu: z.number(),
memory: z.number(),
}),
}),
containers: z.object({
refreshRate: z.number(),
services: z.object({
include: z.array(z.string()),
exclude: z.array(z.string()),
}),
}),
})
.optional(),
logCleanupCron: z.string().optional().nullable(),
});

View File

@@ -1,178 +0,0 @@
import { relations } from "drizzle-orm";
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { certificateType } from "./shared";
export const webServerSettings = pgTable("webServerSettings", {
id: text("id")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
// Web Server Configuration
serverIp: text("serverIp"),
certificateType: certificateType("certificateType").notNull().default("none"),
https: boolean("https").notNull().default(false),
host: text("host"),
letsEncryptEmail: text("letsEncryptEmail"),
sshPrivateKey: text("sshPrivateKey"),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(true),
logCleanupCron: text("logCleanupCron").default("0 0 * * *"),
// Metrics Configuration
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {
type: "Dokploy" | "Remote";
refreshRate: number;
port: number;
token: string;
urlCallback: string;
retentionDays: number;
cronJob: string;
thresholds: {
cpu: number;
memory: number;
};
};
containers: {
refreshRate: number;
services: {
include: string[];
exclude: string[];
};
};
}>()
.notNull()
.default({
server: {
type: "Dokploy",
refreshRate: 60,
port: 4500,
token: "",
retentionDays: 2,
cronJob: "",
urlCallback: "",
thresholds: {
cpu: 0,
memory: 0,
},
},
containers: {
refreshRate: 60,
services: {
include: [],
exclude: [],
},
},
}),
// Cache Cleanup Configuration
cleanupCacheApplications: boolean("cleanupCacheApplications")
.notNull()
.default(false),
cleanupCacheOnPreviews: boolean("cleanupCacheOnPreviews")
.notNull()
.default(false),
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
.notNull()
.default(false),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
export const webServerSettingsRelations = relations(
webServerSettings,
() => ({}),
);
const createSchema = createInsertSchema(webServerSettings, {
id: z.string().min(1),
});
export const apiUpdateWebServerSettings = createSchema.partial().extend({
serverIp: z.string().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
https: z.boolean().optional(),
host: z.string().optional(),
letsEncryptEmail: z.string().email().optional().nullable(),
sshPrivateKey: z.string().optional(),
enableDockerCleanup: z.boolean().optional(),
logCleanupCron: z.string().optional().nullable(),
metricsConfig: z
.object({
server: z.object({
type: z.enum(["Dokploy", "Remote"]),
refreshRate: z.number(),
port: z.number(),
token: z.string(),
urlCallback: z.string(),
retentionDays: z.number(),
cronJob: z.string(),
thresholds: z.object({
cpu: z.number(),
memory: z.number(),
}),
}),
containers: z.object({
refreshRate: z.number(),
services: z.object({
include: z.array(z.string()),
exclude: z.array(z.string()),
}),
}),
})
.optional(),
cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(),
});
export const apiAssignDomain = z
.object({
host: z.string(),
certificateType: z.enum(["letsencrypt", "none", "custom"]),
letsEncryptEmail: z.string().email().optional().nullable(),
https: z.boolean().optional(),
})
.required()
.partial({
letsEncryptEmail: true,
https: true,
});
export const apiSaveSSHKey = z
.object({
sshPrivateKey: z.string(),
})
.required();
export const apiUpdateDockerCleanup = z.object({
enableDockerCleanup: z.boolean(),
serverId: z.string().optional(),
});
export const apiUpdateWebServerMonitoring = z.object({
metricsConfig: z
.object({
server: z.object({
refreshRate: z.number().min(2),
port: z.number().min(1),
token: z.string(),
urlCallback: z.string().url(),
retentionDays: z.number().min(1),
cronJob: z.string().min(1),
thresholds: z.object({
cpu: z.number().min(0),
memory: z.number().min(0),
}),
}),
containers: z.object({
refreshRate: z.number().min(2),
services: z.object({
include: z.array(z.string()).optional(),
exclude: z.array(z.string()).optional(),
}),
}),
})
.required(),
});

View File

@@ -41,7 +41,6 @@ export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
export * from "./setup/monitoring-setup";
export * from "./setup/postgres-setup";

View File

@@ -9,10 +9,7 @@ import { IS_CLOUD } from "../constants";
import { db } from "../db";
import * as schema from "../db/schema";
import { getUserByToken } from "../services/admin";
import {
getWebServerSettings,
updateWebServerSettings,
} from "../services/web-server-settings";
import { updateUser } from "../services/user";
import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
@@ -38,14 +35,22 @@ const { handler, api } = betterAuth({
},
...(!IS_CLOUD && {
async trustedOrigins() {
const settings = await getWebServerSettings();
if (!settings) {
return [];
const admin = await db.query.member.findFirst({
where: eq(schema.member.role, "owner"),
with: {
user: true,
},
});
if (admin?.user) {
return [
...(admin.user.serverIp
? [`http://${admin.user.serverIp}:3000`]
: []),
...(admin.user.host ? [`https://${admin.user.host}`] : []),
];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
];
return [];
},
}),
emailVerification: {
@@ -117,7 +122,7 @@ const { handler, api } = betterAuth({
});
if (!IS_CLOUD) {
await updateWebServerSettings({
await updateUser(user.id, {
serverIp: await getPublicIpWithFallback(),
});
}

View File

@@ -8,7 +8,6 @@ import {
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { IS_CLOUD } from "../constants";
import { getWebServerSettings } from "./web-server-settings";
export const findUserById = async (userId: string) => {
const userResult = await db.query.user.findFirst({
@@ -108,11 +107,11 @@ export const getDokployUrl = async () => {
if (IS_CLOUD) {
return "https://app.dokploy.com";
}
const settings = await getWebServerSettings();
const owner = await findOwner();
if (settings?.host) {
const protocol = settings?.https ? "https" : "http";
return `${protocol}://${settings?.host}`;
if (owner.user.host) {
const protocol = owner.user.https ? "https" : "http";
return `${protocol}://${owner.user.host}`;
}
return `http://${settings?.serverIp}:${process.env.PORT}`;
return `http://${owner.user.serverIp}:${process.env.PORT}`;
};

View File

@@ -6,8 +6,8 @@ import { generateObject } from "ai";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { IS_CLOUD } from "../constants";
import { findOrganizationById } from "./admin";
import { findServerById } from "./server";
import { getWebServerSettings } from "./web-server-settings";
export const getAiSettingsByOrganizationId = async (organizationId: string) => {
const aiSettings = await db.query.ai.findMany({
@@ -79,8 +79,8 @@ export const suggestVariants = async ({
let ip = "";
if (!IS_CLOUD) {
const settings = await getWebServerSettings();
ip = settings?.serverIp || "";
const organization = await findOrganizationById(organizationId);
ip = organization?.owner.serverIp || "";
}
if (serverId) {

View File

@@ -3,10 +3,10 @@ import { promisify } from "node:util";
import { db } from "@dokploy/server/db";
import { generateRandomDomain } from "@dokploy/server/templates";
import { manageDomain } from "@dokploy/server/utils/traefik/domain";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { type apiCreateDomain, domains } from "../db/schema";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { detectCDNProvider } from "./cdn";
import { findServerById } from "./server";
@@ -61,9 +61,9 @@ export const generateTraefikMeDomain = async (
projectName: appName,
});
}
const settings = await getWebServerSettings();
const admin = await findUserById(userId);
return generateRandomDomain({
serverIp: settings?.serverIp || "",
serverIp: admin?.serverIp || "",
projectName: appName,
});
};

View File

@@ -13,11 +13,11 @@ import { removeDirectoryCode } from "../utils/filesystem/directory";
import { authGithub } from "../utils/providers/github";
import { removeTraefikConfig } from "../utils/traefik/application";
import { manageDomain } from "../utils/traefik/domain";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { removeDeploymentsByPreviewDeploymentId } from "./deployment";
import { createDomain } from "./domain";
import { type Github, getIssueComment } from "./github";
import { getWebServerSettings } from "./web-server-settings";
export type PreviewDeployment = typeof previewDeployments.$inferSelect;
@@ -253,8 +253,8 @@ const generateWildcardDomain = async (
}
if (!ip) {
const settings = await getWebServerSettings();
ip = settings?.serverIp || "";
const admin = await findUserById(userId);
ip = admin?.serverIp || "";
}
const slugIp = ip.replaceAll(".", "-");

View File

@@ -1,44 +0,0 @@
import { db } from "@dokploy/server/db";
import { webServerSettings } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm";
/**
* Get the web server settings (singleton - only one row should exist)
*/
export const getWebServerSettings = async () => {
const settings = await db.query.webServerSettings.findFirst({
orderBy: (settings, { asc }) => [asc(settings.createdAt)],
});
if (!settings) {
// Create default settings if none exist
const [newSettings] = await db
.insert(webServerSettings)
.values({})
.returning();
return newSettings;
}
return settings;
};
/**
* Update web server settings
*/
export const updateWebServerSettings = async (
updates: Partial<typeof webServerSettings.$inferInsert>,
) => {
const current = await getWebServerSettings();
const [updated] = await db
.update(webServerSettings)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(webServerSettings.id, current?.id ?? ""))
.returning();
return updated;
};

View File

@@ -1,7 +1,7 @@
import { findServerById } from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import type { ContainerCreateOptions } from "dockerode";
import { IS_CLOUD } from "../constants";
import { findUserById } from "../services/admin";
import { getDokployImageTag } from "../services/settings";
import { pullImage, pullRemoteImage } from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
@@ -83,8 +83,8 @@ export const setupMonitoring = async (serverId: string) => {
}
};
export const setupWebMonitoring = async () => {
const webServerSettings = await getWebServerSettings();
export const setupWebMonitoring = async (userId: string) => {
const user = await findUserById(userId);
const containerName = "dokploy-monitoring";
let imageName = "dokploy/monitoring:latest";
@@ -99,7 +99,7 @@ export const setupWebMonitoring = async () => {
const settings: ContainerCreateOptions = {
name: containerName,
Env: [`METRICS_CONFIG=${JSON.stringify(webServerSettings?.metricsConfig)}`],
Env: [`METRICS_CONFIG=${JSON.stringify(user?.metricsConfig)}`],
Image: imageName,
HostConfig: {
// Memory: 100 * 1024 * 1024, // 100MB en bytes
@@ -110,9 +110,9 @@ export const setupWebMonitoring = async () => {
Name: "always",
},
PortBindings: {
[`${webServerSettings?.metricsConfig?.server?.port}/tcp`]: [
[`${user?.metricsConfig?.server?.port}/tcp`]: [
{
HostPort: webServerSettings?.metricsConfig?.server?.port.toString(),
HostPort: user?.metricsConfig?.server?.port.toString(),
},
],
},
@@ -126,7 +126,7 @@ export const setupWebMonitoring = async () => {
// NetworkMode: "host",
},
ExposedPorts: {
[`${webServerSettings?.metricsConfig?.server?.port}/tcp`]: {},
[`${user?.metricsConfig?.server?.port}/tcp`]: {},
},
};
const docker = await getRemoteDocker();

View File

@@ -1,8 +1,6 @@
import { paths } from "@dokploy/server/constants";
import {
getWebServerSettings,
updateWebServerSettings,
} from "@dokploy/server/services/web-server-settings";
import { findOwner } from "@dokploy/server/services/admin";
import { updateUser } from "@dokploy/server/services/user";
import { scheduledJobs, scheduleJob } from "node-schedule";
import { execAsync } from "../process/execAsync";
@@ -31,9 +29,12 @@ export const startLogCleanup = async (
}
});
await updateWebServerSettings({
logCleanupCron: cronExpression,
});
const owner = await findOwner();
if (owner) {
await updateUser(owner.user.id, {
logCleanupCron: cronExpression,
});
}
return true;
} catch (error) {
@@ -50,9 +51,12 @@ export const stopLogCleanup = async (): Promise<boolean> => {
}
// Update database
await updateWebServerSettings({
logCleanupCron: null,
});
const owner = await findOwner();
if (owner) {
await updateUser(owner.user.id, {
logCleanupCron: null,
});
}
return true;
} catch (error) {
@@ -65,8 +69,8 @@ export const getLogCleanupStatus = async (): Promise<{
enabled: boolean;
cronExpression: string | null;
}> => {
const settings = await getWebServerSettings();
const cronExpression = settings?.logCleanupCron ?? null;
const owner = await findOwner();
const cronExpression = owner?.user.logCleanupCron ?? null;
return {
enabled: cronExpression !== null,
cronExpression,

View File

@@ -2,7 +2,6 @@ import path from "node:path";
import { member } from "@dokploy/server/db/schema";
import type { BackupSchedule } from "@dokploy/server/services/backup";
import { getAllServers } from "@dokploy/server/services/server";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { eq } from "drizzle-orm";
import { scheduleJob } from "node-schedule";
import { db } from "../../db/index";
@@ -26,9 +25,7 @@ export const initCronJobs = async () => {
return;
}
const webServerSettings = await getWebServerSettings();
if (webServerSettings?.enableDockerCleanup) {
if (admin?.user?.enableDockerCleanup) {
scheduleJob("docker-cleanup", "0 0 * * *", async () => {
console.log(
`Docker Cleanup ${new Date().toLocaleString()}] Running docker cleanup`,
@@ -85,12 +82,9 @@ export const initCronJobs = async () => {
}
}
if (webServerSettings?.logCleanupCron) {
console.log(
"Starting log requests cleanup",
webServerSettings.logCleanupCron,
);
await startLogCleanup(webServerSettings.logCleanupCron);
if (admin?.user?.logCleanupCron) {
console.log("Starting log requests cleanup", admin.user.logCleanupCron);
await startLogCleanup(admin.user.logCleanupCron);
}
};

View File

@@ -90,7 +90,7 @@ export const createCommand = (compose: ComposeNested) => {
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
command = `stack deploy -c ${path} ${appName} --prune`;
}
return command;

View File

@@ -117,7 +117,7 @@ const getRegistryCommands = (
): string => {
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${registry.registryUrl} -u '${registry.username}' --password-stdin || {
echo "${registry.password}" | docker login ${registry.registryUrl} -u ${registry.username} --password-stdin || {
echo "❌ DockerHub Failed" ;
exit 1;
}

View File

@@ -9,19 +9,12 @@ export { ExecError } from "./ExecError";
const execAsyncBase = util.promisify(exec);
// Set maxBuffer to 100MB to handle large backup restore operations
// Default is 1MB which can cause "maxBuffer length exceeded" errors
const MAX_EXEC_BUFFER_SIZE = 100 * 1024 * 1024;
export const execAsync = async (
command: string,
options?: ExecOptions & { shell?: string },
options?: { cwd?: string; env?: NodeJS.ProcessEnv; shell?: string },
): Promise<{ stdout: string; stderr: string }> => {
try {
const result = await execAsyncBase(command, {
...options,
maxBuffer: options?.maxBuffer ?? MAX_EXEC_BUFFER_SIZE,
});
const result = await execAsyncBase(command, options);
return {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
@@ -50,7 +43,6 @@ export const execAsync = async (
interface ExecOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
maxBuffer?: number;
}
export const execAsyncStream = (
@@ -62,26 +54,22 @@ export const execAsyncStream = (
let stdoutComplete = "";
let stderrComplete = "";
const childProcess = exec(
command,
{ ...options, maxBuffer: options?.maxBuffer ?? MAX_EXEC_BUFFER_SIZE },
(error) => {
if (error) {
reject(
new ExecError(`Command execution failed: ${error.message}`, {
command,
stdout: stdoutComplete,
stderr: stderrComplete,
// @ts-ignore
exitCode: error.code,
originalError: error,
}),
);
return;
}
resolve({ stdout: stdoutComplete, stderr: stderrComplete });
},
);
const childProcess = exec(command, options, (error) => {
if (error) {
reject(
new ExecError(`Command execution failed: ${error.message}`, {
command,
stdout: stdoutComplete,
stderr: stderrComplete,
// @ts-ignore
exitCode: error.code,
originalError: error,
}),
);
return;
}
resolve({ stdout: stdoutComplete, stderr: stderrComplete });
});
childProcess.stdout?.on("data", (data: Buffer | string) => {
const stringData = data.toString();

View File

@@ -1,7 +1,7 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { paths } from "@dokploy/server/constants";
import type { webServerSettings } from "@dokploy/server/db/schema/web-server-settings";
import type { User } from "@dokploy/server/services/user";
import { parse, stringify } from "yaml";
import {
loadOrCreateConfig,
@@ -12,10 +12,10 @@ import type { FileConfig } from "./file-types";
import type { MainTraefikConfig } from "./types";
export const updateServerTraefik = (
settings: typeof webServerSettings.$inferSelect | null,
user: User | null,
newHost: string | null,
) => {
const { https, certificateType } = settings || {};
const { https, certificateType } = user || {};
const appName = "dokploy";
const config: FileConfig = loadOrCreateConfig(appName);

12
pnpm-lock.yaml generated
View File

@@ -406,6 +406,9 @@ importers:
recharts:
specifier: ^2.15.3
version: 2.15.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
rotating-file-stream:
specifier: 3.2.3
version: 3.2.3
shell-quote:
specifier: ^1.8.1
version: 1.8.2
@@ -729,6 +732,9 @@ importers:
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
rotating-file-stream:
specifier: 3.2.3
version: 3.2.3
shell-quote:
specifier: ^1.8.1
version: 1.8.2
@@ -7058,6 +7064,10 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
rotating-file-stream@3.2.3:
resolution: {integrity: sha512-cfmm3tqdnbuYw2FBmRTPBDaohYEbMJ3211T35o6eZdr4d7v69+ZeK1Av84Br7FLj2dlzyeZSbN6qTuXXE6dawQ==}
engines: {node: '>=14.0'}
rou3@0.5.1:
resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==}
@@ -14650,6 +14660,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.41.1
fsevents: 2.3.3
rotating-file-stream@3.2.3: {}
rou3@0.5.1: {}
run-parallel@1.2.0: