Merge branch 'refs/heads/canary' into feature/custom-entrypoint

# Conflicts:
#	apps/dokploy/drizzle/meta/0131_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
This commit is contained in:
mkarpats
2025-12-12 18:26:29 +02:00
40 changed files with 14556 additions and 167 deletions
@@ -0,0 +1,209 @@
import type { Registry } from "@dokploy/server";
import { getRegistryTag } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("getRegistryTag", () => {
// Helper to create a mock registry
const createMockRegistry = (overrides: Partial<Registry> = {}): Registry => {
return {
registryId: "test-registry-id",
registryName: "Test Registry",
username: "myuser",
password: "test-password",
registryUrl: "docker.io",
registryType: "cloud",
imagePrefix: null,
createdAt: new Date().toISOString(),
organizationId: "test-org-id",
...overrides,
};
};
describe("with username (no imagePrefix)", () => {
it("should handle simple image name without tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with tag", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myuser/nginx:latest");
});
it("should handle image name with username already present (no duplication)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with username and tag already present", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "myuser/myprivaterepo:latest");
// Should not duplicate username
expect(result).toBe("docker.io/myuser/myprivaterepo:latest");
});
it("should handle complex image name with username", () => {
const registry = createMockRegistry({ username: "siumauricio" });
const result = getRegistryTag(
registry,
"siumauricio/app-parse-multi-byte-port-e32uh7",
);
// Should not duplicate username
expect(result).toBe(
"docker.io/siumauricio/app-parse-multi-byte-port-e32uh7",
);
});
it("should handle image name with different username (should not duplicate)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "otheruser/myprivaterepo");
expect(result).toBe("docker.io/myuser/myprivaterepo");
});
it("should handle image name with full registry URL (no username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "docker.io/nginx");
// Should add username since imageName doesn't have one
expect(result).toBe("docker.io/myuser/nginx");
});
it("should handle image name with custom registry URL and username", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/myuser/repo");
// Should not duplicate username even if registry URL is different
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with custom registry URL (different username)", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "ghcr.io/otheruser/repo");
// Should use registry username, not the one in imageName
expect(result).toBe("docker.io/myuser/repo");
});
});
describe("with imagePrefix", () => {
it("should use imagePrefix instead of username", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("docker.io/myorg/nginx");
});
it("should use imagePrefix with image tag", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "nginx:latest");
expect(result).toBe("docker.io/myorg/nginx:latest");
});
it("should handle imagePrefix with username already in image name", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
it("should handle imagePrefix matching image name prefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
});
const result = getRegistryTag(registry, "myorg/myprivaterepo");
// Should not duplicate prefix
expect(result).toBe("docker.io/myorg/myprivaterepo");
});
});
describe("without registryUrl", () => {
it("should work without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myuser/nginx");
});
it("should work without registryUrl with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("myorg/nginx");
});
it("should handle username already present without registryUrl", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("myuser/myprivaterepo");
});
});
describe("with custom registryUrl", () => {
it("should handle custom registry URL", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myuser/nginx");
});
it("should handle custom registry URL with imagePrefix", () => {
const registry = createMockRegistry({
username: "myuser",
imagePrefix: "myorg",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "nginx");
expect(result).toBe("ghcr.io/myorg/nginx");
});
it("should handle custom registry URL with username already present", () => {
const registry = createMockRegistry({
username: "myuser",
registryUrl: "ghcr.io",
});
const result = getRegistryTag(registry, "myuser/myprivaterepo");
// Should not duplicate username
expect(result).toBe("ghcr.io/myuser/myprivaterepo");
});
});
describe("edge cases", () => {
it("should handle empty image name", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "");
expect(result).toBe("docker.io/myuser/");
});
it("should handle image name with multiple slashes", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/suborg/repo");
expect(result).toBe("docker.io/myuser/repo");
});
it("should handle image name with username at different position", () => {
const registry = createMockRegistry({ username: "myuser" });
const result = getRegistryTag(registry, "org/myuser/repo");
expect(result).toBe("docker.io/myuser/repo");
});
});
});
+2
View File
@@ -28,6 +28,7 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.2.2",
applicationId: "",
previewLabels: [],
createEnvFile: true,
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
@@ -67,6 +68,7 @@ const baseApp: ApplicationNested = {
previewWildcard: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
@@ -54,4 +54,22 @@ describe("processLogs", () => {
const result = parseRawConfig(entryWithWhitespace);
expect(result.data).toHaveLength(2);
});
it("should filter out Dokploy dashboard requests", () => {
const dokployDashboardEntry = `{"ClientAddr":"172.71.187.131:9485","ClientHost":"172.71.187.131","ClientPort":"9485","ClientUsername":"-","DownstreamContentSize":14550,"DownstreamStatus":200,"Duration":57681682,"OriginContentSize":14550,"OriginDuration":57612242,"OriginStatus":200,"Overhead":69440,"RequestAddr":"hostinger.dokploy.com","RequestContentSize":0,"RequestCount":20142,"RequestHost":"hostinger.dokploy.com","RequestMethod":"GET","RequestPath":"/_next/data/cb_zzI4Rp9G7Q7djrFKh0/en/dashboard/traefik.json","RequestPort":"-","RequestProtocol":"HTTP/2.0","RequestScheme":"https","RetryAttempts":0,"RouterName":"dokploy-router-app-secure@file","ServiceAddr":"dokploy:3000","ServiceName":"dokploy-service-app@file","ServiceURL":"http://dokploy:3000","StartLocal":"2025-12-10T05:10:41.957755949Z","StartUTC":"2025-12-10T05:10:41.957755949Z","TLSCipher":"TLS_AES_128_GCM_SHA256","TLSVersion":"1.3","entryPointName":"websecure","level":"info","msg":"","time":"2025-12-10T05:10:42Z"}`;
// Test with only Dokploy dashboard entry - should be filtered out
const resultOnlyDokploy = parseRawConfig(dokployDashboardEntry);
expect(resultOnlyDokploy.data).toHaveLength(0);
expect(resultOnlyDokploy.totalCount).toBe(0);
// Test with mixed entries - Dokploy should be filtered, others should remain
const mixedEntries = `${dokployDashboardEntry}\n${sampleLogEntry}`;
const resultMixed = parseRawConfig(mixedEntries);
expect(resultMixed.data).toHaveLength(1);
expect(resultMixed.totalCount).toBe(1);
expect(resultMixed.data[0]?.ServiceName).not.toBe(
"dokploy-service-app@file",
);
});
});
@@ -7,6 +7,7 @@ const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
previewLabels: [],
createEnvFile: true,
herokuVersion: "",
giteaRepository: "",
giteaOwner: "",
@@ -49,6 +50,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
environment: {
env: "",
isDefault: false,
environmentId: "",
name: "",
createdAt: "",
@@ -221,6 +221,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
@@ -519,6 +521,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
to make your traefik.me domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
@@ -5,14 +5,23 @@ import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Form } from "@/components/ui/form";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Secrets } from "@/components/ui/secrets";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const addEnvironmentSchema = z.object({
env: z.string(),
buildArgs: z.string(),
buildSecrets: z.string(),
createEnvFile: z.boolean(),
});
type EnvironmentSchema = z.infer<typeof addEnvironmentSchema>;
@@ -39,6 +48,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: "",
buildArgs: "",
buildSecrets: "",
createEnvFile: true,
},
resolver: zodResolver(addEnvironmentSchema),
});
@@ -47,10 +57,12 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentEnv = form.watch("env");
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "");
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
useEffect(() => {
if (data) {
@@ -58,6 +70,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data.env || "",
buildArgs: data.buildArgs || "",
buildSecrets: data.buildSecrets || "",
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
@@ -67,6 +80,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: formData.env,
buildArgs: formData.buildArgs,
buildSecrets: formData.buildSecrets,
createEnvFile: formData.createEnvFile,
applicationId,
})
.then(async () => {
@@ -83,6 +97,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
env: data?.env || "",
buildArgs: data?.buildArgs || "",
buildSecrets: data?.buildSecrets || "",
createEnvFile: data?.createEnvFile ?? true,
});
};
@@ -167,6 +182,30 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
placeholder="NPM_TOKEN=xyz"
/>
)}
{data?.buildType === "dockerfile" && (
<FormField
control={form.control}
name="createEnvFile"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Create Environment File</FormLabel>
<FormDescription>
When enabled, an .env file will be created during the
build process. Disable this if you don't want to generate
an environment file.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
<div className="flex flex-row justify-end gap-2">
{hasChanges && (
<Button type="button" variant="outline" onClick={handleCancel}>
@@ -86,6 +86,9 @@ export const AddPreviewDomain = ({
resolver: zodResolver(domain),
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
useEffect(() => {
if (data) {
form.reset({
@@ -157,6 +160,13 @@ export const AddPreviewDomain = ({
name="host"
render={({ field }) => (
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -100,6 +101,8 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
});
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -168,6 +171,13 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
@@ -102,7 +102,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to create environment");
toast.error(
`Failed to create environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -123,7 +125,9 @@ export const AdvancedEnvironmentSelector = ({
setName("");
setDescription("");
} catch (error) {
toast.error("Failed to update environment");
toast.error(
`Failed to update environment: ${error instanceof Error ? error.message : error}`,
);
}
};
@@ -140,15 +144,18 @@ export const AdvancedEnvironmentSelector = ({
setIsDeleteDialogOpen(false);
setSelectedEnvironment(null);
// Redirect to production if we deleted the current environment
// Redirect to first available environment if we deleted the current environment
if (selectedEnvironment.environmentId === currentEnvironmentId) {
const productionEnv = environments?.find(
(env) => env.name === "production",
const firstEnv = environments?.find(
(env) => env.environmentId !== selectedEnvironment.environmentId,
);
if (productionEnv) {
if (firstEnv) {
router.push(
`/dashboard/project/${projectId}/environment/${productionEnv.environmentId}`,
`/dashboard/project/${projectId}/environment/${firstEnv.environmentId}`,
);
} else {
// No other environments, redirect to project page
router.push(`/dashboard/project/${projectId}`);
}
}
} catch (error) {
@@ -239,8 +246,8 @@ export const AdvancedEnvironmentSelector = ({
)}
</div>
</DropdownMenuItem>
{environment.name !== "production" && (
<div className="flex items-center gap-1 px-2">
<div className="flex items-center gap-1 px-2">
{!environment.isDefault && (
<Button
variant="ghost"
size="sm"
@@ -252,22 +259,21 @@ export const AdvancedEnvironmentSelector = ({
>
<PencilIcon className="h-3 w-3" />
</Button>
{canDeleteEnvironments && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
)}
)}
{canDeleteEnvironments && !environment.isDefault && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
onClick={(e) => {
e.stopPropagation();
openDeleteDialog(environment);
}}
>
<TrashIcon className="h-3 w-3" />
</Button>
)}
</div>
</div>
);
})}
@@ -89,24 +89,26 @@ export const SearchCommand = () => {
<CommandGroup heading={"Projects"}>
<CommandList>
{data?.map((project) => {
const productionEnvironment = project.environments.find(
(environment) => environment.name === "production",
);
// Find default environment, or fall back to first environment
const defaultEnvironment =
project.environments.find(
(environment) => environment.isDefault,
) || project?.environments?.[0];
if (!productionEnvironment) return null;
if (!defaultEnvironment) return null;
return (
<CommandItem
key={project.projectId}
onSelect={() => {
router.push(
`/dashboard/project/${project.projectId}/environment/${productionEnvironment!.environmentId}`,
`/dashboard/project/${project.projectId}/environment/${defaultEnvironment.environmentId}`,
);
setOpen(false);
}}
>
<BookIcon className="size-4 text-muted-foreground mr-2" />
{project.name} / {productionEnvironment!.name}
{project.name} / {defaultEnvironment.name}
</CommandItem>
);
})}
@@ -45,7 +45,34 @@ const AddRegistrySchema = z.object({
password: z.string().min(1, {
message: "Password is required",
}),
registryUrl: z.string(),
registryUrl: z
.string()
.optional()
.refine(
(val) => {
// If empty or undefined, skip validation (field is optional)
if (!val || val.trim().length === 0) {
return true;
}
// Validate that it's a valid hostname (no protocol, no path, optional port)
// Valid formats: example.com, registry.example.com, [::1], example.com:5000
// Invalid: https://example.com, example.com/path
const trimmed = val.trim();
// Check for protocol or path - these are not allowed
if (/^https?:\/\//i.test(trimmed) || trimmed.includes("/")) {
return false;
}
// Basic hostname validation: allow alphanumeric, dots, hyphens, underscores, and IPv6 in brackets
// Allow optional port at the end
const hostnameRegex =
/^(?:\[[^\]]+\]|[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?)(?::\d+)?$/;
return hostnameRegex.test(trimmed);
},
{
message:
"Invalid registry URL. Please enter only the hostname (e.g., example.com or registry.example.com). Do not include protocol (https://) or paths.",
},
),
imagePrefix: z.string(),
serverId: z.string().optional(),
});
@@ -99,6 +126,9 @@ export const HandleRegistry = ({ registryId }: Props) => {
const registryName = form.watch("registryName");
const imagePrefix = form.watch("imagePrefix");
const serverId = form.watch("serverId");
const selectedServer = servers?.find(
(server) => server.serverId === serverId,
);
useEffect(() => {
if (registry) {
@@ -125,7 +155,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
password: data.password,
registryName: data.registryName,
username: data.username,
registryUrl: data.registryUrl,
registryUrl: data.registryUrl || "",
registryType: "cloud",
imagePrefix: data.imagePrefix,
serverId: data.serverId,
@@ -261,6 +291,10 @@ export const HandleRegistry = ({ registryId }: Props) => {
render={({ field }) => (
<FormItem>
<FormLabel>Registry URL</FormLabel>
<FormDescription>
Enter only the hostname (e.g.,
aws_account_id.dkr.ecr.us-west-2.amazonaws.com).
</FormDescription>
<FormControl>
<Input
placeholder="aws_account_id.dkr.ecr.us-west-2.amazonaws.com"
@@ -282,8 +316,40 @@ export const HandleRegistry = ({ registryId }: Props) => {
<FormItem>
<FormLabel>Server {!isCloud && "(Optional)"}</FormLabel>
<FormDescription>
Select a server to test the registry. this will run the
following command on the server
{!isCloud ? (
<>
{serverId && serverId !== "none" && selectedServer ? (
<>
Authentication will be performed on{" "}
<strong>{selectedServer.name}</strong>. This
registry will be available on this server.
</>
) : (
<>
Choose where to authenticate with the registry. By
default, authentication occurs on the Dokploy
server. Select a specific server to authenticate
from that server instead.
</>
)}
</>
) : (
<>
{serverId && serverId !== "none" && selectedServer ? (
<>
Authentication will be performed on{" "}
<strong>{selectedServer.name}</strong>. This
registry will be available on this server.
</>
) : (
<>
Select a server to authenticate with the registry.
The authentication will be performed from the
selected server.
</>
)}
</>
)}
</FormDescription>
<FormControl>
<Select
@@ -345,7 +411,7 @@ export const HandleRegistry = ({ registryId }: Props) => {
await testRegistry({
username: username,
password: password,
registryUrl: registryUrl,
registryUrl: registryUrl || "",
registryName: registryName,
registryType: "cloud",
imagePrefix: imagePrefix,
@@ -369,6 +369,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
webhookUrl: notification.lark?.webhookUrl,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
volumeBackup: notification.volumeBackup,
serverThreshold: notification.serverThreshold,
});
} else if (notification.notificationType === "custom") {
@@ -388,6 +389,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
)
: [],
name: notification.name,
volumeBackup: notification.volumeBackup,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
});
@@ -522,6 +524,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
webhookUrl: data.webhookUrl,
name: data.name,
dockerCleanup: dockerCleanup,
@@ -547,6 +550,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
appDeploy: appDeploy,
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
volumeBackup: volumeBackup,
endpoint: data.endpoint,
headers: headersRecord,
name: data.name,
@@ -1,6 +1,6 @@
import { api } from "@/utils/api";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { ChatwootWidget } from "../shared/ChatwootWidget";
import { HubSpotWidget } from "../shared/HubSpotWidget";
import Page from "./side";
interface Props {
@@ -25,7 +25,9 @@ export const DashboardLayout = ({ children }: Props) => {
<>
<Page>{children}</Page>
{isCloud === true && isUserSubscribed === true && (
<ChatwootWidget websiteToken="USCpQRKzHvFMssf3p6Eacae5" />
<>
<HubSpotWidget />
</>
)}
{haveRootAccess === true && <ImpersonationBar />}
@@ -0,0 +1,14 @@
import Script from "next/script";
export const HubSpotWidget = () => {
return (
<Script
id="hs-script-loader"
type="text/javascript"
src="//js-eu1.hs-scripts.com/147033433.js"
strategy="lazyOnload"
async
defer
/>
);
};
@@ -0,0 +1 @@
ALTER TABLE "application" ADD COLUMN "createEnvFile" boolean DEFAULT true NOT NULL;
@@ -0,0 +1,4 @@
ALTER TABLE "environment" ADD COLUMN "isDefault" boolean DEFAULT false NOT NULL;
-- Set isDefault to true for existing production environments
UPDATE "environment" SET "isDefault" = true WHERE "name" = 'production';
+8 -7
View File
@@ -1,5 +1,5 @@
{
"id": "e27d6701-2676-4045-983d-43047a868000",
"id": "fcbc5f29-78e8-4df5-9547-f571ee121334",
"prevId": "cff546ae-01ea-40d4-b32d-0512e05c3856",
"version": "7",
"dialect": "postgresql",
@@ -1349,6 +1349,13 @@
"primaryKey": false,
"notNull": false
},
"createEnvFile": {
"name": "createEnvFile",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
@@ -2722,12 +2729,6 @@
"notNull": false,
"default": 3000
},
"customEntrypoint": {
"name": "customEntrypoint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"path": {
"name": "path",
"type": "text",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -922,8 +922,22 @@
{
"idx": 131,
"version": "7",
"when": 1765274846213,
"tag": "0131_sharp_ozymandias",
"when": 1765342621312,
"tag": "0131_volatile_beast",
"breakpoints": true
},
{
"idx": 132,
"version": "7",
"when": 1765346573500,
"tag": "0132_clean_layla_miller",
"breakpoints": true
º },
{
"idx": 133,
"version": "7",
"when": 1765556763059,
"tag": "0133_giant_korvac",
"breakpoints": true
}
]
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.26.0",
"version": "v0.26.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -13,7 +13,6 @@
"reset-password": "node -r dotenv/config dist/reset-password.mjs",
"reset-2fa": "node -r dotenv/config dist/reset-2fa.mjs",
"dev": "tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json ",
"dev-turbopack": "TURBOPACK=1 tsx -r dotenv/config ./server/server.ts --project tsconfig.server.json",
"studio": "drizzle-kit studio --config ./server/db/drizzle.config.ts",
"migration:generate": "drizzle-kit generate --config ./server/db/drizzle.config.ts",
"migration:run": "tsx -r dotenv/config migration.ts",
@@ -118,7 +117,7 @@
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
"nanoid": "3.3.11",
"next": "^16.0.7",
"next": "^16.0.10",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
+25 -32
View File
@@ -81,41 +81,34 @@ export default async function handler(
return;
}
if (!webhookImageName) {
res.status(301).json({
message: "Webhook Docker Image Name Not Found",
});
return;
}
// If webhook provides image information, validate it matches the configured image
// If webhook doesn't provide image information, fall back to using the configured image (backward compatibility)
if (webhookImageName) {
// Validate image name matches
if (webhookImageName !== applicationImageName) {
res.status(301).json({
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
});
return;
}
// Validate image name matches
if (webhookImageName !== applicationImageName) {
res.status(301).json({
message: `Application Image Name (${applicationImageName}) doesn't match request event payload Image Name (${webhookImageName}).`,
});
return;
}
if (!applicationDockerTag) {
res.status(301).json({
message: "Application Docker Tag Not Found",
});
return;
}
if (!applicationDockerTag) {
res.status(301).json({
message: "Application Docker Tag Not Found",
});
return;
}
if (!webhookDockerTag) {
res.status(301).json({
message: "Webhook Docker Tag Not Found",
});
return;
}
if (webhookDockerTag !== applicationDockerTag) {
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
return;
if (webhookDockerTag) {
if (webhookDockerTag !== applicationDockerTag) {
res.status(301).json({
message: `Application Image Tag (${applicationDockerTag}) doesn't match request event payload Image Tag (${webhookDockerTag}).`,
});
return;
}
}
}
// If webhook doesn't provide image info, we'll use the configured image (old behavior)
} else if (sourceType === "github") {
const normalizedCommits = req.body?.commits?.flatMap(
(commit: any) => commit.modified,
@@ -365,6 +365,7 @@ export const applicationRouter = createTRPCRouter({
env: input.env,
buildArgs: input.buildArgs,
buildSecrets: input.buildSecrets,
createEnvFile: input.createEnvFile,
});
return true;
}),
+20 -8
View File
@@ -66,10 +66,12 @@ export const environmentRouter = createTRPCRouter({
if (input.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
message:
"You cannot create a environment with the name 'production'",
});
}
// Allow users to create environments with any name, including "production"
const environment = await createEnvironment(input);
if (ctx.user.role === "member") {
@@ -206,6 +208,14 @@ export const environmentRouter = createTRPCRouter({
});
}
// Prevent deletion of the default environment
if (environment.isDefault) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot delete the default environment",
});
}
// Check environment deletion permission
await checkEnvironmentDeletionPermission(
ctx.user.id,
@@ -243,13 +253,7 @@ export const environmentRouter = createTRPCRouter({
try {
const { environmentId, ...updateData } = input;
if (updateData.name === "production") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Environment name cannot be production",
});
}
// Allow users to rename environments to any name, including "production"
if (ctx.user.role === "member") {
await checkEnvironmentAccess(
ctx.user.id,
@@ -259,6 +263,14 @@ export const environmentRouter = createTRPCRouter({
);
}
const currentEnvironment = await findEnvironmentById(environmentId);
// Prevent renaming the default environment, but allow updating env and description
if (currentEnvironment.isDefault && updateData.name !== undefined) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "You cannot rename the default environment",
});
}
if (
currentEnvironment.project.organizationId !==
ctx.session.activeOrganizationId
+15
View File
@@ -247,3 +247,18 @@
.cm-lineWrapping {
@apply font-mono;
}
/* HubSpot Widget - Force light color-scheme to prevent white background */
#hubspot-messages-iframe-container,
#hubspot-messages-iframe-container * {
background-color: transparent !important;
color-scheme: light !important;
}
#hubspot-messages-iframe-container .hs-shadow-container {
display: none !important;
}
#hubspot-conversations-iframe {
color-scheme: light !important;
}