mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
Merge pull request #4697 from gentslava/feat/domain-enable-disable
feat(domains): add toggle to enable/disable domains
This commit is contained in:
commit
56fdb6151d
@ -33,6 +33,7 @@ const makeDomain = (serviceName: string) =>
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
}) as any;
|
||||
|
||||
// If the returned shell fragment is safe, parse() yields only string tokens.
|
||||
|
||||
312
apps/dokploy/__test__/compose/domain/enabled-filter.test.ts
Normal file
312
apps/dokploy/__test__/compose/domain/enabled-filter.test.ts
Normal file
@ -0,0 +1,312 @@
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// addDomainToCompose reads the compose file from disk through loadDockerCompose
|
||||
// (existsSync + readFileSync). Mock node:fs so the function runs its real
|
||||
// label-generation logic against an in-memory compose spec.
|
||||
const baseComposeYaml = `
|
||||
services:
|
||||
frigate:
|
||||
image: frigate
|
||||
`;
|
||||
let composeYaml = baseComposeYaml;
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(() => true),
|
||||
readFileSync: vi.fn(() => composeYaml),
|
||||
};
|
||||
});
|
||||
|
||||
const baseCompose = {
|
||||
appName: "test-app",
|
||||
composeType: "docker-compose",
|
||||
composePath: "docker-compose.yml",
|
||||
sourceType: "raw",
|
||||
serverId: null,
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
} as unknown as Compose;
|
||||
|
||||
const baseDomain: Domain = {
|
||||
host: "frigate.example.com",
|
||||
port: 8971,
|
||||
customEntrypoint: null,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "compose-id",
|
||||
domainType: "compose",
|
||||
serviceName: "frigate",
|
||||
domainId: "domain-id",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const serviceLabels = (
|
||||
result: Awaited<ReturnType<typeof addDomainToCompose>>,
|
||||
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
|
||||
|
||||
describe("addDomainToCompose enabled filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
composeYaml = baseComposeYaml;
|
||||
});
|
||||
|
||||
it("generates traefik labels for an enabled domain", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, enabled: true },
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels).toContain("traefik.enable=true");
|
||||
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a disabled domain entirely (no traefik labels)", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels).not.toContain("traefik.enable=true");
|
||||
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
|
||||
- custom.label=preserved
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
|
||||
- custom.label=preserved
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale labels for a disabled domain from %s rebuilds",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toContain("custom.label=preserved");
|
||||
expect(
|
||||
(labels as string[]).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
legacy:
|
||||
image: frigate
|
||||
labels:
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- custom.label=preserved
|
||||
frigate:
|
||||
image: frigate
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
legacy:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- custom.label=preserved
|
||||
frigate:
|
||||
image: frigate
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale labels from the previous %s service after reassignment",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, serviceName: "frigate", enabled: false },
|
||||
]);
|
||||
|
||||
const previousService = result?.services?.legacy;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? previousService?.labels
|
||||
: previousService?.deploy?.labels;
|
||||
expect(labels).toContain("custom.label=preserved");
|
||||
expect(
|
||||
(labels as string[]).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
|
||||
custom.label: preserved
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
|
||||
custom.label: preserved
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale mapping labels for a disabled domain from %s rebuilds",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toMatchObject({ "custom.label": "preserved" });
|
||||
expect(
|
||||
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
custom.label: preserved
|
||||
`,
|
||||
"traefik.docker.network",
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
custom.label: preserved
|
||||
`,
|
||||
"traefik.swarm.network",
|
||||
],
|
||||
] as const)(
|
||||
"regenerates routing in mapping labels for an enabled domain in %s",
|
||||
async (composeType, mappingComposeYaml, networkLabel) => {
|
||||
composeYaml = mappingComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: true },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toMatchObject({
|
||||
"custom.label": "preserved",
|
||||
"traefik.enable": "true",
|
||||
[networkLabel]: "dokploy-network",
|
||||
"traefik.http.routers.test-app-1-web.rule":
|
||||
"Host(`frigate.example.com`)",
|
||||
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("emits labels only for the enabled domain when both are present", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, host: "enabled.example.com", enabled: true },
|
||||
{
|
||||
...baseDomain,
|
||||
host: "disabled.example.com",
|
||||
uniqueConfigKey: 2,
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -35,6 +35,7 @@ describe("Host rule format regression tests", () => {
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
|
||||
@ -35,6 +35,7 @@ const baseDomain: Domain = {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("forwardAuthMiddlewareName", () => {
|
||||
|
||||
@ -151,6 +151,7 @@ const baseDomain: Domain = {
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const baseRedirect: Redirect = {
|
||||
|
||||
@ -14,6 +14,7 @@ import Link from "next/link";
|
||||
import { DialogAction } from "@/components/shared/dialog-action";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@ -35,7 +36,9 @@ interface ColumnsProps {
|
||||
validationStates: ValidationStates;
|
||||
handleValidateDomain: (host: string) => Promise<void>;
|
||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||
handleToggleEnable: (domainId: string) => Promise<void>;
|
||||
isDeleting: boolean;
|
||||
isToggling: boolean;
|
||||
serverIp?: string;
|
||||
canCreateDomain: boolean;
|
||||
canDeleteDomain: boolean;
|
||||
@ -47,7 +50,9 @@ export const createColumns = ({
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
handleToggleEnable,
|
||||
isDeleting,
|
||||
isToggling,
|
||||
serverIp,
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
@ -249,6 +254,42 @@ export const createColumns = ({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const domain = row.original;
|
||||
if (!canCreateDomain) {
|
||||
return (
|
||||
<Badge variant={domain.enabled ? "outline" : "secondary"}>
|
||||
{domain.enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Switch
|
||||
checked={domain.enabled}
|
||||
onCheckedChange={() => handleToggleEnable(domain.domainId)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{domain.enabled
|
||||
? "Domain is active. Toggle to disable routing without deleting it."
|
||||
: "Domain is disabled and not routed. Toggle to enable it again."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
|
||||
@ -46,6 +46,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { api } from "@/utils/api";
|
||||
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||
|
||||
export type CacheType = "fetch" | "cache";
|
||||
|
||||
@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||
})
|
||||
.then(async () => {
|
||||
toast.success(dictionary.success);
|
||||
toast.success(
|
||||
dictionary.success,
|
||||
data.domainType === "compose"
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (data.domainType === "application") {
|
||||
await utils.domain.byApplicationId.invalidate({
|
||||
@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
||||
</DialogHeader>
|
||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||
|
||||
{type === "compose" && (
|
||||
<AlertBlock type="info" className="mb-4">
|
||||
Whenever you make changes to domains, remember to redeploy your
|
||||
compose to apply the changes.
|
||||
</AlertBlock>
|
||||
)}
|
||||
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
import { AlertBlock } from "@/components/shared/alert-block";
|
||||
|
||||
/**
|
||||
* Domains attached to a compose service are rendered as docker labels and only
|
||||
* take effect on the next deployment. These strings keep the "redeploy required"
|
||||
* wording consistent across the add/edit dialog, the domains list and the
|
||||
* toasts shown after create/update/delete/toggle operations.
|
||||
*/
|
||||
export const COMPOSE_REDEPLOY_HINT =
|
||||
"Whenever you make changes to domains, remember to redeploy your compose to apply the changes.";
|
||||
|
||||
export const COMPOSE_REDEPLOY_TOAST =
|
||||
"Redeploy the compose to apply the changes.";
|
||||
|
||||
export const ComposeRedeployAlert = ({ className }: { className?: string }) => (
|
||||
<AlertBlock type="info" className={className}>
|
||||
{COMPOSE_REDEPLOY_HINT}
|
||||
</AlertBlock>
|
||||
);
|
||||
@ -44,6 +44,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@ -58,11 +59,13 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { api } from "@/utils/api";
|
||||
import { createColumns } from "./columns";
|
||||
import { DnsHelperModal } from "./dns-helper-modal";
|
||||
import { AddDomain } from "./handle-domain";
|
||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||
|
||||
export type ValidationState = {
|
||||
isLoading: boolean;
|
||||
@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
api.domain.validateDomain.useMutation();
|
||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||
api.domain.delete.useMutation();
|
||||
const { mutateAsync: toggleEnable, isPending: isToggling } =
|
||||
api.domain.toggleEnable.useMutation();
|
||||
|
||||
const handleToggleEnable = async (domainId: string) => {
|
||||
try {
|
||||
const result = await toggleEnable({ domainId });
|
||||
refetch();
|
||||
toast.success(
|
||||
result.enabled ? "Domain enabled" : "Domain disabled",
|
||||
result.requiresRedeploy
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error updating the domain");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDomain = async (domainId: string) => {
|
||||
try {
|
||||
await deleteDomain({ domainId });
|
||||
refetch();
|
||||
toast.success("Domain deleted successfully");
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
type === "compose"
|
||||
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||
: undefined,
|
||||
);
|
||||
} catch {
|
||||
toast.error("Error deleting domain");
|
||||
}
|
||||
@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
validationStates,
|
||||
handleValidateDomain,
|
||||
handleDeleteDomain,
|
||||
handleToggleEnable,
|
||||
isDeleting: isRemoving,
|
||||
isToggling,
|
||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||
canCreateDomain,
|
||||
canDeleteDomain,
|
||||
@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
{type === "compose" && data && data.length > 0 && (
|
||||
<div className="px-6 pb-4">
|
||||
<ComposeRedeployAlert />
|
||||
</div>
|
||||
)}
|
||||
<CardContent className="flex w-full flex-row gap-4">
|
||||
{isLoadingDomains ? (
|
||||
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
||||
@ -413,7 +445,10 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
return (
|
||||
<Card
|
||||
key={item.domainId}
|
||||
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
|
||||
className={cn(
|
||||
"relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit",
|
||||
!item.enabled && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
@ -466,18 +501,7 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
description="Are you sure you want to delete this domain?"
|
||||
type="destructive"
|
||||
onClick={async () => {
|
||||
await deleteDomain({
|
||||
domainId: item.domainId,
|
||||
})
|
||||
.then((_data) => {
|
||||
refetch();
|
||||
toast.success(
|
||||
"Domain deleted successfully",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error("Error deleting domain");
|
||||
});
|
||||
await handleDeleteDomain(item.domainId);
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full break-all">
|
||||
<Link
|
||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
{item.host}
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="w-full break-all">
|
||||
<Link
|
||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||
target="_blank"
|
||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||
>
|
||||
{item.host}
|
||||
<ExternalLink className="size-4 min-w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
{canCreateDomain && (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center shrink-0">
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEnable(item.domainId)
|
||||
}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{item.enabled
|
||||
? "Domain is active. Toggle to disable routing without deleting it."
|
||||
: "Domain is disabled and not routed. Toggle to enable it again."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Domain Details */}
|
||||
|
||||
1
apps/dokploy/drizzle/0183_tearful_groot.sql
Normal file
1
apps/dokploy/drizzle/0183_tearful_groot.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE "domain" ADD COLUMN "enabled" boolean DEFAULT true NOT NULL;
|
||||
9044
apps/dokploy/drizzle/meta/0183_snapshot.json
Normal file
9044
apps/dokploy/drizzle/meta/0183_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1282,6 +1282,13 @@
|
||||
"when": 1786435879438,
|
||||
"tag": "0182_skinny_wild_pack",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 183,
|
||||
"version": "7",
|
||||
"when": 1786463845579,
|
||||
"tag": "0183_tearful_groot",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -141,6 +141,58 @@ export const domainRouter = createTRPCRouter({
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
toggleEnable: protectedProcedure
|
||||
.input(apiFindDomain)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const currentDomain = await findDomainById(input.domainId);
|
||||
const serviceId = currentDomain.applicationId || currentDomain.composeId;
|
||||
if (serviceId) {
|
||||
await checkServicePermissionAndAccess(ctx, serviceId, {
|
||||
domain: ["create"],
|
||||
});
|
||||
} else if (currentDomain.previewDeploymentId) {
|
||||
const preview = await findPreviewDeploymentById(
|
||||
currentDomain.previewDeploymentId,
|
||||
);
|
||||
await checkServicePermissionAndAccess(ctx, preview.applicationId, {
|
||||
domain: ["create"],
|
||||
});
|
||||
}
|
||||
|
||||
const result = await updateDomainById(input.domainId, {
|
||||
enabled: !currentDomain.enabled,
|
||||
});
|
||||
const domain = await findDomainById(input.domainId);
|
||||
await audit(ctx, {
|
||||
action: "update",
|
||||
resourceType: "domain",
|
||||
resourceId: domain.domainId,
|
||||
resourceName: domain.host,
|
||||
});
|
||||
|
||||
// Applications apply instantly through the traefik file provider:
|
||||
// manageDomain creates the router when enabled and removes it when
|
||||
// disabled. Compose domains are docker labels and only change on the
|
||||
// next deployment, so we just persist the flag here.
|
||||
if (domain.applicationId) {
|
||||
const application = await findApplicationById(domain.applicationId);
|
||||
await manageDomain(application, domain);
|
||||
} else if (domain.previewDeploymentId) {
|
||||
const previewDeployment = await findPreviewDeploymentById(
|
||||
domain.previewDeploymentId,
|
||||
);
|
||||
const application = await findApplicationById(
|
||||
previewDeployment.applicationId,
|
||||
);
|
||||
application.appName = previewDeployment.appName;
|
||||
await manageDomain(application, domain);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
requiresRedeploy: domain.domainType === "compose",
|
||||
};
|
||||
}),
|
||||
one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => {
|
||||
const domain = await findDomainById(input.domainId);
|
||||
const serviceId = domain.applicationId || domain.composeId;
|
||||
|
||||
@ -56,6 +56,7 @@ export const domains = pgTable("domain", {
|
||||
stripPath: boolean("stripPath").notNull().default(false),
|
||||
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
|
||||
forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
});
|
||||
|
||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||
@ -129,5 +130,6 @@ export const apiUpdateDomain = createSchema
|
||||
stripPath: true,
|
||||
middlewares: true,
|
||||
forwardAuthEnabled: true,
|
||||
enabled: true,
|
||||
})
|
||||
.merge(createSchema.pick({ domainId: true }).required());
|
||||
|
||||
@ -170,6 +170,32 @@ export const applyComposeFilePatch = async (
|
||||
}
|
||||
};
|
||||
|
||||
const removeDomainLabels = (
|
||||
labels: DefinitionsService["labels"],
|
||||
appName: string,
|
||||
uniqueConfigKey: number,
|
||||
) => {
|
||||
const prefixes = [
|
||||
`traefik.http.routers.${appName}-${uniqueConfigKey}-`,
|
||||
`traefik.http.services.${appName}-${uniqueConfigKey}-`,
|
||||
`traefik.http.middlewares.stripprefix-${appName}-${uniqueConfigKey}.`,
|
||||
`traefik.http.middlewares.addprefix-${appName}-${uniqueConfigKey}.`,
|
||||
];
|
||||
const belongsToDomain = (label: string) =>
|
||||
prefixes.some((prefix) => label.startsWith(prefix));
|
||||
|
||||
if (Array.isArray(labels)) {
|
||||
return labels.filter((label) => !belongsToDomain(label));
|
||||
}
|
||||
if (labels) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(labels).filter(([label]) => !belongsToDomain(label)),
|
||||
);
|
||||
}
|
||||
|
||||
return labels;
|
||||
};
|
||||
|
||||
export const addDomainToCompose = async (
|
||||
compose: Compose,
|
||||
domains: Domain[],
|
||||
@ -203,6 +229,24 @@ export const addDomainToCompose = async (
|
||||
}
|
||||
|
||||
for (const domain of domains) {
|
||||
for (const service of Object.values(result.services ?? {})) {
|
||||
if (compose.composeType === "docker-compose") {
|
||||
service.labels = removeDomainLabels(
|
||||
service.labels,
|
||||
appName,
|
||||
domain.uniqueConfigKey,
|
||||
);
|
||||
} else if (service.deploy) {
|
||||
service.deploy.labels = removeDomainLabels(
|
||||
service.deploy.labels,
|
||||
appName,
|
||||
domain.uniqueConfigKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const domain of domains.filter((d) => d.enabled)) {
|
||||
const { serviceName, https } = domain;
|
||||
if (!serviceName) {
|
||||
throw new Error(`Domain "${domain.host}" is missing a service name`);
|
||||
@ -242,33 +286,31 @@ export const addDomainToCompose = async (
|
||||
labels = result.services[serviceName].deploy.labels;
|
||||
}
|
||||
|
||||
const networkLabel =
|
||||
compose.composeType === "docker-compose"
|
||||
? "traefik.docker.network"
|
||||
: "traefik.swarm.network";
|
||||
const networkName = compose.isolatedDeployment
|
||||
? compose.suffix || compose.appName
|
||||
: "dokploy-network";
|
||||
|
||||
if (Array.isArray(labels)) {
|
||||
if (!labels.includes("traefik.enable=true")) {
|
||||
labels.unshift("traefik.enable=true");
|
||||
}
|
||||
labels.unshift(...httpLabels);
|
||||
if (!compose.isolatedDeployment) {
|
||||
if (compose.composeType === "docker-compose") {
|
||||
if (!labels.includes("traefik.docker.network=dokploy-network")) {
|
||||
labels.unshift("traefik.docker.network=dokploy-network");
|
||||
}
|
||||
} else {
|
||||
// Stack Case
|
||||
if (!labels.includes("traefik.swarm.network=dokploy-network")) {
|
||||
labels.unshift("traefik.swarm.network=dokploy-network");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const isolatedNetwork = compose.suffix || compose.appName;
|
||||
if (compose.composeType === "docker-compose") {
|
||||
if (!labels.includes(`traefik.docker.network=${isolatedNetwork}`)) {
|
||||
labels.unshift(`traefik.docker.network=${isolatedNetwork}`);
|
||||
}
|
||||
} else {
|
||||
if (!labels.includes(`traefik.swarm.network=${isolatedNetwork}`)) {
|
||||
labels.unshift(`traefik.swarm.network=${isolatedNetwork}`);
|
||||
}
|
||||
}
|
||||
const networkLabelEntry = `${networkLabel}=${networkName}`;
|
||||
if (!labels.includes(networkLabelEntry)) {
|
||||
labels.unshift(networkLabelEntry);
|
||||
}
|
||||
} else if (labels) {
|
||||
labels["traefik.enable"] = "true";
|
||||
labels[networkLabel] = networkName;
|
||||
for (const label of httpLabels) {
|
||||
const separatorIndex = label.indexOf("=");
|
||||
labels[label.slice(0, separatorIndex)] = label.slice(
|
||||
separatorIndex + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -19,6 +19,15 @@ import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
|
||||
|
||||
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
||||
const { appName } = app;
|
||||
|
||||
// A disabled domain keeps its configuration in the database but must never
|
||||
// expose a traefik router. Guarding here covers every caller (create, update,
|
||||
// forward-auth, toggle) so a disabled domain can't be revived from any path.
|
||||
if (!domain.enabled) {
|
||||
await removeDomain(app, domain.uniqueConfigKey);
|
||||
return;
|
||||
}
|
||||
|
||||
let config: FileConfig;
|
||||
|
||||
if (app.serverId) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user