From 8bcc55193b723c19deda4d0e4a90e5a6895a3054 Mon Sep 17 00:00:00 2001 From: hhftechnologies Date: Fri, 15 May 2026 20:36:44 +0530 Subject: [PATCH 1/6] Import compose services and blueprint fixes Add support for importing individual Docker Compose services into blueprints and improve generator behavior. Introduces resourceFromComposeService, default service port handling, and unique blueprint name generation; preserves disabled compose services (no Pangolin labels) and retains existing network syntax when injecting the Pangolin network. Update Blueprint and Compose builders to batch-import services/networks/volumes, expose available compose services UI, and fix selection/indexing when adding/removing resources or services. Add tests for template modal and blueprint generator/validator changes, and adjust env output header formatting and template-store state updates after import. --- package-lock.json | 31 +----- .../__tests__/TemplateDetailModal.test.tsx | 47 +++++++++ src/routes/blueprint-builder.tsx | 81 ++++++++++++++-- src/routes/docker/compose-builder.tsx | 56 +++++------ .../blueprint/__tests__/generator.test.ts | 30 +++++- .../blueprint/__tests__/validator.test.ts | 11 +++ src/utils/blueprint/generator.ts | 97 ++++++++++++++----- 7 files changed, 263 insertions(+), 90 deletions(-) create mode 100644 src/components/templates/__tests__/TemplateDetailModal.test.tsx diff --git a/package-lock.json b/package-lock.json index 472dc5d..591c3e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1237,7 +1237,6 @@ "version": "0.129.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz", "integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/Boshen" @@ -2409,7 +2408,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2426,7 +2424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2443,7 +2440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2460,7 +2456,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2477,7 +2472,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2494,7 +2488,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2511,7 +2504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2528,7 +2520,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2545,7 +2536,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2562,7 +2552,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2579,7 +2568,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2596,7 +2584,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2613,7 +2600,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -2632,7 +2618,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2649,7 +2634,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3361,7 +3345,7 @@ "version": "25.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz", "integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.21.0" @@ -3371,7 +3355,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -3381,7 +3365,7 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.2.0" @@ -4362,7 +4346,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/data-urls": { @@ -5845,7 +5828,6 @@ "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, "funding": [ { "type": "github", @@ -6004,7 +5986,6 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -6241,7 +6222,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz", "integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==", - "dev": true, "license": "MIT", "dependencies": { "@oxc-project/types": "=0.129.0", @@ -6275,7 +6255,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz", "integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -6696,7 +6675,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz", "integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unplugin": { @@ -6821,7 +6800,6 @@ "version": "8.0.12", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz", "integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==", - "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", @@ -6899,7 +6877,6 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" diff --git a/src/components/templates/__tests__/TemplateDetailModal.test.tsx b/src/components/templates/__tests__/TemplateDetailModal.test.tsx new file mode 100644 index 0000000..b62c895 --- /dev/null +++ b/src/components/templates/__tests__/TemplateDetailModal.test.tsx @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { TemplateDetailModal } from "../TemplateDetailModal"; +import { ToastProvider } from "../../ui/toast"; + +const VALID_TEMPLATE = { + id: "nginx", + name: "Nginx", + composeContent: `services: + nginx: + image: nginx:latest + ports: + - "8080:80" +`, +}; + +function renderModal(props: Partial[0]> = {}) { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + template: VALID_TEMPLATE, + onImport: vi.fn().mockResolvedValue(undefined), + }; + + const merged = { ...defaultProps, ...props }; + render( + + + , + ); + return merged; +} + +describe("TemplateDetailModal", () => { + it("closes after a successful import", async () => { + const user = userEvent.setup(); + const onOpenChange = vi.fn(); + const onImport = vi.fn().mockResolvedValue(undefined); + renderModal({ onOpenChange, onImport }); + + await user.click(screen.getByRole("button", { name: /import template/i })); + + expect(onImport).toHaveBeenCalledWith(VALID_TEMPLATE); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/routes/blueprint-builder.tsx b/src/routes/blueprint-builder.tsx index 0dfddeb..a9c31f8 100644 --- a/src/routes/blueprint-builder.tsx +++ b/src/routes/blueprint-builder.tsx @@ -16,9 +16,9 @@ import { TemplateDetailModal } from "../components/templates/TemplateDetailModal import { Field, ValidatedInput } from "../components/compose-builder/ServiceForm/Field"; import { useTemplateStore } from "../hooks/useTemplateStore"; import { - defaultResource, defaultBlueprint, fromCompose, + resourceFromComposeService, toComposeYaml, toEnvExample, } from "../utils/blueprint/generator"; @@ -58,6 +58,21 @@ function BlueprintBuilderRoute() { const composeYaml = useMemo(() => toComposeYaml(blueprint), [blueprint]); const envExample = useMemo(() => toEnvExample(blueprint), [blueprint]); const errors = useMemo(() => validateBlueprint(blueprint), [blueprint]); + const composeServiceEntries = useMemo(() => { + const services = blueprint.composeDocument?.services; + if (!services || typeof services !== "object" || Array.isArray(services)) { + return [] as Array<[string, unknown]>; + } + return Object.entries(services as Record); + }, [blueprint.composeDocument]); + const enabledServiceNames = useMemo( + () => new Set(blueprint.resources.map((r) => r.serviceContainerName)), + [blueprint.resources], + ); + const availableComposeServices = useMemo( + () => composeServiceEntries.filter(([name]) => !enabledServiceNames.has(name)), + [composeServiceEntries, enabledServiceNames], + ); const handleNavView = useCallback( (next: BuilderView) => { @@ -81,18 +96,39 @@ function BlueprintBuilderRoute() { const removeResource = useCallback( (idx: number) => { + const nextLength = Math.max(blueprint.resources.length - 1, 0); setBlueprint((bp) => ({ ...bp, resources: bp.resources.filter((_, i) => i !== idx), })); setSelectedIdx((cur) => { - if (cur === null) return null; - if (cur === idx) return null; + if (cur === null) return nextLength > 0 ? 0 : null; + if (cur === idx) return nextLength > 0 ? Math.min(idx, nextLength - 1) : null; if (cur > idx) return cur - 1; return cur; }); }, - [], + [blueprint.resources.length], + ); + + const addResourceForComposeService = useCallback( + (serviceName: string, rawService: unknown) => { + setBlueprint((bp) => { + const existingNames = new Set(bp.resources.map((r) => r.blueprintName)); + const resource = resourceFromComposeService(serviceName, rawService); + const baseName = resource.blueprintName; + let nextName = baseName; + let suffix = 2; + while (existingNames.has(nextName)) { + nextName = `${baseName}-${suffix}`; + suffix += 1; + } + resource.blueprintName = nextName; + return { ...bp, resources: [...bp.resources, resource] }; + }); + setSelectedIdx(blueprint.resources.length); + }, + [blueprint.resources.length], ); const importTemplate = useCallback( @@ -103,13 +139,17 @@ function BlueprintBuilderRoute() { const next = fromCompose(template.composeContent, blueprint.baseDomain); setBlueprint(next); setSelectedIdx(next.resources.length > 0 ? 0 : null); + templateStore.setTemplateDetailOpen(false); + templateStore.setTemplateStoreOpen(false); + templateStore.setSelectedTemplate(null); + templateStore.setTemplateDetailTab("overview"); toast({ title: "Template imported", description: `${next.resources.length} resource${next.resources.length === 1 ? "" : "s"} ready to label`, variant: "success", }); }, - [blueprint.baseDomain, toast], + [blueprint.baseDomain, templateStore, toast], ); const output = outputTab === "compose" ? composeYaml : envExample; @@ -215,6 +255,35 @@ function BlueprintBuilderRoute() { })} )} + + {availableComposeServices.length > 0 && ( + <> +
+

Available compose services

+

+ Add Pangolin labels back to services preserved in the imported compose file. +

+
+
+ {availableComposeServices.map(([serviceName, rawService]) => ( +
+ + + {serviceName} + Pangolin disabled + + +
+ ))} +
+ + )} @@ -695,5 +764,3 @@ function ResourceForm({ ); } -// silence unused-import warnings -void defaultResource; diff --git a/src/routes/docker/compose-builder.tsx b/src/routes/docker/compose-builder.tsx index 960c430..ab3e491 100644 --- a/src/routes/docker/compose-builder.tsx +++ b/src/routes/docker/compose-builder.tsx @@ -407,22 +407,17 @@ function ComposeBuilderRoute() { } function removeService(idx: number) { + const nextLength = Math.max(services.length - 1, 0); setServices((prev) => { if (idx < 0 || idx >= prev.length) return prev; const next = [...prev]; next.splice(idx, 1); - // Invariant: always keep at least one service so the ServiceForm has - // something to render. Removing the last row replaces it with a fresh - // unnamed default. - if (next.length === 0) next.push(defaultService()); return next; }); if (selectedType === "service" && selectedIdx !== null) { if (selectedIdx === idx) { - // Fall back to the previous row, or 0 when the deleted row was - // replaced by a fresh default. - selectService(Math.max(0, idx - 1)); + selectService(nextLength > 0 ? Math.min(idx, nextLength - 1) : null); } else if (selectedIdx > idx) { selectService(selectedIdx - 1); } @@ -431,29 +426,36 @@ function ComposeBuilderRoute() { // ---------------- template import ---------------- - // eslint-disable-next-line @typescript-eslint/no-explicit-any - function importComposeService(svc: any, allNetworks: any, allVolumes: any) { - const parsed = parseComposeService(svc, allNetworks, allVolumes); - const filtered = services.filter((s) => s.name && s.name.trim() !== ""); - const idx = filtered.length; - setServices((prev) => { - const f = prev.filter((s) => s.name && s.name.trim() !== ""); - return [...f, parsed.service]; - }); - if (parsed.networks.length > 0) bulkAddNetworks(parsed.networks); - if (parsed.volumes.length > 0) bulkAddVolumes(parsed.volumes); - selectService(idx); - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any async function importTemplate(template: any) { try { const parsed = parseComposeTemplate(template.composeContent); - for (const svc of parsed.services) { - importComposeService(svc, parsed.networks, parsed.volumes); - } + const parsedServices = parsed.services.map((svc) => + parseComposeService(svc, parsed.networks, parsed.volumes), + ); + const importedServices = parsedServices.map((item) => item.service); + const firstImportedIdx = services.filter( + (service) => service.name && service.name.trim() !== "", + ).length; + + setServices((prev) => { + const existing = prev.filter( + (service) => service.name && service.name.trim() !== "", + ); + const next = [...existing, ...importedServices]; + return next.length > 0 ? next : [defaultService()]; + }); + + const importedNetworks = parsedServices.flatMap((item) => item.networks); + const importedVolumes = parsedServices.flatMap((item) => item.volumes); + if (importedNetworks.length > 0) bulkAddNetworks(importedNetworks); + if (importedVolumes.length > 0) bulkAddVolumes(importedVolumes); + + selectService(importedServices.length > 0 ? firstImportedIdx : null); templateStore.setTemplateDetailOpen(false); templateStore.setTemplateStoreOpen(false); + templateStore.setSelectedTemplate(null); + templateStore.setTemplateDetailTab("overview"); } catch (error: unknown) { const msg = error instanceof Error ? error.message : String(error); // eslint-disable-next-line no-console @@ -467,10 +469,10 @@ function ComposeBuilderRoute() { const currentService = typeof selectedIdx === "number" && services[selectedIdx] ? services[selectedIdx] - : services[0]; + : null; const formApi: ServiceFormApi = { - service: currentService, + service: currentService ?? defaultService(), restartOptions: RESTART_OPTIONS, updateServiceField, updatePortField, @@ -526,7 +528,7 @@ function ComposeBuilderRoute() { } }; - const showServiceForm = selectedType === "service"; + const showServiceForm = selectedType === "service" && currentService !== null; const showNetworkForm = selectedType === "network" && selectedNetworkIdx !== null && diff --git a/src/utils/blueprint/__tests__/generator.test.ts b/src/utils/blueprint/__tests__/generator.test.ts index fb189f4..c8e026f 100644 --- a/src/utils/blueprint/__tests__/generator.test.ts +++ b/src/utils/blueprint/__tests__/generator.test.ts @@ -9,7 +9,7 @@ import { } from "../generator"; describe("fromCompose", () => { - it("creates one resource per service with exposed ports", () => { + it("creates one resource per compose service", () => { const compose = `version: "3.8" services: web: @@ -20,13 +20,16 @@ services: image: postgres:16 `; const bp = fromCompose(compose, "example.com"); - expect(bp.resources.length).toBe(1); + expect(bp.resources.length).toBe(2); expect(bp.resources[0].serviceContainerName).toBe("web"); expect(bp.resources[0].blueprintName).toBe("web"); expect(bp.resources[0].subdomain).toBe("web"); expect(bp.resources[0].servicePort).toBe(8080); expect(bp.resources[0].protocol).toBe("http"); expect(bp.resources[0].image).toBe("nginx:latest"); + expect(bp.resources[1].serviceContainerName).toBe("db"); + expect(bp.resources[1].servicePort).toBe(80); + expect(bp.resources[1].image).toBe("postgres:16"); }); it("uses https for port 443 / 8443", () => { @@ -83,6 +86,25 @@ describe("toComposeYaml", () => { ); }); + + it("leaves disabled compose services unlabelled", () => { + const bp = fromCompose(`services: + web: + image: nginx + ports: + - "80" + worker: + image: busybox +`); + bp.resources = bp.resources.filter((r) => r.serviceContainerName !== "worker"); + const yaml = toComposeYaml(bp); + const parsed = jsyaml.load(yaml) as Record; + const services = parsed.services as Record>; + expect(services.worker).toBeTruthy(); + expect(services.worker.labels).toBeUndefined(); + expect(String(yaml)).not.toContain("pangolin.public-resources.worker"); + }); + it("adds the external pangolin network block", () => { const bp = fromCompose( `services: @@ -219,8 +241,8 @@ describe("toEnvExample", () => { - "3000" `); const env = toEnvExample(bp); - expect(env).toMatch(/─── web ───/); - expect(env).toMatch(/─── api ───/); + expect(env).toMatch(/--- web ---/); + expect(env).toMatch(/--- api ---/); }); it("returns a placeholder when no resources are present", () => { diff --git a/src/utils/blueprint/__tests__/validator.test.ts b/src/utils/blueprint/__tests__/validator.test.ts index ce7fe40..c13382c 100644 --- a/src/utils/blueprint/__tests__/validator.test.ts +++ b/src/utils/blueprint/__tests__/validator.test.ts @@ -74,6 +74,17 @@ describe("validateBlueprint", () => { expect(validateBlueprint(bp)).toEqual([]); }); + + it("validates a marketplace service without ports using defaults", () => { + const bp = fromCompose(`services: + worker: + image: busybox +`, "example.com"); + expect(bp.resources).toHaveLength(1); + expect(bp.resources[0].servicePort).toBe(80); + expect(validateBlueprint(bp)).toEqual([]); + }); + it("flags duplicate blueprintNames", () => { const bp = fromCompose(`services: a: diff --git a/src/utils/blueprint/generator.ts b/src/utils/blueprint/generator.ts index a995860..d6e9780 100644 --- a/src/utils/blueprint/generator.ts +++ b/src/utils/blueprint/generator.ts @@ -18,6 +18,7 @@ import type { const ENV_REF = (k: string) => `\${${k}}`; const ENV_REQUIRED = (k: string, msg: string) => `\${${k}:?${msg}}`; const DEFAULT_NETWORK = "pangolin_default"; +const DEFAULT_SERVICE_PORT = 80; export function defaultAuth(): BlueprintAuth { return { @@ -128,6 +129,41 @@ function protocolFor(port: number): BlueprintProtocol { return "http"; } +export function resourceFromComposeService( + serviceKey: string, + rawService: unknown, +): BlueprintResource { + const svc = + rawService && typeof rawService === "object" + ? (rawService as Record) + : {}; + const port = firstServicePort(svc) ?? DEFAULT_SERVICE_PORT; + const sluggedKey = slug(serviceKey) || serviceKey.toLowerCase() || "service"; + + return { + ...defaultResource(), + serviceContainerName: serviceKey, + blueprintName: sluggedKey, + resourceName: titleCase(serviceKey) || serviceKey, + subdomain: sluggedKey, + servicePort: port, + image: typeof svc.image === "string" ? svc.image : "", + protocol: protocolFor(port), + }; +} + +function uniqueBlueprintName(base: string, seen: Set): string { + if (!seen.has(base)) { + seen.add(base); + return base; + } + let i = 2; + while (seen.has(`${base}-${i}`)) i += 1; + const next = `${base}-${i}`; + seen.add(next); + return next; +} + export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint { let doc: unknown; try { @@ -147,22 +183,14 @@ export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint { bp.baseDomain = baseDomain; bp.composeDocument = root; + const seenBlueprintNames = new Set(); for (const [svcKey, raw] of Object.entries(services)) { if (!raw || typeof raw !== "object") continue; - const svc = raw as Record; - const port = firstServicePort(svc); - if (port === null) continue; - const sluggedKey = slug(svcKey) || svcKey; - const resource: BlueprintResource = { - ...defaultResource(), - serviceContainerName: svcKey, - blueprintName: sluggedKey, - resourceName: titleCase(svcKey) || svcKey, - subdomain: sluggedKey, - servicePort: port, - image: typeof svc.image === "string" ? svc.image : "", - protocol: protocolFor(port), - }; + const resource = resourceFromComposeService(svcKey, raw); + resource.blueprintName = uniqueBlueprintName( + resource.blueprintName, + seenBlueprintNames, + ); bp.resources.push(resource); } return bp; @@ -190,6 +218,24 @@ function dedupePreserveOrder(items: T[]): T[] { return out; } +function labelsToArray(labels: unknown): string[] { + if (Array.isArray(labels)) { + return labels.map((v) => String(v)); + } + if (isPlainObject(labels)) { + return Object.entries(labels).map(([key, value]) => `${key}=${String(value)}`); + } + return []; +} + +function removePangolinResourceLabels( + labels: string[], + blueprintName: string, +): string[] { + const prefix = `pangolin.public-resources.${blueprintName}.`; + return labels.filter((label) => !label.startsWith(prefix)); +} + function labelsFor(bp: Blueprint, r: BlueprintResource): string[] { const ns = `pangolin.public-resources.${r.blueprintName}`; const fullDomain = bp.baseDomain @@ -245,19 +291,20 @@ export function toComposeYaml(bp: Blueprint): string { ? (services[r.serviceContainerName] as Record) : {}; // Inject labels — merge with anything already there, de-dupe. - const existing: string[] = Array.isArray(svc.labels) - ? (svc.labels as unknown[]).map((v) => String(v)) - : []; + const existing = removePangolinResourceLabels( + labelsToArray(svc.labels), + r.blueprintName, + ); svc.labels = dedupePreserveOrder([...existing, ...labelsFor(bp, r)]); - // Ensure pangolin network membership. - const nets: unknown[] = Array.isArray(svc.networks) - ? (svc.networks as unknown[]) - : []; - if (!nets.includes("pangolin")) { - svc.networks = [...nets, "pangolin"]; + // Ensure pangolin network membership without discarding existing syntax. + if (Array.isArray(svc.networks)) { + const nets = svc.networks as unknown[]; + svc.networks = nets.includes("pangolin") ? nets : [...nets, "pangolin"]; + } else if (isPlainObject(svc.networks)) { + svc.networks = { ...svc.networks, pangolin: {} }; } else { - svc.networks = nets; + svc.networks = ["pangolin"]; } services[r.serviceContainerName] = svc; @@ -297,7 +344,7 @@ export function toEnvExample(bp: Blueprint): string { const blocks: string[] = []; bp.resources.forEach((r, i) => { const header = - bp.resources.length > 1 ? `# ─── ${r.serviceContainerName} ───\n` : ""; + bp.resources.length > 1 ? `# --- ${r.serviceContainerName} ---\n` : ""; const auth = ` # Optional resource auth overrides — uncomment and set as needed. # RESOURCE_AUTH_PINCODE= From 65fda48f7d9514120cc2b38a2a2e12faa84311e1 Mon Sep 17 00:00:00 2001 From: hhftechnologies Date: Fri, 15 May 2026 21:42:48 +0530 Subject: [PATCH 2/6] testfiles --- src/__tests__/setup.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 9a98628..68b5c41 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,5 +1,39 @@ import "@testing-library/jest-dom/vitest"; +// Some runners expose a partial localStorage object when the native backing +// file is unavailable. Tests need the full Storage surface. +if (typeof window.localStorage?.clear !== "function") { + const store = new Map(); + const storage: Storage = { + get length() { + return store.size; + }, + clear() { + store.clear(); + }, + getItem(key: string) { + return store.has(key) ? store.get(key)! : null; + }, + key(index: number) { + return Array.from(store.keys())[index] ?? null; + }, + removeItem(key: string) { + store.delete(key); + }, + setItem(key: string, value: string) { + store.set(key, String(value)); + }, + }; + Object.defineProperty(window, "localStorage", { + configurable: true, + value: storage, + }); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }); +} + // matchMedia polyfill for jsdom if (!window.matchMedia) { window.matchMedia = (query: string) => ({ From a70c6f8c8faf32f2b46e7b83ddfa67fc932c1ccf Mon Sep 17 00:00:00 2001 From: hhftechnologies Date: Tue, 19 May 2026 16:53:12 +0530 Subject: [PATCH 3/6] bugfixes-ui-valadations --- src/components/ConversionDialog.tsx | 21 +- src/components/__tests__/ServicesTab.test.tsx | 17 +- src/components/compose-builder/CodePanel.tsx | 75 +++--- .../ConfigColumn/ServicesTab.tsx | 7 +- .../templates/TemplateDetailModal.tsx | 29 ++- src/hooks/useConversionDialog.ts | 71 +++--- src/hooks/useTemplateStore.ts | 81 +++++-- src/routes/blueprint-builder.tsx | 8 + src/routes/config-builder.tsx | 137 +++++++---- src/routes/docker/compose-builder.tsx | 13 ++ src/routes/scheduler-builder.tsx | 123 +++++++--- src/utils/__tests__/compose-version.test.ts | 47 ++++ src/utils/__tests__/template-import.test.ts | 12 + src/utils/blueprint/__tests__/golden.test.ts | 3 +- src/utils/blueprint/generator.ts | 6 +- src/utils/compose-version.ts | 18 ++ src/utils/template-import.ts | 3 +- .../validation/__tests__/compose.test.ts | 9 + src/utils/validation/__tests__/config.test.ts | 57 +++++ .../__tests__/outputs-extra.test.ts | 60 +++++ .../validation/__tests__/outputs.test.ts | 55 +++++ .../validation/__tests__/scheduler.test.ts | 62 +++++ .../validation/__tests__/templates.test.ts | 57 +++++ src/utils/validation/compose.ts | 13 +- src/utils/validation/config.ts | 118 ++++++++++ src/utils/validation/issues.ts | 78 +++++++ src/utils/validation/outputs.ts | 219 ++++++++++++++++++ src/utils/validation/scheduler.ts | 199 ++++++++++++++++ src/utils/validation/templates.ts | 63 +++++ vitest.validation.config.ts | 28 +++ 30 files changed, 1482 insertions(+), 207 deletions(-) create mode 100644 src/utils/__tests__/compose-version.test.ts create mode 100644 src/utils/compose-version.ts create mode 100644 src/utils/validation/__tests__/config.test.ts create mode 100644 src/utils/validation/__tests__/outputs-extra.test.ts create mode 100644 src/utils/validation/__tests__/outputs.test.ts create mode 100644 src/utils/validation/__tests__/scheduler.test.ts create mode 100644 src/utils/validation/__tests__/templates.test.ts create mode 100644 src/utils/validation/config.ts create mode 100644 src/utils/validation/issues.ts create mode 100644 src/utils/validation/outputs.ts create mode 100644 src/utils/validation/scheduler.ts create mode 100644 src/utils/validation/templates.ts create mode 100644 vitest.validation.config.ts diff --git a/src/components/ConversionDialog.tsx b/src/components/ConversionDialog.tsx index 0ed9d70..1ad6830 100644 --- a/src/components/ConversionDialog.tsx +++ b/src/components/ConversionDialog.tsx @@ -8,7 +8,7 @@ import { } from "./ui/dialog"; import { Checkbox } from "./ui/checkbox"; import { Label } from "./ui/label"; -import { Copy, Download } from "lucide-react"; +import { Copy, Download, AlertCircle } from "lucide-react"; import { copyToClipboard, downloadFile } from "../utils/clipboard"; import type { ConversionType } from "../hooks/useConversionDialog"; @@ -17,6 +17,7 @@ export interface ConversionDialogProps { onOpenChange: (open: boolean) => void; conversionType: ConversionType | string; conversionOutput: string; + conversionValid?: boolean; clearEnvAfterDownload?: boolean; onClearEnvChange?: (checked: boolean) => void; onToast?: (message: { title: string; description?: string; variant?: "default" | "success" | "error" }) => void; @@ -51,6 +52,7 @@ export function ConversionDialog({ onOpenChange, conversionType, conversionOutput, + conversionValid = true, clearEnvAfterDownload = false, onClearEnvChange, onToast, @@ -60,6 +62,7 @@ export function ConversionDialog({ const mimeType = CONVERSION_MIME_TYPES[conversionType] || "text/plain"; const handleCopy = async () => { + if (!conversionValid) return; try { await copyToClipboard(conversionOutput); onToast?.({ @@ -67,7 +70,7 @@ export function ConversionDialog({ description: `${title} has been copied to your clipboard.`, variant: "success", }); - } catch (error) { + } catch { onToast?.({ title: "Failed to copy", description: "Could not copy to clipboard. Please try again.", @@ -77,6 +80,7 @@ export function ConversionDialog({ }; const handleDownload = () => { + if (!conversionValid) return; try { downloadFile(conversionOutput, filename, mimeType); onToast?.({ @@ -84,7 +88,7 @@ export function ConversionDialog({ description: `Downloading ${filename}`, variant: "success", }); - } catch (error) { + } catch { onToast?.({ title: "Download failed", description: "Could not download file. Please try again.", @@ -100,6 +104,13 @@ export function ConversionDialog({ {title} + {!conversionValid && ( +
+ + Output is blocked until validation errors are fixed. +
+ )} +
             {conversionOutput}
@@ -120,11 +131,11 @@ export function ConversionDialog({
         )}
 
         
-          
-          
diff --git a/src/components/__tests__/ServicesTab.test.tsx b/src/components/__tests__/ServicesTab.test.tsx
index 97d7a92..b7e846d 100644
--- a/src/components/__tests__/ServicesTab.test.tsx
+++ b/src/components/__tests__/ServicesTab.test.tsx
@@ -5,7 +5,7 @@ import { ServicesTab } from "../compose-builder/ConfigColumn/ServicesTab";
 import { defaultService } from "../../utils/default-configs";
 
 describe("ServicesTab", () => {
-  it("hides the trash icon on an unnamed service", () => {
+  it("hides the trash icon on the last remaining service", () => {
     render(
        {
     ).not.toBeInTheDocument();
   });
 
-  it("shows the trash icon once a service is named", () => {
-    const svc = { ...defaultService(), name: "web", image: "nginx" };
+  it("shows trash icons for manually added blank services when more than one exists", () => {
     render(
        {}}
         onAddService={() => {}}
@@ -36,8 +35,8 @@ describe("ServicesTab", () => {
       />,
     );
     expect(
-      screen.getByRole("button", { name: /remove service/i }),
-    ).toBeInTheDocument();
+      screen.getAllByRole("button", { name: /remove service/i }),
+    ).toHaveLength(2);
   });
 
   it("fires onRemoveService when the trash is clicked", async () => {
@@ -45,7 +44,7 @@ describe("ServicesTab", () => {
     const svc = { ...defaultService(), name: "web", image: "nginx" };
     render(
        {}}
@@ -55,7 +54,7 @@ describe("ServicesTab", () => {
       />,
     );
     await userEvent.click(
-      screen.getByRole("button", { name: /remove service/i }),
+      screen.getByRole("button", { name: /remove service web/i }),
     );
     expect(onRemove).toHaveBeenCalledWith(0);
   });
diff --git a/src/components/compose-builder/CodePanel.tsx b/src/components/compose-builder/CodePanel.tsx
index 8272ff5..304d05e 100644
--- a/src/components/compose-builder/CodePanel.tsx
+++ b/src/components/compose-builder/CodePanel.tsx
@@ -6,15 +6,14 @@ import type {
   VolumeConfig,
 } from "../../types/compose";
 import type { VPNConfig } from "../../types/vpn-configs";
-import {
-  convertToDockerRun,
-  convertToSystemd,
-  generateKomodoToml,
-  generateEnvFile,
-} from "../../utils/converters";
 import { copyToClipboard } from "../../utils/clipboard";
+import {
+  createValidatedComposeOutput,
+  type ComposeOutputFormat,
+} from "../../utils/validation/outputs";
+import { formatValidationIssues } from "../../utils/validation/issues";
 
-type FormatId = "compose" | "docker-run" | "systemd" | "env" | "komodo";
+type FormatId = Exclude;
 
 interface FormatTab {
   id: FormatId;
@@ -44,6 +43,8 @@ export interface CodePanelProps {
 export function CodePanel({
   yaml,
   services,
+  networks,
+  volumes,
   vpn,
   validationError,
   validationSuccess: _validationSuccess,
@@ -52,22 +53,21 @@ export function CodePanel({
   const [format, setFormat] = useState("compose");
   const [copied, setCopied] = useState(false);
 
-  const output = useMemo(() => {
-    switch (format) {
-      case "compose":
-        return yaml;
-      case "docker-run":
-        return services.map((s) => convertToDockerRun(s)).join("\n\n");
-      case "systemd":
-        return services.map((s) => convertToSystemd(s)).join("\n\n");
-      case "env":
-        return generateEnvFile(services, vpn);
-      case "komodo":
-        return generateKomodoToml(yaml);
-      default:
-        return "";
-    }
-  }, [format, yaml, services, vpn]);
+  const validatedOutput = useMemo(
+    () =>
+      createValidatedComposeOutput(format, {
+        services,
+        networks,
+        volumes,
+        vpn,
+        yaml,
+      }),
+    [format, services, networks, volumes, vpn, yaml],
+  );
+
+  const output = validatedOutput.ok
+    ? validatedOutput.output
+    : invalidPanelOutput(validatedOutput.issues);
 
   const filename =
     FORMATS.find((f) => f.id === format)?.filename || "compose.yml";
@@ -78,16 +78,20 @@ export function CodePanel({
   );
 
   const handleCopy = async () => {
+    if (!validatedOutput.ok) return;
     try {
-      await copyToClipboard(output);
+      await copyToClipboard(validatedOutput.output);
       setCopied(true);
       window.setTimeout(() => setCopied(false), 2000);
     } catch {
-      // ignore — browser may have denied clipboard access
+      // ignore - browser may have denied clipboard access
     }
   };
 
-  const statusKind: "ok" | "err" = validationError ? "err" : "ok";
+  const activeValidationError =
+    validationError ||
+    (!validatedOutput.ok ? formatValidationIssues(validatedOutput.issues) : null);
+  const statusKind: "ok" | "err" = activeValidationError ? "err" : "ok";
 
   return (
     
@@ -102,7 +106,7 @@ export function CodePanel({ onClick={onValidate} title="Validate & reformat" > - {validationError ? ( + {activeValidationError ? ( <> Re-validate @@ -136,6 +140,7 @@ export function CodePanel({ type="button" className="code-copy" onClick={handleCopy} + disabled={!validatedOutput.ok} aria-label="Copy to clipboard" > {copied ? : } @@ -144,11 +149,11 @@ export function CodePanel({
- {validationError ? validationError : "Valid"} + {activeValidationError ? activeValidationError : "Valid"} {lineCount} {lineCount === 1 ? "line" : "lines"} · UTF-8 · LF @@ -160,10 +165,20 @@ export function CodePanel({ ); } +function invalidPanelOutput(issues: { path: string; message: string }[]): string { + return [ + "# Output blocked by validation", + ...issues.map((issue) => { + const label = issue.path ? `${issue.path}: ` : ""; + return `# ${label}${issue.message}`; + }), + ].join("\n"); +} + /** * Lightweight syntax-aware preview. Uses simple regex to highlight YAML keys * and values without pulling in CodeMirror (which is reserved for the - * editable surfaces — TemplateDetailModal and the convert dialog). + * editable surfaces - TemplateDetailModal and the convert dialog). */ function CodeView({ text }: { text: string }) { const lines = useMemo(() => text.split("\n"), [text]); diff --git a/src/components/compose-builder/ConfigColumn/ServicesTab.tsx b/src/components/compose-builder/ConfigColumn/ServicesTab.tsx index 01efdcd..26957c9 100644 --- a/src/components/compose-builder/ConfigColumn/ServicesTab.tsx +++ b/src/components/compose-builder/ConfigColumn/ServicesTab.tsx @@ -55,10 +55,9 @@ export function ServicesTab({ const displayName = svc.name?.trim() || ""; const imageName = svc.image?.trim() || ""; const meta = imageName || "no image specified"; - // Unnamed-and-imageless rows are the placeholder slot; the user - // can't usefully delete it (we'd just regenerate one). Hide the - // trash icon to keep that affordance honest. - const showTrash = Boolean(displayName || imageName); + // Allow deleting manually added blank rows. Only the last + // remaining service is protected so the builder is never stranded. + const showTrash = services.length > 1; return (
{ + if (!template?.composeContent) return { content: "", removed: false }; + return sanitizeComposeVersion(template.composeContent); + }, [template?.composeContent]); + + const composeContent = sanitizedCompose.content; + // Pre-import validation: parse the compose YAML and flag any problems // before the user can pull it into their workspace. const validation = useMemo(() => { - if (!template?.composeContent) return { ok: true, errors: [] as string[] }; - return validateComposeYaml(template.composeContent); - }, [template?.composeContent]); + if (!composeContent) return { ok: true, errors: [] as string[] }; + return validateComposeYaml(composeContent); + }, [composeContent]); const handleCopy = async () => { - if (!template?.composeContent) return; + if (!template || !composeContent) return; try { - await copyToClipboard(template.composeContent); + await copyToClipboard(composeContent); toast({ title: "Copied to clipboard", description: "Docker Compose content has been copied", @@ -76,11 +85,11 @@ export function TemplateDetailModal({ }; const handleDownload = () => { - if (!template?.composeContent) return; + if (!template || !composeContent) return; try { downloadFile( - template.composeContent, + composeContent, `${template.name.toLowerCase().replace(/\s+/g, "-")}-compose.yml`, "text/yaml" ); @@ -111,7 +120,7 @@ export function TemplateDetailModal({ setImporting(true); try { - await onImport(template); + await onImport({ ...template, composeContent }); toast({ title: "Template imported", description: `${template.name} has been imported successfully`, @@ -287,12 +296,12 @@ export function TemplateDetailModal({ - {template.composeContent ? ( + {composeContent ? (
{}} height={undefined} width={undefined} diff --git a/src/hooks/useConversionDialog.ts b/src/hooks/useConversionDialog.ts index b59646d..f8bb5a8 100644 --- a/src/hooks/useConversionDialog.ts +++ b/src/hooks/useConversionDialog.ts @@ -1,18 +1,22 @@ import { useState, useCallback } from "react"; -import type { ServiceConfig } from "../types/compose"; +import type { + NetworkConfig, + ServiceConfig, + VolumeConfig, +} from "../types/compose"; import type { VPNConfig } from "../types/vpn-configs"; import { - convertToDockerRun, - convertToSystemd, - generateKomodoToml, - generateEnvFile, -} from "../utils/converters"; -import { redactSensitiveData } from "../utils/validation"; + createValidatedComposeOutput, + invalidOutputMessage, + type ComposeOutputFormat, +} from "../utils/validation/outputs"; export type ConversionType = "docker-run" | "systemd" | "env" | "redact" | "komodo"; export interface UseConversionDialogOptions { services: ServiceConfig[]; + networks?: NetworkConfig[]; + volumes?: VolumeConfig[]; selectedIdx: number | null; yaml: string; vpnConfig: VPNConfig; @@ -20,6 +24,8 @@ export interface UseConversionDialogOptions { export function useConversionDialog({ services, + networks = [], + volumes = [], selectedIdx, yaml, vpnConfig, @@ -27,48 +33,26 @@ export function useConversionDialog({ const [conversionDialogOpen, setConversionDialogOpen] = useState(false); const [conversionType, setConversionType] = useState(""); const [conversionOutput, setConversionOutput] = useState(""); + const [conversionValid, setConversionValid] = useState(true); const handleConversion = useCallback( (type: ConversionType) => { setConversionType(type); - let output: string; - try { - switch (type) { - case "docker-run": - if (selectedIdx !== null && services[selectedIdx]) { - output = convertToDockerRun(services[selectedIdx]); - } else { - output = services.map((s) => convertToDockerRun(s)).join("\n\n"); - } - break; - case "systemd": - if (selectedIdx !== null && services[selectedIdx]) { - output = convertToSystemd(services[selectedIdx]); - } else { - output = services.map((s) => convertToSystemd(s)).join("\n\n"); - } - break; - case "env": - output = generateEnvFile(services, vpnConfig); - break; - case "redact": - output = redactSensitiveData(yaml); - break; - case "komodo": - output = generateKomodoToml(yaml); - break; - default: - output = "Unknown conversion type"; - } - setConversionOutput(output); - setConversionDialogOpen(true); - } catch (error: any) { - setConversionOutput(`Error: ${error.message}`); - setConversionDialogOpen(true); - } + const result = createValidatedComposeOutput(type as ComposeOutputFormat, { + services, + networks, + volumes, + selectedIdx, + yaml, + vpn: vpnConfig, + }); + + setConversionValid(result.ok); + setConversionOutput(result.ok ? result.output : invalidOutputMessage(result)); + setConversionDialogOpen(true); }, - [services, selectedIdx, yaml, vpnConfig] + [services, networks, volumes, selectedIdx, yaml, vpnConfig], ); const closeDialog = useCallback(() => { @@ -80,6 +64,7 @@ export function useConversionDialog({ setConversionDialogOpen, conversionType, conversionOutput, + conversionValid, handleConversion, closeDialog, }; diff --git a/src/hooks/useTemplateStore.ts b/src/hooks/useTemplateStore.ts index e8d3673..88a3cbf 100644 --- a/src/hooks/useTemplateStore.ts +++ b/src/hooks/useTemplateStore.ts @@ -1,21 +1,34 @@ -import { useCallback, useState } from "react"; +import { + useCallback, + useState, + type Dispatch, + type SetStateAction, +} from "react"; +import { + validateTemplateDetails, + validateTemplateMetaList, + type TemplateDetails, + type TemplateMeta, +} from "../utils/validation/templates"; +import { formatValidationIssues } from "../utils/validation/issues"; +import { sanitizeComposeVersion } from "../utils/compose-version"; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type Template = any; +type Template = TemplateMeta; +type SelectedTemplate = TemplateDetails | null; export interface UseTemplateStoreReturn { templateStoreOpen: boolean; setTemplateStoreOpen: (open: boolean) => void; templates: Template[]; - setTemplates: React.Dispatch>; + setTemplates: Dispatch>; templateLoading: boolean; setTemplateLoading: (loading: boolean) => void; templateError: string | null; setTemplateError: (error: string | null) => void; templateSearch: string; setTemplateSearch: (search: string) => void; - selectedTemplate: Template; - setSelectedTemplate: (template: Template) => void; + selectedTemplate: SelectedTemplate; + setSelectedTemplate: (template: SelectedTemplate) => void; templateDetailOpen: boolean; setTemplateDetailOpen: (open: boolean) => void; templateDetailTab: "overview" | "compose"; @@ -23,7 +36,7 @@ export interface UseTemplateStoreReturn { templateCache: Template[]; templateCacheTimestamp: number | null; fetchTemplatesFromGitHub: (backgroundUpdate?: boolean) => Promise; - fetchTemplateDetails: (templateId: string) => Promise