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:
Vyacheslav Scherbinin
2026-06-25 00:08:53 +07:00
parent 898af1f74c
commit ee802cd54c
9 changed files with 8725 additions and 11 deletions
@@ -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
+7
View File
@@ -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
}
]
}
+52
View File
@@ -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;
+2
View File
@@ -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());
+2 -1
View File
@@ -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) {