fix(domain-proxy): improve endpoint and logic to retrieve domain proxy capabilities

This commit is contained in:
KodekaTeam committed 2026-08-07 14:52:54 +07:00
1 parent e71eaaa6ef
commit e488207bf8
6 files changed
+122 -37

No files matched your search

+25 -22
View File
@@ -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>
+8 -2
View File
@@ -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 }}>
+5
View File
@@ -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`,
+22
View File
@@ -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> {