Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,21 @@ function hasConnectedDomain(service: {
customDomain?: string;
domain?: string;
name?: string;
publicEndpoints?: Array<{
domainType?: "free" | "custom";
customDomain?: string;
domain?: string;
}>;
}) {
if (!service.exposed) return false;
if (service.publicEndpoints && service.publicEndpoints.length > 0) {
const hasEndpointDomain = service.publicEndpoints.some((ep) =>
ep.domainType === "custom"
? Boolean(ep.customDomain?.trim())
: Boolean(ep.domain?.trim()),
);
if (hasEndpointDomain) return true;
}
if (service.domainType === "custom") return Boolean(service.customDomain?.trim());
return Boolean(service.domain?.trim() || service.name?.trim());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ import { useRouter } from "next/navigation";
import { Rocket, ChevronDown, RefreshCw, Layers } from "lucide-react";
import DropdownMenu from "@/components/ui/DropdownMenu";
import WarningCallout from "@/components/shared/WarningCallout";

import {
hasConnectedDomain,
isPotentiallyPublicService,
shouldWarnAboutUnreachableServices,
} from "./redeploy-unreachable-warning";
export const Deployments = () => {
const {
id,
Expand All @@ -23,6 +27,7 @@ export const Deployments = () => {
servicesData,
refreshServices,
hasMultipleServices,
domainsData,
} = useProjectSettings();
const { t } = useI18n();
const { showToast } = useToast();
Expand Down Expand Up @@ -179,8 +184,10 @@ export const Deployments = () => {
if (hasMultipleServices) {
const services =
servicesData.services.length > 0 ? servicesData.services : await refreshServices();
if (shouldWarnAboutUnreachableServices(services)) {
const candidateServices = services.filter(isPotentiallyPublicService);
if (shouldWarnAboutUnreachableServices(services, domainsData.domains)) {
const candidateServices = services.filter(
(s) => isPotentiallyPublicService(s) && !hasConnectedDomain(s, domainsData.domains),
);
let modalId = "";
modalId = showModal({
customContent: (
Expand All @@ -200,10 +207,10 @@ export const Deployments = () => {
className="rounded-lg bg-foreground/[0.06] px-3 py-1.5 text-[12px] font-medium text-foreground transition-colors hover:bg-foreground/[0.1]"
onClick={() => {
hideModal(modalId);
setActiveTab("services");
setActiveTab("domains");
}}
>
{t.projects.redeploy.openServices}
{t.projects.redeploy.openDomains ?? t.projects.redeploy.openServices}
</button>
<button
type="button"
Expand Down Expand Up @@ -443,18 +450,3 @@ export const Deployments = () => {
);
};

function hasConnectedDomain(service: Service) {
if (!service.exposed) return false;
if (service.domainType === "custom") return Boolean(service.customDomain?.trim());
return Boolean(service.domain?.trim());
}

function isPotentiallyPublicService(service: Service) {
return service.enabled && (service.ports?.length ?? 0) > 0;
}

function shouldWarnAboutUnreachableServices(services: Service[]) {
const candidateServices = services.filter(isPotentiallyPublicService);
if (candidateServices.length === 0) return false;
return candidateServices.every((service) => !hasConnectedDomain(service));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, it, expect } from "vitest";
import type { Service } from "@/lib/api/services";
import {
hasConnectedDomain,
isPotentiallyPublicService,
shouldWarnAboutUnreachableServices,
serviceMatchesPort,
} from "./redeploy-unreachable-warning";

describe("serviceMatchesPort", () => {
it("matches exposedPort", () => {
const svc = { exposedPort: "9000", ports: ["20020:9000"] };
expect(serviceMatchesPort(svc, 9000)).toBe(true);
expect(serviceMatchesPort(svc, "9000")).toBe(true);
expect(serviceMatchesPort(svc, 8000)).toBe(false);
});

it("matches container port in host:container port mapping", () => {
const svc = { exposedPort: null, ports: ["20020:9000"] };
expect(serviceMatchesPort(svc, 9000)).toBe(true);
expect(serviceMatchesPort(svc, 20020)).toBe(true);
expect(serviceMatchesPort(svc, 3000)).toBe(false);
});

it("matches port with protocol suffix", () => {
const svc = { exposedPort: null, ports: ["8080:80/tcp"] };
expect(serviceMatchesPort(svc, 80)).toBe(true);
expect(serviceMatchesPort(svc, 8080)).toBe(true);
expect(serviceMatchesPort(svc, 443)).toBe(false);
});
});

describe("hasConnectedDomain", () => {
const baseService: Service = {
id: "svc_proxy",
name: "proxy",
kind: "compose",
image: "proxy:latest",
build: null,
dockerfile: null,
buildArgs: null,
ports: ["20020:9000"],
dependsOn: [],
environment: {},
volumes: [],
command: null,
restart: "unless-stopped",
exposed: true,
exposedPort: "9000",
domain: "",
customDomain: "",
domainType: "free",
publicEndpoints: [],
enabled: true,
sortOrder: 0,
};

it("returns false if service is not exposed", () => {
const svc = { ...baseService, exposed: false };
const domains = [{ serviceId: "svc_proxy", hostname: "example.com" }];
expect(hasConnectedDomain(svc, domains)).toBe(false);
});

it("returns true when domain table record links directly via serviceId", () => {
const svc = { ...baseService, domain: "", customDomain: "" };
const domains = [
{ id: "dom_1", serviceId: "svc_proxy", hostname: "archive.rschl.de", targetPort: 9000 },
];
expect(hasConnectedDomain(svc, domains)).toBe(true);
});

it("returns true when domain table record matches service targetPort", () => {
const svc = { ...baseService, id: "svc_other", domain: "", customDomain: "" };
const domains = [
{ id: "dom_1", serviceId: null, hostname: "archive.rschl.de", targetPort: 9000 },
];
expect(hasConnectedDomain(svc, domains)).toBe(true);
});

it("returns true for project-level domain with no targetPort", () => {
const svc = { ...baseService, domain: "", customDomain: "" };
const domains = [
{ id: "dom_1", serviceId: null, hostname: "archive.rschl.de", targetPort: null },
];
expect(hasConnectedDomain(svc, domains)).toBe(true);
});

it("returns true when service has publicEndpoints with customDomain", () => {
const svc: Service = {
...baseService,
publicEndpoints: [
{ port: 9000, domainType: "custom", customDomain: "api.example.com" },
],
};
expect(hasConnectedDomain(svc, [])).toBe(true);
});

it("returns true when service has scalar customDomain", () => {
const svc: Service = {
...baseService,
domainType: "custom",
customDomain: "api.example.com",
};
expect(hasConnectedDomain(svc, [])).toBe(true);
});

it("returns false when custom domainType is selected but customDomain is blank and no domains exist", () => {
const svc: Service = {
...baseService,
domainType: "custom",
customDomain: "",
};
expect(hasConnectedDomain(svc, [])).toBe(false);
});
});

describe("shouldWarnAboutUnreachableServices", () => {
const serviceWithPorts: Service = {
id: "svc_proxy",
name: "proxy",
kind: "compose",
image: "proxy:latest",
build: null,
dockerfile: null,
buildArgs: null,
ports: ["20020:9000"],
dependsOn: [],
environment: {},
volumes: [],
command: null,
restart: "unless-stopped",
exposed: true,
exposedPort: "9000",
domain: "",
customDomain: "",
domainType: "free",
publicEndpoints: [],
enabled: true,
sortOrder: 0,
};

const internalService: Service = {
id: "svc_redis",
name: "redis",
kind: "compose",
image: "redis:alpine",
build: null,
dockerfile: null,
buildArgs: null,
ports: [],
dependsOn: [],
environment: {},
volumes: [],
command: null,
restart: "unless-stopped",
exposed: false,
exposedPort: null,
domain: "",
customDomain: "",
domainType: "free",
publicEndpoints: [],
enabled: true,
sortOrder: 1,
};

it("returns false when no services have ports", () => {
expect(shouldWarnAboutUnreachableServices([internalService])).toBe(false);
});

it("does NOT warn when candidate service has an active domain in domain table", () => {
const domains = [
{ id: "dom_1", serviceId: "svc_proxy", hostname: "archive.rschl.de", targetPort: 9000 },
];
expect(shouldWarnAboutUnreachableServices([serviceWithPorts, internalService], domains)).toBe(false);
});

it("warns when candidate service is unexposed and has no domains", () => {
const unexposed = { ...serviceWithPorts, exposed: false };
expect(shouldWarnAboutUnreachableServices([unexposed, internalService], [])).toBe(true);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { Service } from "@/lib/api/services";

export interface ProjectDomainLike {
serviceId?: string | null;
targetPort?: number | string | null;
hostname?: string;
domain?: string;
}

export function serviceMatchesPort(
service: Pick<Service, "ports" | "exposedPort">,
port: number | string,
): boolean {
const p = String(port).trim();
if (!p) return false;
if (String(service.exposedPort ?? "").trim() === p) return true;
return (service.ports ?? []).some((spec) => {
const parts = spec.split(":");
const container = (parts[parts.length - 1] ?? "").split("/")[0];
const host = (parts[parts.length - 2] ?? "").split("/")[0];
return container === p || host === p;
});
}

export function hasConnectedDomain(
service: Service,
domains?: ProjectDomainLike[] | null,
): boolean {
if (!service.exposed) return false;

if (domains && domains.length > 0) {
const hasMatchingDomain = domains.some((d) => {
const hostname = (d.hostname ?? d.domain ?? "").trim();
if (!hostname) return false;
if (d.serviceId && d.serviceId === service.id) return true;
if (d.targetPort != null && serviceMatchesPort(service, d.targetPort)) return true;
if (!d.serviceId && d.targetPort == null) return true;
return false;
});
if (hasMatchingDomain) return true;
}

if (service.publicEndpoints && service.publicEndpoints.length > 0) {
const hasEndpointDomain = service.publicEndpoints.some((ep) =>
ep.domainType === "custom"
? Boolean(ep.customDomain?.trim())
: Boolean(ep.domain?.trim()),
);
if (hasEndpointDomain) return true;
}

if (service.domainType === "custom") return Boolean(service.customDomain?.trim());
return Boolean(service.domain?.trim() || service.name?.trim());
}

export function isPotentiallyPublicService(service: Service): boolean {
return service.enabled && (service.ports?.length ?? 0) > 0;
}

export function shouldWarnAboutUnreachableServices(
services: Service[],
domains?: ProjectDomainLike[] | null,
): boolean {
const candidateServices = services.filter(isPotentiallyPublicService);
if (candidateServices.length === 0) return false;
return candidateServices.every((service) => !hasConnectedDomain(service, domains));
}
7 changes: 4 additions & 3 deletions apps/dashboard/src/i18n/locales/ar/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -380,12 +380,13 @@
"noPublicDomainTitle": "لا يوجد نطاق عام متصل",
"noPublicDomainDescOne": "يحتوي هذا المشروع على {count} خدمة بمنافذ مكشوفة، لكن لم يتم إعداد أي منها بنطاق يمكن الوصول إليه. إذا نشرت الآن، يمكن للحزمة أن تعمل داخلياً، لكن لن يتمكن المستخدمون من الوصول إليها عبر رابط عام.",
"noPublicDomainDescOther": "يحتوي هذا المشروع على {count} خدمات بمنافذ مكشوفة، لكن لم يتم إعداد أي منها بنطاق يمكن الوصول إليه. إذا نشرت الآن، يمكن للحزمة أن تعمل داخلياً، لكن لن يتمكن المستخدمون من الوصول إليها عبر رابط عام.",
"openDomains": "فتح النطاقات",
"openServices": "فتح الخدمات",
"deployAnyway": "انشر على أي حال",
"suggestedFix": "الإصلاح المقترح",
"fixStep1": "افتح تبويب الخدمات.",
"fixStep2": "اختر الخدمة التي يجب أن تكون عامة.",
"fixStep3": "فعّل كشف النطاق واختر المنفذ العام."
"fixStep1": "افتح تبويب النطاقات.",
"fixStep2": "أضف مساراً أو نطاقاً مخصصاً لمنفذ الخدمة.",
"fixStep3": "تحقق من النطاق وانشر."
},
"connections": {
"useInProject": "استخدام في مشروع",
Expand Down
7 changes: 4 additions & 3 deletions apps/dashboard/src/i18n/locales/de/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -380,12 +380,13 @@
"noPublicDomainTitle": "Keine öffentliche Domain verbunden",
"noPublicDomainDescOne": "Dieses Projekt hat {count} Dienst mit exponierten Ports, aber keiner ist mit einer erreichbaren Domain konfiguriert. Wenn du jetzt bereitstellst, kann der Stack intern laufen, aber Nutzer können nicht über eine öffentliche URL darauf zugreifen.",
"noPublicDomainDescOther": "Dieses Projekt hat {count} Dienste mit exponierten Ports, aber keiner ist mit einer erreichbaren Domain konfiguriert. Wenn du jetzt bereitstellst, kann der Stack intern laufen, aber Nutzer können nicht über eine öffentliche URL darauf zugreifen.",
"openDomains": "Domains öffnen",
"openServices": "Dienste öffnen",
"deployAnyway": "Trotzdem bereitstellen",
"suggestedFix": "Vorgeschlagene Lösung",
"fixStep1": "Öffne den Tab „Dienste“.",
"fixStep2": "Wähle den Dienst, der öffentlich sein soll.",
"fixStep3": "Aktiviere die Domain-Freigabe und wähle den öffentlichen Port."
"fixStep1": "Öffne den Tab „Domains“.",
"fixStep2": "Füge eine Route oder Custom-Domain für den Service-Port hinzu.",
"fixStep3": "Verifiziere die Domain und stelle bereit."
},
"connections": {
"useInProject": "In einem Projekt verwenden",
Expand Down
7 changes: 4 additions & 3 deletions apps/dashboard/src/i18n/locales/en/projects.json
Original file line number Diff line number Diff line change
Expand Up @@ -428,12 +428,13 @@
"noPublicDomainTitle": "No public domain is connected",
"noPublicDomainDescOne": "This project has {count} service with exposed ports, but none are configured with a reachable domain. If you deploy now, the stack can run internally, but users will not be able to access it from a public URL.",
"noPublicDomainDescOther": "This project has {count} services with exposed ports, but none are configured with a reachable domain. If you deploy now, the stack can run internally, but users will not be able to access it from a public URL.",
"openDomains": "Open Domains",
"openServices": "Open Services",
"deployAnyway": "Deploy anyway",
"suggestedFix": "Suggested fix",
"fixStep1": "Open the Services tab.",
"fixStep2": "Pick the service that should be public.",
"fixStep3": "Enable domain exposure and choose the public port."
"fixStep1": "Open the Domains tab.",
"fixStep2": "Add a route or custom domain for the service port.",
"fixStep3": "Verify the domain and deploy."
},
"incomingWebhooks": {
"title": "Incoming webhooks",
Expand Down
Loading