mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
feat(templates): add custom base URL support for template management
- Implement dynamic base URL configuration for template fetching - Add localStorage persistence for base URL - Update template rendering to use dynamic base URL - Modify API routes to support optional base URL parameter - Enhance template browsing flexibility
This commit is contained in:
@@ -66,55 +66,53 @@ import {
|
|||||||
SearchIcon,
|
SearchIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface TemplateData {
|
const TEMPLATE_BASE_URL_KEY = "dokploy_template_base_url";
|
||||||
metadata: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
version: string;
|
|
||||||
logo: string;
|
|
||||||
links: {
|
|
||||||
github: string;
|
|
||||||
website?: string;
|
|
||||||
docs?: string;
|
|
||||||
};
|
|
||||||
tags: string[];
|
|
||||||
};
|
|
||||||
variables: {
|
|
||||||
[key: string]: string;
|
|
||||||
};
|
|
||||||
config: {
|
|
||||||
domains: Array<{
|
|
||||||
serviceName: string;
|
|
||||||
port: number;
|
|
||||||
path?: string;
|
|
||||||
host?: string;
|
|
||||||
}>;
|
|
||||||
env: Record<string, string>;
|
|
||||||
mounts?: Array<{
|
|
||||||
filePath: string;
|
|
||||||
content: string;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
baseUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const AddTemplate = ({ projectId }: Props) => {
|
export const AddTemplate = ({ projectId, baseUrl }: Props) => {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
|
||||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||||
const { data } = api.compose.templates.useQuery();
|
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
|
||||||
|
// Try to get from props first, then localStorage
|
||||||
|
if (baseUrl) return baseUrl;
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
return localStorage.getItem(TEMPLATE_BASE_URL_KEY) || undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save to localStorage when customBaseUrl changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (customBaseUrl) {
|
||||||
|
localStorage.setItem(TEMPLATE_BASE_URL_KEY, customBaseUrl);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(TEMPLATE_BASE_URL_KEY);
|
||||||
|
}
|
||||||
|
}, [customBaseUrl]);
|
||||||
|
|
||||||
|
const { data } = api.compose.templates.useQuery(
|
||||||
|
{ baseUrl: customBaseUrl },
|
||||||
|
{
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
const { data: isCloud } = api.settings.isCloud.useQuery();
|
const { data: isCloud } = api.settings.isCloud.useQuery();
|
||||||
const { data: servers } = api.server.withSSHKey.useQuery();
|
const { data: servers } = api.server.withSSHKey.useQuery();
|
||||||
const { data: tags, isLoading: isLoadingTags } =
|
const { data: tags, isLoading: isLoadingTags } = api.compose.getTags.useQuery(
|
||||||
api.compose.getTags.useQuery();
|
{ baseUrl: customBaseUrl },
|
||||||
|
{
|
||||||
|
enabled: open,
|
||||||
|
},
|
||||||
|
);
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
|
|
||||||
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
const [serverId, setServerId] = useState<string | undefined>(undefined);
|
||||||
@@ -125,13 +123,11 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
data?.filter((template) => {
|
data?.filter((template) => {
|
||||||
const matchesTags =
|
const matchesTags =
|
||||||
selectedTags.length === 0 ||
|
selectedTags.length === 0 ||
|
||||||
template.metadata.tags.some((tag) => selectedTags.includes(tag));
|
template.tags.some((tag) => selectedTags.includes(tag));
|
||||||
const matchesQuery =
|
const matchesQuery =
|
||||||
query === "" ||
|
query === "" ||
|
||||||
template.metadata.name.toLowerCase().includes(query.toLowerCase()) ||
|
template.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
template.metadata.description
|
template.description.toLowerCase().includes(query.toLowerCase());
|
||||||
.toLowerCase()
|
|
||||||
.includes(query.toLowerCase());
|
|
||||||
return matchesTags && matchesQuery;
|
return matchesTags && matchesQuery;
|
||||||
}) || [];
|
}) || [];
|
||||||
|
|
||||||
@@ -163,6 +159,14 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
className="w-full sm:w-[200px]"
|
className="w-full sm:w-[200px]"
|
||||||
value={query}
|
value={query}
|
||||||
/>
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Base URL (optional)"
|
||||||
|
onChange={(e) =>
|
||||||
|
setCustomBaseUrl(e.target.value || undefined)
|
||||||
|
}
|
||||||
|
className="w-full sm:w-[300px]"
|
||||||
|
value={customBaseUrl || ""}
|
||||||
|
/>
|
||||||
<Popover modal={true}>
|
<Popover modal={true}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
@@ -284,7 +288,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
>
|
>
|
||||||
{templates?.map((template) => (
|
{templates?.map((template) => (
|
||||||
<div
|
<div
|
||||||
key={template.metadata.id}
|
key={template.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-col border rounded-lg overflow-hidden relative",
|
"flex flex-col border rounded-lg overflow-hidden relative",
|
||||||
viewMode === "icon" && "h-[200px]",
|
viewMode === "icon" && "h-[200px]",
|
||||||
@@ -292,9 +296,8 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Badge className="absolute top-2 right-2" variant="blue">
|
<Badge className="absolute top-2 right-2" variant="blue">
|
||||||
{template.metadata.version}
|
{template.version}
|
||||||
</Badge>
|
</Badge>
|
||||||
{/* Template Header */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
|
||||||
@@ -302,21 +305,21 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={`/templates/${template.metadata.logo}`}
|
src={`${customBaseUrl || ""}/templates/${template.id}/${template.logo}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"object-contain",
|
"object-contain",
|
||||||
viewMode === "detailed" ? "size-24" : "size-16",
|
viewMode === "detailed" ? "size-24" : "size-16",
|
||||||
)}
|
)}
|
||||||
alt={template.metadata.name}
|
alt={template.name}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<span className="text-sm font-medium line-clamp-1">
|
<span className="text-sm font-medium line-clamp-1">
|
||||||
{template.metadata.name}
|
{template.name}
|
||||||
</span>
|
</span>
|
||||||
{viewMode === "detailed" &&
|
{viewMode === "detailed" &&
|
||||||
template.metadata.tags.length > 0 && (
|
template.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap justify-center gap-1.5">
|
<div className="flex flex-wrap justify-center gap-1.5">
|
||||||
{template.metadata.tags.map((tag) => (
|
{template.tags.map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="green"
|
variant="green"
|
||||||
@@ -334,7 +337,7 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<ScrollArea className="flex-1 p-6">
|
<ScrollArea className="flex-1 p-6">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{template.metadata.description}
|
{template.description}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)}
|
)}
|
||||||
@@ -351,24 +354,24 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
{viewMode === "detailed" && (
|
{viewMode === "detailed" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link
|
<Link
|
||||||
href={template.metadata.links.github}
|
href={template.links.github}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Github className="size-5" />
|
<Github className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
{template.metadata.links.website && (
|
{template.links.website && (
|
||||||
<Link
|
<Link
|
||||||
href={template.metadata.links.website}
|
href={template.links.website}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
<Globe className="size-5" />
|
<Globe className="size-5" />
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{template.metadata.links.docs && (
|
{template.links.docs && (
|
||||||
<Link
|
<Link
|
||||||
href={template.metadata.links.docs}
|
href={template.links.docs}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
>
|
||||||
@@ -397,8 +400,8 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
</AlertDialogTitle>
|
</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
This will create an application from the{" "}
|
This will create an application from the{" "}
|
||||||
{template.metadata.name} template and add it to
|
{template.name} template and add it to your
|
||||||
your project.
|
project.
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -464,19 +467,20 @@ export const AddTemplate = ({ projectId }: Props) => {
|
|||||||
const promise = mutateAsync({
|
const promise = mutateAsync({
|
||||||
projectId,
|
projectId,
|
||||||
serverId: serverId || undefined,
|
serverId: serverId || undefined,
|
||||||
id: template.metadata.id,
|
id: template.id,
|
||||||
|
baseUrl: customBaseUrl,
|
||||||
});
|
});
|
||||||
toast.promise(promise, {
|
toast.promise(promise, {
|
||||||
loading: "Setting up...",
|
loading: "Setting up...",
|
||||||
success: (_data) => {
|
success: () => {
|
||||||
utils.project.one.invalidate({
|
utils.project.one.invalidate({
|
||||||
projectId,
|
projectId,
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
return `${template.metadata.name} template created successfully`;
|
return `${template.name} template created successfully`;
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: () => {
|
||||||
return `An error occurred deploying ${template.metadata.name} template`;
|
return `An error occurred deploying ${template.name} template`;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
compose as composeTable,
|
compose as composeTable,
|
||||||
} from "@/server/db/schema";
|
} from "@/server/db/schema";
|
||||||
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
import { cleanQueuesByCompose, myQueue } from "@/server/queues/queueSetup";
|
||||||
import { templates } from "@/templates/templates";
|
|
||||||
import { generatePassword } from "@/templates/utils";
|
import { generatePassword } from "@/templates/utils";
|
||||||
import {
|
import {
|
||||||
fetchTemplateFiles,
|
fetchTemplateFiles,
|
||||||
@@ -401,6 +400,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
projectId: z.string(),
|
projectId: z.string(),
|
||||||
serverId: z.string().optional(),
|
serverId: z.string().optional(),
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
baseUrl: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -420,7 +420,7 @@ export const composeRouter = createTRPCRouter({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const template = await fetchTemplateFiles(input.id);
|
const template = await fetchTemplateFiles(input.id, input.baseUrl);
|
||||||
|
|
||||||
const admin = await findUserById(ctx.user.ownerId);
|
const admin = await findUserById(ctx.user.ownerId);
|
||||||
let serverIp = admin.serverIp || "127.0.0.1";
|
let serverIp = admin.serverIp || "127.0.0.1";
|
||||||
@@ -488,27 +488,33 @@ export const composeRouter = createTRPCRouter({
|
|||||||
return null;
|
return null;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
templates: publicProcedure.query(async () => {
|
templates: publicProcedure
|
||||||
try {
|
.input(z.object({ baseUrl: z.string().optional() }))
|
||||||
const githubTemplates = await fetchTemplatesList();
|
.query(async ({ input }) => {
|
||||||
|
try {
|
||||||
|
const githubTemplates = await fetchTemplatesList(input.baseUrl);
|
||||||
|
|
||||||
if (githubTemplates.length > 0) {
|
if (githubTemplates.length > 0) {
|
||||||
return githubTemplates;
|
return githubTemplates;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
"Failed to fetch templates from GitHub, falling back to local templates:",
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
return [];
|
||||||
console.warn(
|
}),
|
||||||
"Failed to fetch templates from GitHub, falling back to local templates:",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}),
|
|
||||||
|
|
||||||
getTags: protectedProcedure.query(async () => {
|
getTags: protectedProcedure
|
||||||
const allTags = templates.flatMap((template) => template.tags);
|
.input(z.object({ baseUrl: z.string().optional() }))
|
||||||
const uniqueTags = _.uniq(allTags);
|
.query(async ({ input }) => {
|
||||||
return uniqueTags;
|
const githubTemplates = await fetchTemplatesList(input.baseUrl);
|
||||||
}),
|
|
||||||
|
const allTags = githubTemplates.flatMap((template) => template.tags);
|
||||||
|
const uniqueTags = _.uniq(allTags);
|
||||||
|
return uniqueTags;
|
||||||
|
}),
|
||||||
|
|
||||||
move: protectedProcedure
|
move: protectedProcedure
|
||||||
.input(
|
.input(
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ interface TemplateMetadata {
|
|||||||
*/
|
*/
|
||||||
export async function fetchTemplatesList(
|
export async function fetchTemplatesList(
|
||||||
baseUrl = "https://dokploy.github.io/templates",
|
baseUrl = "https://dokploy.github.io/templates",
|
||||||
): Promise<CompleteTemplate[]> {
|
): Promise<TemplateMetadata[]> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${baseUrl}/meta.json`);
|
const response = await fetch(`${baseUrl}/meta.json`);
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -62,22 +62,13 @@ export async function fetchTemplatesList(
|
|||||||
}
|
}
|
||||||
const templates = (await response.json()) as TemplateMetadata[];
|
const templates = (await response.json()) as TemplateMetadata[];
|
||||||
return templates.map((template) => ({
|
return templates.map((template) => ({
|
||||||
metadata: {
|
id: template.id,
|
||||||
id: template.id,
|
name: template.name,
|
||||||
name: template.name,
|
description: template.description,
|
||||||
description: template.description,
|
version: template.version,
|
||||||
version: template.version,
|
logo: template.logo,
|
||||||
logo: template.logo,
|
links: template.links,
|
||||||
links: template.links,
|
tags: template.tags,
|
||||||
tags: template.tags,
|
|
||||||
},
|
|
||||||
// These will be populated when fetching individual templates
|
|
||||||
variables: {},
|
|
||||||
config: {
|
|
||||||
domains: [],
|
|
||||||
env: {},
|
|
||||||
mounts: [],
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching templates list:", error);
|
console.error("Error fetching templates list:", error);
|
||||||
|
|||||||
Reference in New Issue
Block a user