diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 07fc42c33a..76ebeb3642 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -1,5 +1,5 @@ -import type { Domain } from "@dokploy/server"; -import { createDomainLabels } from "@dokploy/server"; +import type { Domain } from "@dokploy/server/services/domain"; +import { createDomainLabels } from "@dokploy/server/utils/docker/domain"; import { describe, expect, it } from "vitest"; import { parse, stringify } from "yaml"; @@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => { customEntrypoint: null, middlewares: null, forwardAuthEnabled: false, + externalUpstreamId: null, }; describe("Host rule format validation", () => { diff --git a/apps/dokploy/__test__/compose/domain/labels.test.ts b/apps/dokploy/__test__/compose/domain/labels.test.ts index e48b54452e..ef9073da57 100644 --- a/apps/dokploy/__test__/compose/domain/labels.test.ts +++ b/apps/dokploy/__test__/compose/domain/labels.test.ts @@ -1,5 +1,5 @@ -import type { Domain } from "@dokploy/server"; -import { createDomainLabels } from "@dokploy/server"; +import type { Domain } from "@dokploy/server/services/domain"; +import { createDomainLabels } from "@dokploy/server/utils/docker/domain"; import { describe, expect, it } from "vitest"; describe("createDomainLabels", () => { @@ -24,6 +24,7 @@ describe("createDomainLabels", () => { stripPath: false, middlewares: null, forwardAuthEnabled: false, + externalUpstreamId: null, }; it("should create basic labels for web entrypoint", async () => { diff --git a/apps/dokploy/__test__/external-upstream/validation.test.ts b/apps/dokploy/__test__/external-upstream/validation.test.ts new file mode 100644 index 0000000000..c521348e75 --- /dev/null +++ b/apps/dokploy/__test__/external-upstream/validation.test.ts @@ -0,0 +1,72 @@ +import { + DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS, + normalizeBlockedCidrs, + validateBlockedCidrs, + validateExternalUpstreamTargetUrl, +} from "@dokploy/server/utils/network/external-upstream"; +import { describe, expect, test } from "vitest"; + +describe("external upstream target validation", () => { + test("normalizes blocked CIDR input", () => { + expect( + normalizeBlockedCidrs([ + "", + " 127.0.0.0/8 ", + "127.0.0.0/8", + " ", + "10.0.0.0/8", + ]), + ).toEqual(["127.0.0.0/8", "10.0.0.0/8"]); + }); + + test("rejects invalid blocked CIDR values", () => { + expect(() => validateBlockedCidrs(["nope"])).toThrow( + "Invalid blocked address or CIDR: nope", + ); + }); + + test("rejects non-http protocols", async () => { + await expect( + validateExternalUpstreamTargetUrl({ + targetUrl: "tcp://1.1.1.1:8080", + blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS, + }), + ).rejects.toThrow("Target URL must use http:// or https://"); + }); + + test("rejects credentials in target URLs", async () => { + await expect( + validateExternalUpstreamTargetUrl({ + targetUrl: "https://user:pass@example.com", + blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS, + }), + ).rejects.toThrow("Target URL must not include credentials"); + }); + + test("rejects localhost hostnames", async () => { + await expect( + validateExternalUpstreamTargetUrl({ + targetUrl: "http://localhost:3000", + blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS, + }), + ).rejects.toThrow("Target URL points to a blocked network range"); + }); + + test("rejects blocked private IPs", async () => { + await expect( + validateExternalUpstreamTargetUrl({ + targetUrl: "http://127.0.0.1:3000", + blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS, + }), + ).rejects.toThrow("Target URL points to a blocked network range"); + }); + + test("accepts a public HTTP target", async () => { + await expect( + validateExternalUpstreamTargetUrl({ + targetUrl: "https://1.1.1.1:8443", + blockedCidrs: DEFAULT_EXTERNAL_UPSTREAM_BLOCKED_CIDRS, + }), + ).resolves.toBe("https://1.1.1.1:8443/"); + }); +}); diff --git a/apps/dokploy/__test__/traefik/forward-auth.test.ts b/apps/dokploy/__test__/traefik/forward-auth.test.ts index 9bc9a1f9ed..fe1161e8c8 100644 --- a/apps/dokploy/__test__/traefik/forward-auth.test.ts +++ b/apps/dokploy/__test__/traefik/forward-auth.test.ts @@ -1,12 +1,13 @@ -import type { ApplicationNested, Domain } from "@dokploy/server"; import { buildForwardAuthEnv, - createRouterConfig, deriveBaseDomain, deriveCookieSecret, forwardAuthCallbackUrl, - forwardAuthMiddlewareName, -} from "@dokploy/server"; +} from "@dokploy/server/setup/forward-auth-setup"; +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import type { Domain } from "@dokploy/server/services/domain"; +import { createRouterConfig as createTraefikRouterConfig } from "@dokploy/server/utils/traefik/domain"; +import { forwardAuthMiddlewareName } from "@dokploy/server/utils/traefik/forward-auth"; import { beforeAll, describe, expect, test } from "vitest"; const app = { @@ -35,6 +36,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + externalUpstreamId: null, }; describe("forwardAuthMiddlewareName", () => { @@ -53,7 +55,7 @@ describe("forwardAuthMiddlewareName", () => { describe("createRouterConfig forward-auth wiring", () => { test("does NOT add forward-auth middleware when no provider is linked", async () => { - const config = await createRouterConfig(app, baseDomain, "websecure"); + const config = await createTraefikRouterConfig(app, baseDomain, "websecure"); expect(config.middlewares).not.toContain( forwardAuthMiddlewareName("my-app", 7), ); @@ -64,7 +66,7 @@ describe("createRouterConfig forward-auth wiring", () => { ...baseDomain, forwardAuthEnabled: true, }; - const config = await createRouterConfig(app, domain, "websecure"); + const config = await createTraefikRouterConfig(app, domain, "websecure"); expect(config.middlewares).toContain( forwardAuthMiddlewareName("my-app", 7), ); @@ -76,7 +78,7 @@ describe("createRouterConfig forward-auth wiring", () => { forwardAuthEnabled: true, middlewares: ["rate-limit@file"], }; - const config = await createRouterConfig(app, domain, "websecure"); + const config = await createTraefikRouterConfig(app, domain, "websecure"); const forwardAuthIdx = config.middlewares?.indexOf( forwardAuthMiddlewareName("my-app", 7), ); @@ -91,7 +93,7 @@ describe("createRouterConfig forward-auth wiring", () => { https: true, forwardAuthEnabled: true, }; - const config = await createRouterConfig(app, domain, "web"); + const config = await createTraefikRouterConfig(app, domain, "web"); expect(config.middlewares).toContain("redirect-to-https"); expect(config.middlewares).not.toContain( forwardAuthMiddlewareName("my-app", 7), diff --git a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts index ba09c2c80a..1b06ebb8bb 100644 --- a/apps/dokploy/__test__/traefik/server/update-server-config.test.ts +++ b/apps/dokploy/__test__/traefik/server/update-server-config.test.ts @@ -5,13 +5,13 @@ vi.mock("node:fs", () => ({ default: fs, })); -import type { FileConfig } from "@dokploy/server"; import { createDefaultServerTraefikConfig, - loadOrCreateConfig, - updateServerTraefik, -} from "@dokploy/server"; +} from "@dokploy/server/setup/traefik-setup"; import type { webServerSettings } from "@dokploy/server/db/schema"; +import type { FileConfig } from "@dokploy/server/utils/traefik/file-types"; +import { loadOrCreateConfig as loadTraefikConfig } from "@dokploy/server/utils/traefik/application"; +import { updateServerTraefik as updateWebServerTraefik } from "@dokploy/server/utils/traefik/web-server"; import { beforeEach, expect, test, vi } from "vitest"; type WebServerSettings = typeof webServerSettings.$inferSelect; @@ -66,6 +66,8 @@ const baseSettings: WebServerSettings = { cleanupCacheApplications: false, cleanupCacheOnCompose: false, cleanupCacheOnPreviews: false, + externalUpstreamsEnabled: false, + externalUpstreamBlockedCidrs: [], remoteServersOnly: false, enforceSSO: false, createdAt: null, @@ -78,14 +80,14 @@ beforeEach(() => { }); test("Should read the configuration file", () => { - const config: FileConfig = loadOrCreateConfig("dokploy"); + const config: FileConfig = loadTraefikConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app"]?.service).toBe( "dokploy-service-app", ); }); test("Should apply redirect-to-https", () => { - updateServerTraefik( + updateWebServerTraefik( { ...baseSettings, https: true, @@ -94,7 +96,7 @@ test("Should apply redirect-to-https", () => { "example.com", ); - const config: FileConfig = loadOrCreateConfig("dokploy"); + const config: FileConfig = loadTraefikConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app"]?.middlewares).toContain( "redirect-to-https", @@ -102,35 +104,35 @@ test("Should apply redirect-to-https", () => { }); test("Should change only host when no certificate", () => { - updateServerTraefik(baseSettings, "example.com"); + updateWebServerTraefik(baseSettings, "example.com"); - const config: FileConfig = loadOrCreateConfig("dokploy"); + const config: FileConfig = loadTraefikConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined(); }); test("Should not touch config without host", () => { - const originalConfig: FileConfig = loadOrCreateConfig("dokploy"); + const originalConfig: FileConfig = loadTraefikConfig("dokploy"); - updateServerTraefik(baseSettings, null); + updateWebServerTraefik(baseSettings, null); - const config: FileConfig = loadOrCreateConfig("dokploy"); + const config: FileConfig = loadTraefikConfig("dokploy"); expect(originalConfig).toEqual(config); }); test("Should remove websecure if https rollback to http", () => { - updateServerTraefik( + updateWebServerTraefik( { ...baseSettings, certificateType: "letsencrypt" }, "example.com", ); - updateServerTraefik( + updateWebServerTraefik( { ...baseSettings, certificateType: "none" }, "example.com", ); - const config: FileConfig = loadOrCreateConfig("dokploy"); + const config: FileConfig = loadTraefikConfig("dokploy"); expect(config.http?.routers?.["dokploy-router-app-secure"]).toBeUndefined(); expect( diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index 68758ca2d9..43ee97cfbe 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -1,5 +1,7 @@ -import type { ApplicationNested, Domain, Redirect } from "@dokploy/server"; -import { createRouterConfig } from "@dokploy/server"; +import type { ApplicationNested } from "@dokploy/server/utils/builders"; +import type { Domain } from "@dokploy/server/services/domain"; +import type { Redirect } from "@dokploy/server/services/redirect"; +import { createRouterConfig } from "@dokploy/server/utils/traefik/domain"; import { expect, test } from "vitest"; const baseApp: ApplicationNested = { @@ -149,6 +151,7 @@ const baseDomain: Domain = { stripPath: false, middlewares: null, forwardAuthEnabled: false, + externalUpstreamId: null, }; const baseRedirect: Redirect = { diff --git a/apps/dokploy/components/dashboard/application/domains/columns.tsx b/apps/dokploy/components/dashboard/application/domains/columns.tsx index b88443dcc7..0e00fa79b7 100644 --- a/apps/dokploy/components/dashboard/application/domains/columns.tsx +++ b/apps/dokploy/components/dashboard/application/domains/columns.tsx @@ -27,11 +27,12 @@ import type { ValidationStates } from "./show-domains"; export type Domain = | RouterOutputs["domain"]["byApplicationId"][0] - | RouterOutputs["domain"]["byComposeId"][0]; + | RouterOutputs["domain"]["byComposeId"][0] + | RouterOutputs["domain"]["byExternalUpstreamId"][0]; interface ColumnsProps { id: string; - type: "application" | "compose"; + type: "application" | "compose" | "externalUpstream"; validationStates: ValidationStates; handleValidateDomain: (host: string) => Promise; handleDeleteDomain: (domainId: string) => Promise; @@ -52,6 +53,53 @@ export const createColumns = ({ canCreateDomain, canDeleteDomain, }: ColumnsProps): ColumnDef[] => [ + ...(type === "externalUpstream" + ? [ + { + accessorKey: "internalPath", + header: "Upstream Prefix", + cell: ({ row }) => { + const internalPath = row.getValue("internalPath") as string | null + if (!internalPath) { + return - + } + return
{internalPath}
+ }, + } satisfies ColumnDef, + { + accessorKey: "stripPath", + header: "Strip Public Path", + cell: ({ row }) => { + const stripPath = row.getValue("stripPath") as boolean | null + if (!stripPath) { + return No + } + return Yes + }, + } satisfies ColumnDef, + ] + : [ + { + accessorKey: "port", + header: ({ column }) => { + return ( + + ) + }, + cell: ({ row }) => { + const port = row.getValue("port") as number + return {port} + }, + } satisfies ColumnDef, + ]), ...(type === "compose" ? [ { @@ -105,7 +153,7 @@ export const createColumns = ({ variant="ghost" onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} > - Path + {type === "externalUpstream" ? "Public Path" : "Path"} ); @@ -115,24 +163,6 @@ export const createColumns = ({ return
{path || "/"}
; }, }, - { - accessorKey: "port", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const port = row.getValue("port") as number; - return {port}; - }, - }, { accessorKey: "customEntrypoint", header: "Entrypoint", diff --git a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx index be8901921f..a9d5804231 100644 --- a/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx +++ b/apps/dokploy/components/dashboard/application/domains/handle-domain.tsx @@ -75,7 +75,9 @@ export const domain = z certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), customCertResolver: z.string().optional(), serviceName: z.string().optional(), - domainType: z.enum(["application", "compose", "preview"]).optional(), + domainType: z + .enum(["application", "compose", "preview", "externalUpstream"]) + .optional(), middlewares: z.array(z.string()).optional(), }) .superRefine((input, ctx) => { @@ -139,7 +141,7 @@ type Domain = z.infer; interface Props { id: string; - type: "application" | "compose"; + type: "application" | "compose" | "externalUpstream"; domainId?: string; children: React.ReactNode; } @@ -148,6 +150,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const [isOpen, setIsOpen] = useState(false); const [cacheType, setCacheType] = useState("cache"); const [isManualInput, setIsManualInput] = useState(false); + const isExternalUpstream = type === "externalUpstream"; const utils = api.useUtils(); const { data, refetch } = api.domain.one.useQuery( @@ -159,7 +162,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { }, ); - const { data: application } = + const { data: serviceData } = type === "application" ? api.application.one.useQuery( { @@ -169,14 +172,23 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { enabled: !!id, }, ) - : api.compose.one.useQuery( - { - composeId: id, - }, - { - enabled: !!id, - }, - ); + : type === "compose" + ? api.compose.one.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ) + : api.externalUpstream.one.useQuery( + { + externalUpstreamId: id, + }, + { + enabled: !!id, + }, + ); const { mutateAsync, isError, error, isPending } = domainId ? api.domain.update.useMutation() @@ -187,7 +199,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { const { data: canGenerateTraefikMeDomains } = api.domain.canGenerateTraefikMeDomains.useQuery({ - serverId: application?.serverId || "", + serverId: serviceData?.serverId || "", }); const { @@ -296,7 +308,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { ...(data.domainType === "compose" && { composeId: id, }), + ...(data.domainType === "externalUpstream" && { + externalUpstreamId: id, + }), ...data, + ...(isExternalUpstream && { + port: undefined, + }), customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null, }) .then(async () => { @@ -313,6 +331,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { await utils.domain.byComposeId.invalidate({ composeId: id, }); + } else if (data.domainType === "externalUpstream") { + await utils.domain.byExternalUpstreamId.invalidate({ + externalUpstreamId: id, + }); + await utils.externalUpstream.one.invalidate({ + externalUpstreamId: id, + }); } if (domainId) { @@ -343,6 +368,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { compose to apply the changes. )} + {isExternalUpstream && ( + + Protocol, host, and port come from the upstream target URL. Domain + settings here only control the public host/path, TLS, and + middleware behavior. + + )}
{ href="/dashboard/settings/server" className="text-primary" > - {application?.serverId + {serviceData?.serverId ? "Remote Servers -> Server -> Edit Server -> Update IP Address" : "Web Server -> Server -> Update Server IP"} {" "} @@ -552,8 +584,8 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { isLoading={isLoadingGenerate} onClick={() => { generateDomain({ - appName: application?.appName || "", - serverId: application?.serverId || "", + appName: serviceData?.appName || "", + serverId: serviceData?.serverId || "", }) .then((domain) => { field.onChange(domain); @@ -588,10 +620,18 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { render={({ field }) => { return ( - Path + + {isExternalUpstream ? "Public Path" : "Path"} + + {isExternalUpstream && ( + + The path exposed publicly on this domain before the + request is proxied to the upstream target. + + )} ); @@ -604,10 +644,15 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { render={({ field }) => { return ( - Internal Path + + {isExternalUpstream + ? "Upstream Path Prefix" + : "Internal Path"} + - The path where your application expects to receive - requests internally (defaults to "/") + {isExternalUpstream + ? 'An optional path prefix added before forwarding the request to the upstream target (defaults to "/").' + : 'The path where your application expects to receive requests internally (defaults to "/")'} @@ -626,8 +671,9 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
Strip Path - Remove the external path from the request before - forwarding to the application + {isExternalUpstream + ? "Remove the public path from the request before forwarding it to the upstream target" + : "Remove the external path from the request before forwarding to the application"}
@@ -641,26 +687,28 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => { )} /> - { - return ( - - Container Port - - The port where your application is running inside the - container (e.g., 3000 for Node.js, 80 for Nginx, 8080 - for Java) - - - - - - - ); - }} - /> + {!isExternalUpstream && ( + { + return ( + + Container Port + + The port where your application is running inside + the container (e.g., 3000 for Node.js, 80 for + Nginx, 8080 for Java) + + + + + + + ); + }} + /> + )} ; interface Props { id: string; - type: "application" | "compose"; + type: "application" | "compose" | "externalUpstream"; } export const ShowDomains = ({ id, type }: Props) => { + const isExternalUpstream = type === "externalUpstream" const { data: permissions } = api.user.getPermissions.useQuery(); const canCreateDomain = permissions?.domain.create ?? false; const canDeleteDomain = permissions?.domain.delete ?? false; - const { data: application } = + const { data: serviceData } = type === "application" ? api.application.one.useQuery( { @@ -94,14 +95,23 @@ export const ShowDomains = ({ id, type }: Props) => { enabled: !!id, }, ) - : api.compose.one.useQuery( - { - composeId: id, - }, - { - enabled: !!id, - }, - ); + : type === "compose" + ? api.compose.one.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ) + : api.externalUpstream.one.useQuery( + { + externalUpstreamId: id, + }, + { + enabled: !!id, + }, + ); const [validationStates, setValidationStates] = useState( {}, ); @@ -133,14 +143,23 @@ export const ShowDomains = ({ id, type }: Props) => { enabled: !!id, }, ) - : api.domain.byComposeId.useQuery( - { - composeId: id, - }, - { - enabled: !!id, - }, - ); + : type === "compose" + ? api.domain.byComposeId.useQuery( + { + composeId: id, + }, + { + enabled: !!id, + }, + ) + : api.domain.byExternalUpstreamId.useQuery( + { + externalUpstreamId: id, + }, + { + enabled: !!id, + }, + ); const { mutateAsync: validateDomain } = api.domain.validateDomain.useMutation(); @@ -167,7 +186,7 @@ export const ShowDomains = ({ id, type }: Props) => { const result = await validateDomain({ domain: host, serverIp: - application?.server?.ipAddress?.toString() || ip?.toString() || "", + serviceData?.server?.ipAddress?.toString() || ip?.toString() || "", }); setValidationStates((prev) => ({ @@ -201,7 +220,7 @@ export const ShowDomains = ({ id, type }: Props) => { handleValidateDomain, handleDeleteDomain, isDeleting: isRemoving, - serverIp: application?.server?.ipAddress?.toString() || ip?.toString(), + serverIp: serviceData?.server?.ipAddress?.toString() || ip?.toString(), canCreateDomain, canDeleteDomain, }); @@ -232,7 +251,9 @@ export const ShowDomains = ({ id, type }: Props) => {
Domains - Domains are used to access to the application + {isExternalUpstream + ? "Domains define the public entrypoint for this upstream target" + : "Domains are used to access to the application"}
@@ -277,8 +298,9 @@ export const ShowDomains = ({ id, type }: Props) => {
- To access the application it is required to set at least 1 - domain + {isExternalUpstream + ? "To expose this upstream publicly, configure at least one domain" + : "To access the application it is required to set at least 1 domain"} {canCreateDomain && (
@@ -434,7 +456,7 @@ export const ShowDomains = ({ id, type }: Props) => { path: item.path || undefined, }} serverIp={ - application?.server?.ipAddress?.toString() || + serviceData?.server?.ipAddress?.toString() || ip?.toString() } /> @@ -510,28 +532,75 @@ export const ShowDomains = ({ id, type }: Props) => { - Path: {item.path || "/"} + {isExternalUpstream + ? "Public Path" + : "Path"} + : {item.path || "/"} -

URL path for this service

+

+ {isExternalUpstream + ? "Public URL path exposed on this domain" + : "URL path for this service"} +

- - - - - - Port: {item.port} - - - -

Container port exposed

-
-
-
+ {!isExternalUpstream && ( + + + + + + Port: {item.port} + + + +

Container port exposed

+
+
+
+ )} + + {isExternalUpstream && item.internalPath && ( + + + + + + Upstream Prefix: {item.internalPath} + + + +

+ Path prefix added before proxying to the + upstream target +

+
+
+
+ )} + + {isExternalUpstream && !!item.stripPath && ( + + + + + + Strip Public Path + + + +

+ Remove the public path before forwarding to + the upstream target +

+
+
+
+ )} diff --git a/apps/dokploy/components/dashboard/compose/delete-service.tsx b/apps/dokploy/components/dashboard/compose/delete-service.tsx index 35fe01ff9f..9367653518 100644 --- a/apps/dokploy/components/dashboard/compose/delete-service.tsx +++ b/apps/dokploy/components/dashboard/compose/delete-service.tsx @@ -42,7 +42,7 @@ type DeleteCompose = z.infer; interface Props { id: string; - type: ServiceType | "application"; + type: ServiceType | "application" | "external-upstream"; } export const DeleteService = ({ id, type }: Props) => { @@ -63,6 +63,11 @@ export const DeleteService = ({ id, type }: Props) => { mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }), compose: () => api.compose.one.useQuery({ composeId: id }, { enabled: !!id }), + "external-upstream": () => + api.externalUpstream.one.useQuery( + { externalUpstreamId: id }, + { enabled: !!id }, + ), }; const { data } = queryMap[type] ? queryMap[type]() @@ -77,6 +82,7 @@ export const DeleteService = ({ id, type }: Props) => { application: () => api.application.delete.useMutation(), mongo: () => api.mongo.remove.useMutation(), compose: () => api.compose.delete.useMutation(), + "external-upstream": () => api.externalUpstream.delete.useMutation(), }; const { mutateAsync, isPending } = mutationMap[type] ? mutationMap[type]() @@ -103,6 +109,7 @@ export const DeleteService = ({ id, type }: Props) => { libsqlId: id || "", applicationId: id || "", composeId: id || "", + externalUpstreamId: id || "", deleteVolumes, }) .then((result) => { diff --git a/apps/dokploy/components/dashboard/external-upstream/update-external-upstream.tsx b/apps/dokploy/components/dashboard/external-upstream/update-external-upstream.tsx new file mode 100644 index 0000000000..fb6b3e5c02 --- /dev/null +++ b/apps/dokploy/components/dashboard/external-upstream/update-external-upstream.tsx @@ -0,0 +1,186 @@ +import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema"; +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 { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import { api } from "@/utils/api"; + +const updateExternalUpstreamSchema = z.object({ + name: z.string().min(1, { message: "Name is required" }), + description: z.string().optional(), + targetUrl: z.string().url("Target URL must be a valid URL"), + passHostHeader: z.boolean(), +}); + +type UpdateExternalUpstreamForm = z.infer< + typeof updateExternalUpstreamSchema +>; + +interface Props { + externalUpstreamId: string; +} + +export const UpdateExternalUpstream = ({ externalUpstreamId }: Props) => { + const utils = api.useUtils(); + const [visible, setVisible] = useState(false); + const { data } = api.externalUpstream.one.useQuery( + { externalUpstreamId }, + { enabled: !!externalUpstreamId }, + ); + const { mutateAsync, isPending, error, isError } = + api.externalUpstream.update.useMutation(); + + const form = useForm({ + defaultValues: { + name: "", + description: "", + targetUrl: "http://", + passHostHeader: true, + }, + resolver: zodResolver(updateExternalUpstreamSchema), + }); + + useEffect(() => { + if (!data) { + return; + } + + form.reset({ + name: data.name, + description: data.description || "", + targetUrl: data.targetUrl, + passHostHeader: data.passHostHeader, + }); + }, [data, form]); + + const onSubmit = async (values: UpdateExternalUpstreamForm) => { + await mutateAsync({ + externalUpstreamId, + ...values, + }) + .then(async () => { + toast.success("External Upstream updated"); + setVisible(false); + await utils.externalUpstream.one.invalidate({ + externalUpstreamId, + }); + await utils.environment.one.invalidate({ + environmentId: data?.environmentId || "", + }); + }) + .catch(() => { + toast.error("Error updating the external upstream"); + }); + }; + + return ( + + + + + + + Edit + + Update the upstream target and metadata for this service + + + {isError && {error?.message}} + + + ( + + Name + + + + + + )} + /> + ( + + Target URL + + + + + + )} + /> + ( + + Description + +