From 56396713d68fe9847717f82650a1e65782cdd86e Mon Sep 17 00:00:00 2001 From: Peter Gonda Date: Mon, 28 Sep 2026 12:41:05 +0200 Subject: [PATCH 1/3] feat(packs): configure pack variables before deploy in the gallery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nomad Pack gallery deployed with defaults only. Now picking a pack opens a configure step: it fetches the registry's per-pack JSON (api.nomad.getNomadPack) and renders a form from the pack's variable schema — scalar vars only, plumbing vars (job_name/namespace/datacenters/constraints) hidden, secrets masked, placeholder ("change-me") vars required. The chosen values are serialized to the pack var-file (composeFile, HCL) so `nomad-pack run --var-file` picks them up. Quick facts (tasks/ports/bundled DB) are shown; packs with no schema fall back to "create with defaults". Also adds optional deep-link props (defaultOpen / initialPackName / initialRegistryUrl) for a future "Deploy to Nomploy" link. Backend query getNomadPack already landed in main (cea80c121). Co-Authored-By: Claude Opus 4.8 --- .../components/dashboard/project/add-pack.tsx | 597 ++++++++++++++---- 1 file changed, 459 insertions(+), 138 deletions(-) diff --git a/apps/dokploy/components/dashboard/project/add-pack.tsx b/apps/dokploy/components/dashboard/project/add-pack.tsx index 1a5690d81..fb995287e 100644 --- a/apps/dokploy/components/dashboard/project/add-pack.tsx +++ b/apps/dokploy/components/dashboard/project/add-pack.tsx @@ -1,5 +1,11 @@ -import { Box, ExternalLink, Loader2, SearchIcon } from "lucide-react"; -import { useState } from "react"; +import { + ArrowLeft, + Box, + ExternalLink, + Loader2, + SearchIcon, +} from "lucide-react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; import { Badge } from "@/components/ui/badge"; @@ -29,6 +35,11 @@ import { api } from "@/utils/api"; interface Props { environmentId: string; projectName?: string; + // Deep-link support (e.g. a "Deploy to Nomploy" link from the registry site): + // open straight into this pack's configure step. + defaultOpen?: boolean; + initialPackName?: string; + initialRegistryUrl?: string; } // A Nomad Pack registry is a git repo of packs. Default to Nomploy's own; the @@ -80,35 +91,133 @@ const PackLogo = ({ name }: { name: string }) => { ); }; +// Plumbing variables the user should never edit from the form — they're managed +// by the platform (constraints/datacenters) or the deployment name (job_name). +const HIDDEN_VARS = new Set([ + "job_name", + "namespace", + "datacenters", + "constraints", +]); +// Only scalar variables get a form field; list/object vars (resources, disks…) +// keep their pack defaults. +const EDITABLE_KINDS = new Set(["string", "number", "bool"]); + +type PackVar = { + name: string; + description?: string; + type?: string; + kind?: string; + default?: string; + placeholder?: boolean; + sensitive?: boolean; +}; + +const isEditable = (v: PackVar) => + !HIDDEN_VARS.has(v.name) && EDITABLE_KINDS.has(v.kind ?? ""); + +// A variable's `default` is a raw HCL literal: strings are JSON-style quoted +// ("\"immich\""), numbers/bools are bare. Turn it into a display value. +const displayDefault = (v: PackVar): string => { + const def = v.default ?? ""; + if (v.kind === "string") { + try { + const parsed = JSON.parse(def); + return typeof parsed === "string" ? parsed : def; + } catch { + return def.replace(/^"|"$/g, ""); + } + } + return def; +}; + +// Turn a form value back into an HCL literal for the var-file. +const toHclLiteral = (v: PackVar, value: string): string => { + if (v.kind === "number") return value.trim(); + if (v.kind === "bool") return value === "true" ? "true" : "false"; + return JSON.stringify(value); // properly escapes quotes/newlines +}; + +type PackSummary = { + name: string; + description: string; + version: string; + url: string; +}; + /** - * Browse a Nomad Pack registry and one-click deploy a pack — the pack equivalent of - * the template gallery. Creates a composeType="nomad-pack" service pointed at the - * chosen pack + registry (then the user deploys it). + * Browse a Nomad Pack registry, configure a pack's variables, and deploy it — + * the pack equivalent of the template gallery. Picking a pack opens a form built + * from the pack's variable schema (from the registry's per-pack JSON API); the + * filled values are written to the service's pack var-file (composeFile, HCL), + * then a composeType="nomad-pack" service is created (the user hits Deploy). */ -export const AddPack = ({ environmentId, projectName }: Props) => { +export const AddPack = ({ + environmentId, + projectName, + defaultOpen = false, + initialPackName, + initialRegistryUrl, +}: Props) => { const utils = api.useUtils(); const slug = slugify(projectName); - const [open, setOpen] = useState(false); - const [registry, setRegistry] = useState(REGISTRIES[0].url); - const [custom, setCustom] = useState(""); + const [open, setOpen] = useState(defaultOpen); + const [registry, setRegistry] = useState( + initialRegistryUrl && REGISTRIES.some((r) => r.url === initialRegistryUrl) + ? initialRegistryUrl + : initialRegistryUrl + ? "__custom__" + : REGISTRIES[0].url, + ); + const [custom, setCustom] = useState( + initialRegistryUrl && !REGISTRIES.some((r) => r.url === initialRegistryUrl) + ? initialRegistryUrl + : "", + ); const [search, setSearch] = useState(""); - const [pendingPack, setPendingPack] = useState(null); + const [selected, setSelected] = useState( + initialPackName + ? { name: initialPackName, description: "", version: "", url: "" } + : null, + ); + const [values, setValues] = useState>({}); const registryUrl = registry === "__custom__" ? custom.trim() : registry; const { data: packs, isFetching } = api.nomad.listNomadPacks.useQuery( { registryUrl: registryUrl || undefined }, { enabled: open && !!registryUrl }, ); + const { data: detail, isFetching: detailLoading } = + api.nomad.getNomadPack.useQuery( + { registryUrl: registryUrl || undefined, id: selected?.name ?? "" }, + { enabled: open && !!selected?.name }, + ); const create = api.compose.create.useMutation(); + // Seed the form with each variable's default once the schema loads. + useEffect(() => { + if (!detail?.variables) return; + const seed: Record = {}; + for (const v of detail.variables as PackVar[]) { + if (isEditable(v)) seed[v.name] = displayDefault(v); + } + setValues(seed); + }, [detail]); + const filtered = (packs ?? []).filter( (p) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()), ); - const deploy = async (packName: string) => { - setPendingPack(packName); + const reset = () => { + setSelected(null); + setValues({}); + }; + + const deploy = async (composeFile?: string) => { + if (!selected) return; + const packName = selected.name; try { await create.mutateAsync({ name: packName, @@ -118,20 +227,70 @@ export const AddPack = ({ environmentId, projectName }: Props) => { nomadPack: packName, // Blank registry = the community default (handled by the deploy builder). nomadPackRegistry: registryUrl === REGISTRIES[1].url ? "" : registryUrl, + ...(composeFile ? { composeFile } : {}), }); toast.success(`Created "${packName}" — open it and hit Deploy`); setOpen(false); + reset(); await utils.environment.one.invalidate({ environmentId }); await utils.project.all.invalidate(); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to create"); - } finally { - setPendingPack(null); } }; + const deployWithValues = async () => { + const vars = ((detail?.variables as PackVar[]) ?? []).filter(isEditable); + // Force placeholder (change-me) variables to actually be changed. + for (const v of vars) { + if (v.placeholder && (values[v.name] ?? "") === displayDefault(v)) { + toast.error(`Please set a value for "${v.name}"`); + return; + } + if ( + v.kind === "number" && + (values[v.name] ?? "").trim() !== "" && + Number.isNaN(Number(values[v.name])) + ) { + toast.error(`"${v.name}" must be a number`); + return; + } + } + // Only write variables that differ from the default, plus sensitive / + // placeholder ones — keeps the var-file minimal but always sets secrets. + const lines: string[] = []; + for (const v of vars) { + const def = displayDefault(v); + const cur = values[v.name] ?? def; + if (cur === "" && v.kind !== "string") continue; + if (cur !== def || v.sensitive || v.placeholder) { + lines.push(`${v.name} = ${toHclLiteral(v, cur)}`); + } + } + await deploy(lines.length ? `${lines.join("\n")}\n` : undefined); + }; + + const facts = detail?.facts as + | { + ports?: { name: string; port: string }[]; + tasks?: number; + volumeNames?: string[]; + bundledDb?: boolean; + } + | null + | undefined; + const editableVars = ((detail?.variables as PackVar[]) ?? []).filter( + isEditable, + ); + return ( - + { + setOpen(o); + if (!o) reset(); + }} + > { - -
-
- Deploy a Nomad Pack - - Browse a pack registry and create a service from a pack. - -
-
-
- - -
- {registry === "__custom__" && ( -
- - setCustom(e.target.value)} - className="w-full sm:w-[240px]" - /> + {selected ? ( + // ── Configure step ──────────────────────────────────────────────── + <> + +
+
+ +
+
+ + {detail?.name ?? selected.name} + {detail?.version && ( + + {detail.version} + + )} + + + {detail?.description || + selected.description || + "Configure and deploy this pack."} + + {facts && ( +
+ {typeof facts.tasks === "number" && ( + + {facts.tasks} task{facts.tasks === 1 ? "" : "s"} + + )} + {facts.bundledDb && ( + + bundled DB + + )} + {(facts.ports ?? []).slice(0, 4).map((p) => ( + + {p.name}:{p.port} + + ))} +
+ )}
- )} -
- - setSearch(e.target.value)} - className="w-full sm:w-[220px]" - />
-
-
- + - -
- {create.isError && ( - - {create.error?.message} - - )} + +
+ {create.isError && ( + {create.error?.message} + )} + {detailLoading ? ( +
+ + Loading configuration… +
+ ) : editableVars.length === 0 ? ( + + This pack exposes no configurable variables here (or the + registry has no variable schema). You can create it with its + built-in defaults and edit variables afterwards. + + ) : ( + editableVars.map((v) => ( +
+ + {v.kind === "bool" ? ( + + ) : ( + + setValues((s) => ({ + ...s, + [v.name]: e.target.value, + })) + } + /> + )} + {v.description && ( +

+ {v.description} +

+ )} +
+ )) + )} +
+
- {isFetching ? ( -
- - Loading packs… +
+ +
+ {editableVars.length === 0 && ( + + )} + {editableVars.length > 0 && ( + + )}
- ) : filtered.length === 0 ? ( -
- -

- No packs found (is nomad-pack available and the registry - reachable?). -

+
+ + ) : ( + // ── Browse step ─────────────────────────────────────────────────── + <> + +
+
+ Deploy a Nomad Pack + + Browse a pack registry, configure it, and create a service. + +
+
+
+ + +
+ {registry === "__custom__" && ( +
+ + setCustom(e.target.value)} + className="w-full sm:w-[240px]" + /> +
+ )} +
+ + setSearch(e.target.value)} + className="w-full sm:w-[220px]" + /> +
+
- ) : ( -
- {filtered.map((p) => ( -
-
-
- -
-
-
- - {p.name} - - {p.version && ( - - {p.version} - - )} + + + +
+ {isFetching ? ( +
+ + Loading packs… +
+ ) : filtered.length === 0 ? ( +
+ +

+ No packs found (is nomad-pack available and the registry + reachable?). +

+
+ ) : ( +
+ {filtered.map((p) => ( +
+
+
+ +
+
+
+ + {p.name} + + {p.version && ( + + {p.version} + + )} +
+ {p.url && ( + + Homepage + + )} +
- {p.url && ( - - Homepage - - )} +

+ {p.description || "No description provided."} +

+
-
-

- {p.description || "No description provided."} -

- + ))}
- ))} + )}
- )} -
- + + + )}
); From 447d18f7c09cba95ae908649e354e7d0b5336ba6 Mon Sep 17 00:00:00 2001 From: Peter Gonda Date: Mon, 28 Sep 2026 12:55:01 +0200 Subject: [PATCH 2/3] feat(packs): deploy-pack landing page for registry deep-links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /dashboard/deploy-pack?pack=®istry= — the target of a "Deploy to Nomploy" link from a pack registry (e.g. packs.nomploy.com). The logged-in user picks a project + environment (auto-selected when there's only one), then the Nomad Pack configure dialog opens pre-filled with that pack; on success it jumps to the environment. AddPack gains `hideTrigger` (render standalone, outside a dropdown menu, driven by defaultOpen) and `onCreated` (host page redirect) to support this. Co-Authored-By: Claude Opus 4.8 --- .../components/dashboard/project/add-pack.tsx | 28 ++- apps/dokploy/pages/dashboard/deploy-pack.tsx | 234 ++++++++++++++++++ 2 files changed, 253 insertions(+), 9 deletions(-) create mode 100644 apps/dokploy/pages/dashboard/deploy-pack.tsx diff --git a/apps/dokploy/components/dashboard/project/add-pack.tsx b/apps/dokploy/components/dashboard/project/add-pack.tsx index fb995287e..087c27d20 100644 --- a/apps/dokploy/components/dashboard/project/add-pack.tsx +++ b/apps/dokploy/components/dashboard/project/add-pack.tsx @@ -40,6 +40,11 @@ interface Props { defaultOpen?: boolean; initialPackName?: string; initialRegistryUrl?: string; + // When rendered outside a dropdown menu (e.g. the deploy-pack landing page), + // hide the DropdownMenuItem trigger and drive the dialog via defaultOpen. + hideTrigger?: boolean; + // Called after a service is successfully created (lets a host page redirect). + onCreated?: () => void; } // A Nomad Pack registry is a git repo of packs. Default to Nomploy's own; the @@ -158,6 +163,8 @@ export const AddPack = ({ defaultOpen = false, initialPackName, initialRegistryUrl, + hideTrigger = false, + onCreated, }: Props) => { const utils = api.useUtils(); const slug = slugify(projectName); @@ -234,6 +241,7 @@ export const AddPack = ({ reset(); await utils.environment.one.invalidate({ environmentId }); await utils.project.all.invalidate(); + onCreated?.(); } catch (e) { toast.error(e instanceof Error ? e.message : "Failed to create"); } @@ -291,15 +299,17 @@ export const AddPack = ({ if (!o) reset(); }} > - - e.preventDefault()} - > - - Nomad Pack - - + {!hideTrigger && ( + + e.preventDefault()} + > + + Nomad Pack + + + )} {selected ? ( // ── Configure step ──────────────────────────────────────────────── diff --git a/apps/dokploy/pages/dashboard/deploy-pack.tsx b/apps/dokploy/pages/dashboard/deploy-pack.tsx new file mode 100644 index 000000000..949fd367e --- /dev/null +++ b/apps/dokploy/pages/dashboard/deploy-pack.tsx @@ -0,0 +1,234 @@ +import { validateRequest } from "@nomploy/server/lib/auth"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import { Box, Loader2 } from "lucide-react"; +import type { GetServerSidePropsContext } from "next"; +import { useRouter } from "next/router"; +import { type ReactElement, useEffect, useMemo, useState } from "react"; +import superjson from "superjson"; +import { AddPack } from "@/components/dashboard/project/add-pack"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { appRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +const DEFAULT_REGISTRY = "github.com/Nomploy/nomad-packs"; + +/** + * Landing page for a "Deploy to Nomploy" deep-link from a pack registry + * (e.g. packs.nomploy.com). The link carries ?pack=®istry=; the + * logged-in user picks a project + environment, then the Nomad Pack configure + * dialog opens pre-filled with that pack. On success we jump to the environment. + */ +const DeployPack = () => { + const router = useRouter(); + const pack = typeof router.query.pack === "string" ? router.query.pack : ""; + const registry = + typeof router.query.registry === "string" && router.query.registry + ? router.query.registry + : DEFAULT_REGISTRY; + + const { data: projects, isLoading } = api.project.all.useQuery(); + const [projectId, setProjectId] = useState(""); + const [environmentId, setEnvironmentId] = useState(""); + const [mountKey, setMountKey] = useState(null); + + const projectList = useMemo( + () => + (projects ?? []).map((p) => ({ + projectId: p.projectId, + name: p.name, + environments: (p.environments ?? []).map((e) => ({ + environmentId: e.environmentId, + name: e.name, + })), + })), + [projects], + ); + const selectedProject = projectList.find((p) => p.projectId === projectId); + const environments = selectedProject?.environments ?? []; + + // Preselect when there's an obvious single choice. + useEffect(() => { + if (!projectId && projectList.length === 1) { + setProjectId(projectList[0].projectId); + } + }, [projectList, projectId]); + useEffect(() => { + setEnvironmentId( + environments.length === 1 ? environments[0].environmentId : "", + ); + setMountKey(null); + }, [projectId]); + + if (!pack) { + return ( +
+ + + No pack specified + + This page expects a ?pack=<id> query parameter, + usually from a "Deploy to Nomploy" link on a pack registry. + + + + + + +
+ ); + } + + return ( +
+ + +
+
+ {/* biome-ignore lint/performance/noImgElement: external CDN logo */} + {pack} { + (e.currentTarget as HTMLImageElement).style.display = "none"; + }} + /> + +
+
+ Deploy {pack} + + Choose where to deploy this Nomad Pack, then configure it. + +
+
+
+ + {isLoading ? ( +
+ Loading projects… +
+ ) : projectList.length === 0 ? ( +

+ You have no projects yet. Create one first, then come back to this + link. +

+ ) : ( + <> +
+ + +
+ +
+ + +
+ + + + )} +
+
+ + {mountKey && environmentId && ( + + router.push( + `/dashboard/project/${projectId}/environment/${environmentId}`, + ) + } + /> + )} +
+ ); +}; + +export default DeployPack; + +DeployPack.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const { req, res } = ctx; + const { user, session } = await validateRequest(req); + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + + if (!user) { + return { redirect: { permanent: false, destination: "/" } }; + } + await helpers.settings.isCloud.prefetch(); + await helpers.user.get.prefetch(); + await helpers.project.all.prefetch(); + + return { props: { trpcState: helpers.dehydrate() } }; +} From 3822675133111ee5408590ab932e7b4a5f3c0271 Mon Sep 17 00:00:00 2001 From: Peter Gonda Date: Wed, 30 Sep 2026 09:28:38 +0200 Subject: [PATCH 3/3] fix(packs): satisfy noUncheckedIndexedAccess in deploy-pack preselect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Guard projectList[0] / environments[0] behind a local so tsc narrows them — the surrounding length checks already made these runtime-safe, but the prod build (tsconfig noUncheckedIndexedAccess) flagged them. Co-Authored-By: Claude Opus 4.8 --- apps/dokploy/pages/dashboard/deploy-pack.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/dokploy/pages/dashboard/deploy-pack.tsx b/apps/dokploy/pages/dashboard/deploy-pack.tsx index 949fd367e..a198c9bfb 100644 --- a/apps/dokploy/pages/dashboard/deploy-pack.tsx +++ b/apps/dokploy/pages/dashboard/deploy-pack.tsx @@ -64,14 +64,14 @@ const DeployPack = () => { // Preselect when there's an obvious single choice. useEffect(() => { - if (!projectId && projectList.length === 1) { - setProjectId(projectList[0].projectId); + const only = projectList.length === 1 ? projectList[0] : undefined; + if (!projectId && only) { + setProjectId(only.projectId); } }, [projectList, projectId]); useEffect(() => { - setEnvironmentId( - environments.length === 1 ? environments[0].environmentId : "", - ); + const only = environments.length === 1 ? environments[0] : undefined; + setEnvironmentId(only ? only.environmentId : ""); setMountKey(null); }, [projectId]);