diff --git a/src/app/domains/components/AddDomainModal.tsx b/src/app/domains/components/AddDomainModal.tsx index 0430502..6018482 100644 --- a/src/app/domains/components/AddDomainModal.tsx +++ b/src/app/domains/components/AddDomainModal.tsx @@ -21,11 +21,7 @@ import { DomainConfigMode, PortOption, } from "./domain-types"; -import { - detectAvailableProxies, - isIpv4WithPort, - parseInspectPortOptions, -} from "./domain-utils"; +import { isIpv4WithPort, parseInspectPortOptions } from "./domain-utils"; interface AddDomainModalProps { serverList: Server[]; @@ -84,6 +80,7 @@ export default function AddDomainModal({ const [proxyCapabilityError, setProxyCapabilityError] = useState(""); const [certbotInstalled, setCertbotInstalled] = useState(false); const [serverContainers, setServerContainers] = useState([]); + const [serverContainersLoading, setServerContainersLoading] = useState(false); const [portOptions, setPortOptions] = useState([]); const [containerInspectLoading, setContainerInspectLoading] = useState(false); const [availableProxies, setAvailableProxies] = useState< @@ -175,6 +172,7 @@ export default function AddDomainModal({ setProxyCapabilityError(""); setAvailableProxies(null); setServerContainers([]); + setServerContainersLoading(false); setPortOptions([]); setCertbotInstalled(false); setForm((current) => { @@ -203,32 +201,27 @@ export default function AddDomainModal({ } setCapabilityLoading(true); + setServerContainersLoading(true); setProxyCapabilityError(""); + const containersRequest = containersApi.list({ serverId: form.serverId }); void Promise.all([ - serversApi.getConfig(form.serverId), - containersApi.list({ serverId: form.serverId }), + serversApi.getDomainProxyCapability(form.serverId), + containersRequest, ]) - .then(([configRes, containersRes]) => { + .then(([capabilityRes, containersRes]) => { if (cancelled) { return; } - const proxies = detectAvailableProxies( - configRes.data, - containersRes.data, - ); - const hasCertbot = configRes.data.webServer.components.some( - (component) => component.key === "certbot" && component.installed, - ); + const proxies: DomainProxy[] = ["NONE"]; + if (containersRes.data.some((container) => `${container.name} ${container.image}`.toLowerCase().includes("traefik"))) proxies.push("TRAEFIK"); + if (capabilityRes.data.nginx) proxies.push("NGINX"); + if (capabilityRes.data.caddy) proxies.push("CADDY"); + const hasCertbot = capabilityRes.data.certbot; setCertbotInstalled(hasCertbot); setAvailableProxies(proxies); - setServerContainers( - containersRes.data.filter( - (container) => container.status === "RUNNING", - ), - ); setForm((current) => ({ ...current, sslEnabled: hasCertbot ? current.sslEnabled : false, @@ -244,8 +237,6 @@ export default function AddDomainModal({ setAvailableProxies(["NONE"]); setCertbotInstalled(false); - setServerContainers([]); - setPortOptions([]); setForm((current) => ({ ...current, proxy: "NONE" })); setProxyCapabilityError( err instanceof Error @@ -259,6 +250,17 @@ export default function AddDomainModal({ } }); + void containersRequest + .then((containersRes) => { + if (!cancelled) setServerContainers(containersRes.data.filter((container) => container.status === "RUNNING")); + }) + .catch(() => { + if (!cancelled) setServerContainers([]); + }) + .finally(() => { + if (!cancelled) setServerContainersLoading(false); + }); + return () => { cancelled = true; }; @@ -629,6 +631,7 @@ export default function AddDomainModal({ capabilityLoading={capabilityLoading} proxyCapabilityError={proxyCapabilityError} serverContainers={serverContainers} + serverContainersLoading={serverContainersLoading} portOptions={portOptions} containerInspectLoading={containerInspectLoading} selectableProxies={selectableProxies} diff --git a/src/app/domains/components/AddDomainTargetingSection.tsx b/src/app/domains/components/AddDomainTargetingSection.tsx index c3d39e7..a2c30e3 100644 --- a/src/app/domains/components/AddDomainTargetingSection.tsx +++ b/src/app/domains/components/AddDomainTargetingSection.tsx @@ -1,4 +1,6 @@ import { Container, DomainProxy } from "@/lib/api"; +import SearchableSelect, { type SearchableSelectOption } from "@/components/SearchableSelect"; +import { Loader2 } from "lucide-react"; import { AddDomainFormState, PortOption } from "./domain-types"; interface AddDomainTargetingSectionProps { @@ -6,6 +8,7 @@ interface AddDomainTargetingSectionProps { capabilityLoading: boolean; proxyCapabilityError: string; serverContainers: Container[]; + serverContainersLoading: boolean; portOptions: PortOption[]; containerInspectLoading: boolean; selectableProxies: DomainProxy[]; @@ -23,6 +26,7 @@ export default function AddDomainTargetingSection({ capabilityLoading, proxyCapabilityError, serverContainers, + serverContainersLoading, portOptions, containerInspectLoading, selectableProxies, @@ -32,16 +36,22 @@ export default function AddDomainTargetingSection({ linkedPortDomains, onChange, }: AddDomainTargetingSectionProps) { + const containerOptions: SearchableSelectOption[] = serverContainers.map((container) => { + const linkedDomains = linkedContainerDomains.get(container.id) ?? []; + const disabled = linkedDomains.length > 0 && !allowSharedTargetReuse; + return { value: container.id, label: `${container.name} (${container.image})`, keywords: `${container.name} ${container.image} ${container.dockerId ?? ""}`, description: disabled ? linkedDomains.length > 1 ? "Used by 1+ domains" : `Used by ${linkedDomains.join(", ")}` : undefined, disabled }; + }); + return ( <>
-
+
- + */}
-
+
- {form.serverId && serverContainers.length === 0 && ( + {form.serverId && serverContainersLoading && ( +
+
+ )} + + {form.serverId && !serverContainersLoading && serverContainers.length === 0 && (

No running containers were found on the selected server.

@@ -197,10 +218,11 @@ export default function AddDomainTargetingSection({ ); })}
-

+

+ {form.serverId && capabilityLoading &&

diff --git a/src/components/SearchableSelect.tsx b/src/components/SearchableSelect.tsx index e139d2c..061e40f 100644 --- a/src/components/SearchableSelect.tsx +++ b/src/components/SearchableSelect.tsx @@ -15,6 +15,7 @@ export type SearchableSelectOption = { label: string; keywords?: string; description?: string; + disabled?: boolean; }; type SearchableSelectProps = { @@ -252,9 +253,11 @@ export default function SearchableSelect({ key={option.value} type="button" onClick={() => { + if (option.disabled) return; onChange(option.value); setOpen(false); }} + disabled={option.disabled} style={{ width: "100%", border: "none", @@ -262,7 +265,9 @@ export default function SearchableSelect({ background: active ? "var(--bg-card-hover)" : "transparent", - color: "var(--text-primary)", + color: option.disabled + ? "var(--text-muted)" + : "var(--text-primary)", minHeight: OPTION_ROW_HEIGHT, display: "flex", alignItems: "center", @@ -271,7 +276,8 @@ export default function SearchableSelect({ textAlign: "left", borderRadius: 0, padding: "9px 12px", - cursor: "pointer", + cursor: option.disabled ? "not-allowed" : "pointer", + opacity: option.disabled ? 0.55 : 1, }} > diff --git a/src/lib/api.ts b/src/lib/api.ts index 3c07b67..ba59a02 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1651,6 +1651,11 @@ export const servers = { `/servers/${id}/docker`, { timeoutMs: 12000 }, ), + getDomainProxyCapability: (id: string) => + get<{ + success: boolean; + data: { nginx: boolean; caddy: boolean; certbot: boolean }; + }>(`/servers/${id}/domain-proxy-capability`, { timeoutMs: 10000 }), getConfig: (id: string) => get<{ success: boolean; data: ServerConfigSnapshot }>( `/servers/${id}/config`, diff --git a/src/server/routes/servers.ts b/src/server/routes/servers.ts index 888934b..8ca4b61 100644 --- a/src/server/routes/servers.ts +++ b/src/server/routes/servers.ts @@ -715,6 +715,28 @@ export async function serverRoutes(app: FastifyInstance) { }, ); + app.get( + "/:id/domain-proxy-capability", + { preHandler: serverReadAccess }, + async (req, reply) => { + const { id } = req.params as { id: string }; + const server = await getAccessibleServer(req.userId, id, req.organizationId); + if (!server) { + return reply.status(403).send({ success: false, error: "Forbidden" }); + } + + try { + const data = await ssh.getDomainProxyCapability(server); + return reply.send({ success: true, data }); + } catch (err: unknown) { + return reply.status(500).send({ + success: false, + error: err instanceof Error ? err.message : "Failed to inspect domain proxy capability", + }); + } + }, + ); + app.get( "/:id/config", { preHandler: serverReadAccess }, diff --git a/src/server/services/ssh-services/server-config.ts b/src/server/services/ssh-services/server-config.ts index ee10852..f8125b2 100644 --- a/src/server/services/ssh-services/server-config.ts +++ b/src/server/services/ssh-services/server-config.ts @@ -118,6 +118,33 @@ export interface ServerConfigSnapshot { fetchedAt: string; } +export interface DomainProxyCapability { + nginx: boolean; + caddy: boolean; + certbot: boolean; +} + +/** A focused probe for the domain form; avoids loading the full server snapshot. */ +export async function getDomainProxyCapability( + server: Server, +): Promise { + const result = await exec( + server, + `bash -lc ${escapeShellArg([ + 'has_command() { command -v "$1" >/dev/null 2>&1 && return 0; shift; for candidate in "$@"; do [ -x "$candidate" ] && return 0; done; return 1; }', + 'is_active() { systemctl is-active --quiet "$1" 2>/dev/null; }', + 'nginx=0; if has_command nginx /usr/sbin/nginx /usr/bin/nginx /usr/local/sbin/nginx /usr/local/bin/nginx /usr/local/nginx/sbin/nginx && is_active nginx; then nginx=1; fi', + 'caddy=0; if has_command caddy /usr/bin/caddy /usr/sbin/caddy /usr/local/bin/caddy /usr/local/sbin/caddy && is_active caddy; then caddy=1; fi', + 'certbot=0; if has_command certbot /snap/bin/certbot /usr/bin/certbot /usr/local/bin/certbot /root/.local/bin/certbot; then certbot=1; fi', + 'printf "%s|%s|%s\\n" "$nginx" "$caddy" "$certbot"', + ].join("\n"))}`, + configCommandTimeout(CONFIG_FAST_TIMEOUT_MS), + ); + const [nginx, caddy, certbot] = result.stdout.trim().split("|"); + + return { nginx: nginx === "1", caddy: caddy === "1", certbot: certbot === "1" }; +} + export async function getServerConfigSnapshot( server: Server, ): Promise {