fix(domain-proxy): improve endpoint and logic to retrieve domain proxy capabilities
This commit is contained in:
1 parent
e71eaaa6ef
commit
e488207bf8
6 files changed
+122
-37
No files matched your search
@@ -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<Container[]>([]);
|
||||
const [serverContainersLoading, setServerContainersLoading] = useState(false);
|
||||
const [portOptions, setPortOptions] = useState<PortOption[]>([]);
|
||||
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}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gridTemplateColumns: "minmax(0, 1fr)",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@@ -52,18 +62,22 @@ export default function AddDomainTargetingSection({
|
||||
>
|
||||
Target Container *
|
||||
</label>
|
||||
<select
|
||||
className="input"
|
||||
<SearchableSelect
|
||||
value={form.targetContainerId}
|
||||
onChange={(event) =>
|
||||
options={containerOptions}
|
||||
onChange={(targetContainerId) =>
|
||||
onChange({
|
||||
targetContainerId: event.target.value,
|
||||
targetContainerId,
|
||||
targetPort: "",
|
||||
})
|
||||
}
|
||||
disabled={!form.serverId || capabilityLoading || !targetingEnabled}
|
||||
placeholder="Select container..."
|
||||
searchPlaceholder="Search containers..."
|
||||
emptyText="No matching containers found"
|
||||
disabled={!form.serverId || serverContainersLoading || !targetingEnabled}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
/>
|
||||
{/*
|
||||
<option value="">— Select container —</option>
|
||||
{serverContainers.map((container) => {
|
||||
const linkedDomains =
|
||||
@@ -83,9 +97,9 @@ export default function AddDomainTargetingSection({
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
*/}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 12,
|
||||
@@ -136,7 +150,14 @@ export default function AddDomainTargetingSection({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{form.serverId && serverContainers.length === 0 && (
|
||||
{form.serverId && serverContainersLoading && (
|
||||
<div role="status" aria-live="polite" style={{ display: "flex", alignItems: "center", gap: 7, marginTop: 8, padding: "8px 10px", border: "1px solid rgba(59,130,246,0.28)", borderRadius: 7, background: "rgba(59,130,246,0.08)", color: "var(--text-muted)", fontSize: 12 }}>
|
||||
<Loader2 size={14} className="animate-spin" aria-hidden="true" />
|
||||
Loading running containers from the selected server…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{form.serverId && !serverContainersLoading && serverContainers.length === 0 && (
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)" }}>
|
||||
No running containers were found on the selected server.
|
||||
</p>
|
||||
@@ -197,10 +218,11 @@ export default function AddDomainTargetingSection({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p style={{ fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
|
||||
<p style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11, color: "var(--text-muted)", marginTop: 6 }}>
|
||||
{form.serverId && capabilityLoading && <Loader2 size={13} className="animate-spin" aria-hidden="true" />}
|
||||
{form.serverId
|
||||
? capabilityLoading
|
||||
? "Inspecting selected server for available reverse proxies..."
|
||||
? "Checking reverse proxy availability on the selected server…"
|
||||
: "Choose container and port first, then pick one of the proxies detected on the selected server."
|
||||
: "Select Server Target first to enable proxy-aware validation."}
|
||||
</p>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
>
|
||||
<span style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<DomainProxyCapability> {
|
||||
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<ServerConfigSnapshot> {
|
||||
|
||||
Reference in new issue
Block a user