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:
Mauricio Siu
2025-03-09 14:08:08 -06:00
parent 6e7e7b3f9a
commit 6def84d456
3 changed files with 100 additions and 99 deletions

View File

@@ -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`;
}, },
}); });
}} }}

View File

@@ -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(

View File

@@ -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);