mirror of
https://github.com/andwati/dokploy.git
synced 2026-11-04 03:02:16 +00:00
Merge pull request #4697 from gentslava/feat/domain-enable-disable
feat(domains): add toggle to enable/disable domains
This commit is contained in:
@@ -33,6 +33,7 @@ const makeDomain = (serviceName: string) =>
|
|||||||
https: false,
|
https: false,
|
||||||
uniqueConfigKey: 1,
|
uniqueConfigKey: 1,
|
||||||
port: 3000,
|
port: 3000,
|
||||||
|
enabled: true,
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
// If the returned shell fragment is safe, parse() yields only string tokens.
|
// If the returned shell fragment is safe, parse() yields only string tokens.
|
||||||
|
|||||||
@@ -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,
|
customEntrypoint: null,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("Host rule format validation", () => {
|
describe("Host rule format validation", () => {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ describe("createDomainLabels", () => {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should create basic labels for web entrypoint", async () => {
|
it("should create basic labels for web entrypoint", async () => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ const baseDomain: Domain = {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("forwardAuthMiddlewareName", () => {
|
describe("forwardAuthMiddlewareName", () => {
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ const baseDomain: Domain = {
|
|||||||
stripPath: false,
|
stripPath: false,
|
||||||
middlewares: null,
|
middlewares: null,
|
||||||
forwardAuthEnabled: false,
|
forwardAuthEnabled: false,
|
||||||
|
enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const baseRedirect: Redirect = {
|
const baseRedirect: Redirect = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import Link from "next/link";
|
|||||||
import { DialogAction } from "@/components/shared/dialog-action";
|
import { DialogAction } from "@/components/shared/dialog-action";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
@@ -35,7 +36,9 @@ interface ColumnsProps {
|
|||||||
validationStates: ValidationStates;
|
validationStates: ValidationStates;
|
||||||
handleValidateDomain: (host: string) => Promise<void>;
|
handleValidateDomain: (host: string) => Promise<void>;
|
||||||
handleDeleteDomain: (domainId: string) => Promise<void>;
|
handleDeleteDomain: (domainId: string) => Promise<void>;
|
||||||
|
handleToggleEnable: (domainId: string) => Promise<void>;
|
||||||
isDeleting: boolean;
|
isDeleting: boolean;
|
||||||
|
isToggling: boolean;
|
||||||
serverIp?: string;
|
serverIp?: string;
|
||||||
canCreateDomain: boolean;
|
canCreateDomain: boolean;
|
||||||
canDeleteDomain: boolean;
|
canDeleteDomain: boolean;
|
||||||
@@ -47,7 +50,9 @@ export const createColumns = ({
|
|||||||
validationStates,
|
validationStates,
|
||||||
handleValidateDomain,
|
handleValidateDomain,
|
||||||
handleDeleteDomain,
|
handleDeleteDomain,
|
||||||
|
handleToggleEnable,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
|
isToggling,
|
||||||
serverIp,
|
serverIp,
|
||||||
canCreateDomain,
|
canCreateDomain,
|
||||||
canDeleteDomain,
|
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",
|
id: "actions",
|
||||||
header: "Actions",
|
header: "Actions",
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
|
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||||
|
|
||||||
export type CacheType = "fetch" | "cache";
|
export type CacheType = "fetch" | "cache";
|
||||||
|
|
||||||
@@ -300,7 +301,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
|
||||||
})
|
})
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
toast.success(dictionary.success);
|
toast.success(
|
||||||
|
dictionary.success,
|
||||||
|
data.domainType === "compose"
|
||||||
|
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (data.domainType === "application") {
|
if (data.domainType === "application") {
|
||||||
await utils.domain.byApplicationId.invalidate({
|
await utils.domain.byApplicationId.invalidate({
|
||||||
@@ -337,12 +343,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
|
||||||
|
|
||||||
{type === "compose" && (
|
{type === "compose" && <ComposeRedeployAlert className="mb-4" />}
|
||||||
<AlertBlock type="info" className="mb-4">
|
|
||||||
Whenever you make changes to domains, remember to redeploy your
|
|
||||||
compose to apply the changes.
|
|
||||||
</AlertBlock>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...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,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -58,11 +59,13 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import { createColumns } from "./columns";
|
import { createColumns } from "./columns";
|
||||||
import { DnsHelperModal } from "./dns-helper-modal";
|
import { DnsHelperModal } from "./dns-helper-modal";
|
||||||
import { AddDomain } from "./handle-domain";
|
import { AddDomain } from "./handle-domain";
|
||||||
import { HandleForwardAuth } from "./handle-forward-auth";
|
import { HandleForwardAuth } from "./handle-forward-auth";
|
||||||
|
import { COMPOSE_REDEPLOY_TOAST, ComposeRedeployAlert } from "./redeploy-hint";
|
||||||
|
|
||||||
export type ValidationState = {
|
export type ValidationState = {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
@@ -146,12 +149,34 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
api.domain.validateDomain.useMutation();
|
api.domain.validateDomain.useMutation();
|
||||||
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
const { mutateAsync: deleteDomain, isPending: isRemoving } =
|
||||||
api.domain.delete.useMutation();
|
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) => {
|
const handleDeleteDomain = async (domainId: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteDomain({ domainId });
|
await deleteDomain({ domainId });
|
||||||
refetch();
|
refetch();
|
||||||
toast.success("Domain deleted successfully");
|
toast.success(
|
||||||
|
"Domain deleted successfully",
|
||||||
|
type === "compose"
|
||||||
|
? { description: COMPOSE_REDEPLOY_TOAST }
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Error deleting domain");
|
toast.error("Error deleting domain");
|
||||||
}
|
}
|
||||||
@@ -200,7 +225,9 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
validationStates,
|
validationStates,
|
||||||
handleValidateDomain,
|
handleValidateDomain,
|
||||||
handleDeleteDomain,
|
handleDeleteDomain,
|
||||||
|
handleToggleEnable,
|
||||||
isDeleting: isRemoving,
|
isDeleting: isRemoving,
|
||||||
|
isToggling,
|
||||||
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
|
||||||
canCreateDomain,
|
canCreateDomain,
|
||||||
canDeleteDomain,
|
canDeleteDomain,
|
||||||
@@ -265,6 +292,11 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
{type === "compose" && data && data.length > 0 && (
|
||||||
|
<div className="px-6 pb-4">
|
||||||
|
<ComposeRedeployAlert />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<CardContent className="flex w-full flex-row gap-4">
|
<CardContent className="flex w-full flex-row gap-4">
|
||||||
{isLoadingDomains ? (
|
{isLoadingDomains ? (
|
||||||
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
|
<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 (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={item.domainId}
|
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">
|
<CardContent className="p-6">
|
||||||
<div className="flex flex-col gap-4">
|
<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?"
|
description="Are you sure you want to delete this domain?"
|
||||||
type="destructive"
|
type="destructive"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await deleteDomain({
|
await handleDeleteDomain(item.domainId);
|
||||||
domainId: item.domainId,
|
|
||||||
})
|
|
||||||
.then((_data) => {
|
|
||||||
refetch();
|
|
||||||
toast.success(
|
|
||||||
"Domain deleted successfully",
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
toast.error("Error deleting domain");
|
|
||||||
});
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -492,15 +516,41 @@ export const ShowDomains = ({ id, type }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full break-all">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<Link
|
<div className="w-full break-all">
|
||||||
className="flex items-center gap-2 text-base font-medium hover:underline"
|
<Link
|
||||||
target="_blank"
|
className="flex items-center gap-2 text-base font-medium hover:underline"
|
||||||
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
target="_blank"
|
||||||
>
|
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
|
||||||
{item.host}
|
>
|
||||||
<ExternalLink className="size-4 min-w-4" />
|
{item.host}
|
||||||
</Link>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Domain Details */}
|
{/* Domain Details */}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "domain" ADD COLUMN "enabled" boolean DEFAULT true NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1282,6 +1282,13 @@
|
|||||||
"when": 1786435879438,
|
"when": 1786435879438,
|
||||||
"tag": "0182_skinny_wild_pack",
|
"tag": "0182_skinny_wild_pack",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 183,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786463845579,
|
||||||
|
"tag": "0183_tearful_groot",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -141,6 +141,58 @@ export const domainRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
return result;
|
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 }) => {
|
one: protectedProcedure.input(apiFindDomain).query(async ({ input, ctx }) => {
|
||||||
const domain = await findDomainById(input.domainId);
|
const domain = await findDomainById(input.domainId);
|
||||||
const serviceId = domain.applicationId || domain.composeId;
|
const serviceId = domain.applicationId || domain.composeId;
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const domains = pgTable("domain", {
|
|||||||
stripPath: boolean("stripPath").notNull().default(false),
|
stripPath: boolean("stripPath").notNull().default(false),
|
||||||
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
|
middlewares: text("middlewares").array().default(sql`ARRAY[]::text[]`),
|
||||||
forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
|
forwardAuthEnabled: boolean("forwardAuthEnabled").notNull().default(false),
|
||||||
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const domainsRelations = relations(domains, ({ one }) => ({
|
export const domainsRelations = relations(domains, ({ one }) => ({
|
||||||
@@ -129,5 +130,6 @@ export const apiUpdateDomain = createSchema
|
|||||||
stripPath: true,
|
stripPath: true,
|
||||||
middlewares: true,
|
middlewares: true,
|
||||||
forwardAuthEnabled: true,
|
forwardAuthEnabled: true,
|
||||||
|
enabled: true,
|
||||||
})
|
})
|
||||||
.merge(createSchema.pick({ domainId: true }).required());
|
.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 (
|
export const addDomainToCompose = async (
|
||||||
compose: Compose,
|
compose: Compose,
|
||||||
domains: Domain[],
|
domains: Domain[],
|
||||||
@@ -203,6 +229,24 @@ export const addDomainToCompose = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const domain of domains) {
|
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;
|
const { serviceName, https } = domain;
|
||||||
if (!serviceName) {
|
if (!serviceName) {
|
||||||
throw new Error(`Domain "${domain.host}" is missing a service name`);
|
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;
|
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 (Array.isArray(labels)) {
|
||||||
if (!labels.includes("traefik.enable=true")) {
|
if (!labels.includes("traefik.enable=true")) {
|
||||||
labels.unshift("traefik.enable=true");
|
labels.unshift("traefik.enable=true");
|
||||||
}
|
}
|
||||||
labels.unshift(...httpLabels);
|
labels.unshift(...httpLabels);
|
||||||
if (!compose.isolatedDeployment) {
|
const networkLabelEntry = `${networkLabel}=${networkName}`;
|
||||||
if (compose.composeType === "docker-compose") {
|
if (!labels.includes(networkLabelEntry)) {
|
||||||
if (!labels.includes("traefik.docker.network=dokploy-network")) {
|
labels.unshift(networkLabelEntry);
|
||||||
labels.unshift("traefik.docker.network=dokploy-network");
|
}
|
||||||
}
|
} else if (labels) {
|
||||||
} else {
|
labels["traefik.enable"] = "true";
|
||||||
// Stack Case
|
labels[networkLabel] = networkName;
|
||||||
if (!labels.includes("traefik.swarm.network=dokploy-network")) {
|
for (const label of httpLabels) {
|
||||||
labels.unshift("traefik.swarm.network=dokploy-network");
|
const separatorIndex = label.indexOf("=");
|
||||||
}
|
labels[label.slice(0, separatorIndex)] = label.slice(
|
||||||
}
|
separatorIndex + 1,
|
||||||
} 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}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,15 @@ import { createPathMiddlewares, removePathMiddlewares } from "./middleware";
|
|||||||
|
|
||||||
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
export const manageDomain = async (app: ApplicationNested, domain: Domain) => {
|
||||||
const { appName } = app;
|
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;
|
let config: FileConfig;
|
||||||
|
|
||||||
if (app.serverId) {
|
if (app.serverId) {
|
||||||
|
|||||||
Reference in New Issue
Block a user