mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-15 13:21:11 +00:00
feat(domains): add enable/disable toggle for domains
Add an `enabled` flag (default true) so a domain can be switched off without deleting it, preserving its configuration. - schema: new `enabled` column + migration; added to apiUpdateDomain - traefik (applications): guard manageDomain so a disabled domain never exposes a router from any caller (create/update/forward-auth/toggle); applies instantly through the file provider - compose: skip disabled domains when generating docker labels, so the change applies on the next deployment - tRPC: new domain.toggleEnable procedure (permission-checked + audited), returns requiresRedeploy for compose domains - UI: switch on the domain card (host row, variant E) and a Status column in the table view; disabled cards are dimmed; reuses the compose redeploy hint for the toast Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
@@ -247,6 +252,32 @@ 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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={domain.enabled}
|
||||
onCheckedChange={() => handleToggleEnable(domain.domainId)}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{domain.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Actions",
|
||||
|
||||
@@ -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,6 +59,7 @@ 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";
|
||||
@@ -147,6 +149,23 @@ 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 {
|
||||
@@ -206,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,
|
||||
@@ -424,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">
|
||||
@@ -492,15 +516,44 @@ 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 gap-2 shrink-0">
|
||||
<Switch
|
||||
checked={item.enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleEnable(item.domainId)
|
||||
}
|
||||
disabled={isToggling}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{item.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</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 */}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "domain" ADD COLUMN "enabled" boolean DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1226,6 +1226,13 @@
|
||||
"when": 1783674181297,
|
||||
"tag": "0174_great_naoko",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 175,
|
||||
"version": "7",
|
||||
"when": 1783941126738,
|
||||
"tag": "0175_rare_omega_flight",
|
||||
"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());
|
||||
|
||||
@@ -161,7 +161,8 @@ export const addDomainToCompose = async (
|
||||
result = randomized;
|
||||
}
|
||||
|
||||
for (const domain of domains) {
|
||||
// Disabled domains keep their config but must not produce any traefik labels.
|
||||
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`);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user