From d2fa55dd1125c0c919f48e9b86cfe2abe2452690 Mon Sep 17 00:00:00 2001 From: Owen Date: Sat, 20 Dec 2025 15:40:29 -0500 Subject: [PATCH] ui to enable down for maintenance screen --- .../resources/[niceId]/general/page.tsx | 720 ++++++++++++++++++ 1 file changed, 720 insertions(+) create mode 100644 src/app/[orgId]/settings/resources/[niceId]/general/page.tsx diff --git a/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx new file mode 100644 index 00000000..87692112 --- /dev/null +++ b/src/app/[orgId]/settings/resources/[niceId]/general/page.tsx @@ -0,0 +1,720 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { formatAxiosError } from "@app/lib/api"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { ListSitesResponse } from "@server/routers/site"; +import { useEffect, useState } from "react"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { useForm } from "react-hook-form"; +import { toast } from "@app/hooks/useToast"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { Label } from "@app/components/ui/label"; +import { ListDomainsResponse } from "@server/routers/domain"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { useTranslations } from "next-intl"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import DomainPicker from "@app/components/DomainPicker"; +import { AlertCircle, Globe } from "lucide-react"; +import { build } from "@server/build"; +import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; +import { DomainRow } from "../../../../../../components/DomainsTable"; +import { toASCII, toUnicode } from "punycode"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { Separator } from "@app/components/ui/separator"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; + +export default function GeneralForm() { + const [formKey, setFormKey] = useState(0); + const params = useParams(); + const { resource, updateResource } = useResourceContext(); + const { org } = useOrgContext(); + const router = useRouter(); + const t = useTranslations(); + const [editDomainOpen, setEditDomainOpen] = useState(false); + const { licenseStatus } = useLicenseStatusContext(); + const subscriptionStatus = useSubscriptionStatusContext(); + const { user } = useUserContext(); + + const { env } = useEnvContext(); + + const orgId = params.orgId; + + const api = createApiClient({ env }); + + const [sites, setSites] = useState([]); + const [saveLoading, setSaveLoading] = useState(false); + const [transferLoading, setTransferLoading] = useState(false); + const [open, setOpen] = useState(false); + const [baseDomains, setBaseDomains] = useState< + ListDomainsResponse["domains"] + >([]); + + const [loadingPage, setLoadingPage] = useState(true); + const [resourceFullDomain, setResourceFullDomain] = useState( + `${resource.ssl ? "https" : "http"}://${toUnicode(resource.fullDomain || "")}` + ); + const [selectedDomain, setSelectedDomain] = useState<{ + domainId: string; + subdomain?: string; + fullDomain: string; + baseDomain: string; + } | null>(null); + + const GeneralFormSchema = z + .object({ + enabled: z.boolean(), + subdomain: z.string().optional(), + name: z.string().min(1).max(255), + niceId: z.string().min(1).max(255).optional(), + domainId: z.string().optional(), + proxyPort: z.number().int().min(1).max(65535).optional(), + // enableProxy: z.boolean().optional() + maintenanceModeEnabled: z.boolean().optional(), + maintenanceModeType: z.enum(["forced", "automatic"]).optional(), + maintenanceTitle: z.string().max(255).optional(), + maintenanceMessage: z.string().max(2000).optional(), + maintenanceEstimatedTime: z.string().max(100).optional(), + }) + .refine( + (data) => { + // For non-HTTP resources, proxyPort should be defined + if (!resource.http) { + return data.proxyPort !== undefined; + } + // For HTTP resources, proxyPort should be undefined + return data.proxyPort === undefined; + }, + { + message: !resource.http + ? "Port number is required for non-HTTP resources" + : "Port number should not be set for HTTP resources", + path: ["proxyPort"] + } + ); + + type GeneralFormValues = z.infer; + + const form = useForm({ + resolver: zodResolver(GeneralFormSchema), + defaultValues: { + enabled: resource.enabled, + name: resource.name, + niceId: resource.niceId, + subdomain: resource.subdomain ? resource.subdomain : undefined, + domainId: resource.domainId || undefined, + proxyPort: resource.proxyPort || undefined, + // enableProxy: resource.enableProxy || false + maintenanceModeEnabled: resource.maintenanceModeEnabled || false, + maintenanceModeType: resource.maintenanceModeType || "automatic", + maintenanceTitle: resource.maintenanceTitle || "We'll be back soon!", + maintenanceMessage: resource.maintenanceMessage || "We are currently performing scheduled maintenance. Please check back soon.", + maintenanceEstimatedTime: resource.maintenanceEstimatedTime || "", + }, + mode: "onChange" + }); + + const isMaintenanceEnabled = form.watch("maintenanceModeEnabled"); + const maintenanceModeType = form.watch("maintenanceModeType"); + + useEffect(() => { + const fetchSites = async () => { + const res = await api.get>( + `/org/${orgId}/sites/` + ); + setSites(res.data.data.sites); + }; + + const fetchDomains = async () => { + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/domains/`) + .catch((e) => { + toast({ + variant: "destructive", + title: t("domainErrorFetch"), + description: formatAxiosError( + e, + t("domainErrorFetchDescription") + ) + }); + }); + + if (res?.status === 200) { + const rawDomains = res.data.data.domains as DomainRow[]; + const domains = rawDomains.map((domain) => ({ + ...domain, + baseDomain: toUnicode(domain.baseDomain), + })); + setBaseDomains(domains); + setFormKey((key) => key + 1); + } + }; + + const load = async () => { + await fetchDomains(); + await fetchSites(); + + setLoadingPage(false); + }; + + load(); + }, []); + + async function onSubmit(data: GeneralFormValues) { + setSaveLoading(true); + + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + enabled: data.enabled, + name: data.name, + niceId: data.niceId, + subdomain: data.subdomain ? toASCII(data.subdomain) : undefined, + domainId: data.domainId, + proxyPort: data.proxyPort, + // ...(!resource.http && { + // enableProxy: data.enableProxy + // }) + maintenanceModeEnabled: data.maintenanceModeEnabled, + maintenanceModeType: data.maintenanceModeType, + maintenanceTitle: data.maintenanceTitle || null, + maintenanceMessage: data.maintenanceMessage || null, + maintenanceEstimatedTime: data.maintenanceEstimatedTime || null, + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + const updated = res.data.data; + + updateResource({ + enabled: data.enabled, + name: data.name, + niceId: data.niceId, + subdomain: data.subdomain, + fullDomain: resource.fullDomain, + proxyPort: data.proxyPort, + // ...(!resource.http && { + // enableProxy: data.enableProxy + // }) + maintenanceModeEnabled: data.maintenanceModeEnabled, + maintenanceModeType: data.maintenanceModeType, + maintenanceTitle: data.maintenanceTitle || null, + maintenanceMessage: data.maintenanceMessage || null, + maintenanceEstimatedTime: data.maintenanceEstimatedTime || null, + }); + + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + + if (data.niceId && data.niceId !== resource?.niceId) { + router.replace(`/${updated.orgId}/settings/resources/${data.niceId}/general`); + } else { + router.refresh(); + } + + setSaveLoading(false); + } + + setSaveLoading(false); + } + + return ( + !loadingPage && ( + <> + + + + + {t("resourceGeneral")} + + + {t("resourceGeneralDescription")} + + + + + +
+ + ( + +
+ + + form.setValue( + "enabled", + val + ) + } + /> + +
+ +
+ )} + /> + + ( + + + {t("name")} + + + + + + + )} + /> + + ( + + {t("identifier")} + + + + + + )} + /> + + {!resource.http && ( + <> + ( + + + {t( + "resourcePortNumber" + )} + + + + field.onChange( + e + .target + .value + ? parseInt( + e + .target + .value + ) + : undefined + ) + } + /> + + + + {t( + "resourcePortNumberDescription" + )} + + + )} + /> + + {/* {build == "oss" && ( + ( + + + + +
+ + {t( + "resourceEnableProxy" + )} + + + {t( + "resourceEnableProxyDescription" + )} + +
+
+ )} + /> + )} */} + + )} + + {resource.http && ( + <> +
+ +
+ + + {resourceFullDomain} + + +
+
+ + + +
+
+

+ Maintenance Mode +

+

+ Display a maintenance page to visitors +

+
+ + ( + +
+ + + form.setValue( + "maintenanceModeEnabled", + val + ) + } + /> + +
+ + Show a maintenance page to visitors + + +
+ )} + /> + + {isMaintenanceEnabled && ( +
+ ( + + + Maintenance Mode Type + + + + + + + +
+ + Automatic (Recommended) + + + Show maintenance page only when all backend targets are down or unhealthy. + Your resource continues working normally as long as at least one target is healthy. + +
+
+ + + + +
+ + Forced + + + Always show the maintenance page regardless of backend health. + Use this for planned maintenance when you want to prevent all access. + +
+
+
+
+ +
+ )} + /> + + {maintenanceModeType === "forced" && ( + + + + Warning: All traffic will be directed to the maintenance page. + Your backend resources will not receive any requests. + + + )} + + ( + + Page Title + + + + + The main heading displayed on the maintenance page + + + + )} + /> + + ( + + Maintenance Message + +