From 2d0b8a49186dd1e136dfa3b2bf155ec83774355b Mon Sep 17 00:00:00 2001 From: hhftechnologies Date: Tue, 19 May 2026 22:13:03 +0530 Subject: [PATCH] bugfixes-ui-updates --- src/components/Header.tsx | 2 +- src/components/compose-builder/CodePanel.tsx | 15 ++-- src/components/ui/alert.tsx | 2 +- src/components/ui/toast.tsx | 12 +-- src/routes/blueprint-builder.tsx | 88 ++++++++++++++----- src/routes/config-builder.tsx | 9 +- .../blueprint/__tests__/generator.test.ts | 41 +++++++++ src/utils/blueprint/generator.ts | 26 +++++- .../__tests__/outputs-extra.test.ts | 17 ++++ src/utils/validation/scheduler.ts | 18 +++- 10 files changed, 181 insertions(+), 49 deletions(-) diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 72d92a4..c96c618 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -86,7 +86,7 @@ export function Header({ onOpenMarket }: HeaderProps) { { - if (!validatedOutput.ok) return; + if (!canCopy) return; try { await copyToClipboard(validatedOutput.output); setCopied(true); @@ -88,11 +94,6 @@ export function CodePanel({ } }; - const activeValidationError = - validationError || - (!validatedOutput.ok ? formatValidationIssues(validatedOutput.issues) : null); - const statusKind: "ok" | "err" = activeValidationError ? "err" : "ok"; - return (
@@ -140,7 +141,7 @@ export function CodePanel({ type="button" className="code-copy" onClick={handleCopy} - disabled={!validatedOutput.ok} + disabled={!canCopy} aria-label="Copy to clipboard" > {copied ? : } diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index 74f85d6..cd78036 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -10,7 +10,7 @@ const alertVariants = cva( variant: { default: "bg-card text-card-foreground", destructive: - "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + "border-red-600 bg-red-50 text-red-950 dark:border-red-400 dark:bg-red-950 dark:text-red-50 [&>svg]:text-current *:data-[slot=alert-description]:text-red-900 dark:*:data-[slot=alert-description]:text-red-100", }, }, defaultVariants: { diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index bbf938d..e2f1d33 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -70,16 +70,16 @@ function ToastContainer() { function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) { const variantStyles = { - default: "bg-background border-border", - success: "bg-green-500/10 border-green-500/50 text-green-700 dark:text-green-400", - error: "bg-red-500/10 border-red-500/50 text-red-700 dark:text-red-400", - warning: "bg-yellow-500/10 border-yellow-500/50 text-yellow-700 dark:text-yellow-400", + default: "bg-background border-border text-foreground", + success: "bg-emerald-50 border-emerald-600 text-emerald-950 dark:bg-emerald-950 dark:border-emerald-400 dark:text-emerald-50", + error: "bg-red-50 border-red-600 text-red-950 dark:bg-red-950 dark:border-red-400 dark:text-red-50", + warning: "bg-amber-50 border-amber-600 text-amber-950 dark:bg-amber-950 dark:border-amber-400 dark:text-amber-50", } return (
{toast.title}
)} {toast.description && ( -
{toast.description}
+
{toast.description}
)}
{toast.action} diff --git a/src/routes/blueprint-builder.tsx b/src/routes/blueprint-builder.tsx index e3e6037..3714353 100644 --- a/src/routes/blueprint-builder.tsx +++ b/src/routes/blueprint-builder.tsx @@ -16,6 +16,7 @@ import { TemplateDetailModal } from "../components/templates/TemplateDetailModal import { Field, ValidatedInput } from "../components/compose-builder/ServiceForm/Field"; import { useTemplateStore } from "../hooks/useTemplateStore"; import { + defaultAuth, defaultBlueprint, defaultHealthcheck, defaultPrivateResource, @@ -55,6 +56,25 @@ export const Route = createFileRoute("/blueprint-builder")({ type OutputTab = "compose" | "env"; +function nextUnusedSuffix(prefix: string, keys: string[]): number { + const used = new Set(); + const re = new RegExp(`^${prefix}-(\\d+)$`); + keys.forEach((key) => { + const match = re.exec(key); + if (!match) return; + const suffix = Number(match[1]); + if (Number.isInteger(suffix) && suffix > 0) used.add(suffix); + }); + + let suffix = 1; + while (used.has(suffix)) suffix += 1; + return suffix; +} + +function stripTargetMethod(target: BlueprintTarget): BlueprintTarget { + return { ...target, method: undefined }; +} + function BlueprintBuilderRoute() { const navigate = useNavigate(); const { toast } = useToast(); @@ -139,18 +159,26 @@ function BlueprintBuilderRoute() { ); const addPrivateResource = useCallback(() => { - setBlueprint((bp) => ({ - ...bp, - privateResources: [ - ...(bp.privateResources ?? []), - { - ...defaultPrivateResource(defaultLabelService), - key: `private-${(bp.privateResources?.length ?? 0) + 1}`, - name: `Private ${(bp.privateResources?.length ?? 0) + 1}`, - alias: `private-${(bp.privateResources?.length ?? 0) + 1}`, - }, - ], - })); + setBlueprint((bp) => { + const privateResources = bp.privateResources ?? []; + const suffix = nextUnusedSuffix( + "private", + privateResources.map((resource) => resource.key), + ); + const key = `private-${suffix}`; + return { + ...bp, + privateResources: [ + ...privateResources, + { + ...defaultPrivateResource(defaultLabelService), + key, + name: `Private ${suffix}`, + alias: key, + }, + ], + }; + }); }, [defaultLabelService]); const removePrivateResource = useCallback((idx: number) => { @@ -169,17 +197,24 @@ function BlueprintBuilderRoute() { }, []); const addSite = useCallback(() => { - setBlueprint((bp) => ({ - ...bp, - sites: [ - ...(bp.sites ?? []), - { - ...defaultSite(defaultLabelService), - key: `site-${(bp.sites?.length ?? 0) + 1}`, - name: `Site ${(bp.sites?.length ?? 0) + 1}`, - }, - ], - })); + setBlueprint((bp) => { + const sites = bp.sites ?? []; + const suffix = nextUnusedSuffix( + "site", + sites.map((site) => site.key), + ); + return { + ...bp, + sites: [ + ...sites, + { + ...defaultSite(defaultLabelService), + key: `site-${suffix}`, + name: `Site ${suffix}`, + }, + ], + }; + }); }, [defaultLabelService]); const removeSite = useCallback((idx: number) => { @@ -202,6 +237,7 @@ function BlueprintBuilderRoute() { suffix += 1; } resource.blueprintName = nextName; + resource.subdomain = nextName; return { ...bp, resources: [...bp.resources, resource] }; }); setSelectedIdx(blueprint.resources.length); @@ -868,6 +904,12 @@ function ResourceForm({ onChange({ protocol, proxyPort: protocol === "http" ? undefined : (resource.proxyPort ?? resource.servicePort), + ...(protocol !== "http" + ? { + auth: defaultAuth(), + extraTargets: resource.extraTargets.map(stripTargetMethod), + } + : {}), }); }} > diff --git a/src/routes/config-builder.tsx b/src/routes/config-builder.tsx index 3187bfc..8d6f098 100644 --- a/src/routes/config-builder.tsx +++ b/src/routes/config-builder.tsx @@ -34,7 +34,6 @@ function ConfigBuilderRoute() { const [config, setConfig] = useState({ items: [] }); const [customOutput, setCustomOutput] = useState(""); const [currentItem, setCurrentItem] = useState(blankItem()); - const [itemSubmitted, setItemSubmitted] = useState(false); const generateHomepageConfig = useCallback( (items: ConfigItem[]): string => { @@ -74,12 +73,10 @@ function ConfigBuilderRoute() { }, [configType, config.items, customOutput, generateHomepageConfig, outputValidation]); const addItem = useCallback(() => { - setItemSubmitted(true); const validation = validateConfigItem(currentItem); if (!validation.ok || !validation.data) return; setConfig({ items: [...config.items, validation.data] }); setCurrentItem(blankItem()); - setItemSubmitted(false); }, [currentItem, config.items]); const removeItem = useCallback( @@ -96,10 +93,8 @@ function ConfigBuilderRoute() { }; const fieldError = (path: string) => - itemSubmitted - ? currentItemValidation.issues.find((issue) => issue.path === path) - ?.message ?? null - : null; + currentItemValidation.issues.find((issue) => issue.path === path)?.message ?? + null; const customYamlError = configType === "custom" && !outputValidation.ok diff --git a/src/utils/blueprint/__tests__/generator.test.ts b/src/utils/blueprint/__tests__/generator.test.ts index 03c6d15..3db4cd8 100644 --- a/src/utils/blueprint/__tests__/generator.test.ts +++ b/src/utils/blueprint/__tests__/generator.test.ts @@ -35,6 +35,19 @@ services: expect(bp.resources[1].image).toBe("postgres:16"); }); + it("keeps uniquified blueprint names and subdomains aligned", () => { + const bp = fromCompose(`services: + app: + image: nginx + app_: + image: nginx +`); + expect(bp.resources[0].blueprintName).toBe("app"); + expect(bp.resources[0].subdomain).toBe("app"); + expect(bp.resources[1].blueprintName).toBe("app-2"); + expect(bp.resources[1].subdomain).toBe("app-2"); + }); + it("uses an https target method for port 443 / 8443", () => { const bp = fromCompose(`services: s: @@ -104,6 +117,34 @@ describe("toComposeYaml", () => { expect(String(yaml)).not.toContain("pangolin.public-resources.worker"); }); + it("removes Pangolin labels from resources disabled after re-import", () => { + const bp = fromCompose( + `services: + web: + image: nginx + ports: + - "80" + worker: + image: busybox + ports: + - "8080" +`, + "example.com", + ); + const yaml = toComposeYaml(bp); + expect(yaml).toContain("pangolin.public-resources.web"); + expect(yaml).toContain("pangolin.public-resources.worker"); + + const bp2 = fromCompose(yaml, "example.com"); + bp2.resources = bp2.resources.filter( + (resource) => resource.serviceContainerName !== "worker", + ); + const finalYaml = toComposeYaml(bp2); + + expect(finalYaml).toContain("pangolin.public-resources.web"); + expect(finalYaml).not.toContain("pangolin.public-resources.worker"); + }); + it("adds the external pangolin network block with a literal name", () => { const bp = fromCompose( `services: diff --git a/src/utils/blueprint/generator.ts b/src/utils/blueprint/generator.ts index 4774179..95c3638 100644 --- a/src/utils/blueprint/generator.ts +++ b/src/utils/blueprint/generator.ts @@ -224,14 +224,14 @@ export function resourceFromComposeService( ? (rawService as Record) : {}; const port = firstServicePort(svc) ?? DEFAULT_SERVICE_PORT; - const sluggedKey = slug(serviceKey) || serviceKey.toLowerCase() || "service"; + const blueprintName = slug(serviceKey) || serviceKey.toLowerCase() || "service"; return { ...defaultResource(), serviceContainerName: serviceKey, - blueprintName: sluggedKey, + blueprintName, resourceName: titleCase(serviceKey) || serviceKey, - subdomain: sluggedKey, + subdomain: blueprintName, servicePort: port, image: typeof svc.image === "string" ? svc.image : "", protocol: "http", @@ -278,6 +278,7 @@ export function fromCompose(yamlContent: string, baseDomain = ""): Blueprint { resource.blueprintName, seenBlueprintNames, ); + resource.subdomain = resource.blueprintName; bp.resources.push(resource); } return bp; @@ -491,6 +492,21 @@ function ensureService( return svc; } +function removeLabelsFromService( + services: Record, + serviceName: string, + removePrefixes: string[], +) { + if (!serviceName || !isPlainObject(services[serviceName])) return; + const svc = services[serviceName] as Record; + const existing = removePangolinLabels(labelsToArray(svc.labels), removePrefixes); + if (existing.length > 0) { + svc.labels = existing; + } else { + delete svc.labels; + } +} + function applyLabelsToService( services: Record, serviceName: string, @@ -523,6 +539,10 @@ export function toComposeYaml(bp: Blueprint): string { ? (baseDoc.services as Record) : ({} as Record); + Object.keys(services).forEach((serviceName) => { + removeLabelsFromService(services, serviceName, ["pangolin.public-resources."]); + }); + for (const r of bp.resources) { applyLabelsToService( services, diff --git a/src/utils/validation/__tests__/outputs-extra.test.ts b/src/utils/validation/__tests__/outputs-extra.test.ts index 5e4da9e..692f8ad 100644 --- a/src/utils/validation/__tests__/outputs-extra.test.ts +++ b/src/utils/validation/__tests__/outputs-extra.test.ts @@ -53,6 +53,23 @@ jobs: ).toBe(true); }); + it("rejects GitHub Actions output without a scheduled cron trigger", () => { + expect( + validateSchedulerOutput( + "github-actions", + `name: Manual +on: + workflow_dispatch: +jobs: + scheduled: + runs-on: ubuntu-latest + steps: + - run: echo ok +`, + ).ok, + ).toBe(false); + }); + it("rejects invalid generated config outputs", () => { expect(validateCustomYaml("valid: true").ok).toBe(true); expect(validateHomepageOutput("services:\n - Name: Bad\n").ok).toBe(false); diff --git a/src/utils/validation/scheduler.ts b/src/utils/validation/scheduler.ts index 967ba8a..42bb9b5 100644 --- a/src/utils/validation/scheduler.ts +++ b/src/utils/validation/scheduler.ts @@ -161,7 +161,23 @@ export function validateSchedulerOutput( if (type === "github-actions") { try { const parsed = yaml.load(output) as Record; - if (!parsed || typeof parsed !== "object" || !("on" in parsed)) { + const onValue = + parsed && typeof parsed === "object" ? parsed.on : undefined; + const schedule = + onValue && typeof onValue === "object" && !Array.isArray(onValue) + ? (onValue as Record).schedule + : undefined; + const hasScheduleCron = + Array.isArray(schedule) && + schedule.some( + (item) => + item !== null && + typeof item === "object" && + !Array.isArray(item) && + typeof (item as Record).cron === "string", + ); + + if (!hasScheduleCron) { issues.push( validationError( "scheduler.output",