Merge pull request #4932 from Dokploy/feat/add-icons

feat(icon): enhance icon management for services
This commit is contained in:
Mauricio Siu
2026-07-28 16:35:16 -06:00
committed by GitHub
11 changed files with 8900 additions and 36 deletions
@@ -1,5 +1,5 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { CircuitBoard, GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -16,7 +16,8 @@ import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
serviceId: string;
serviceType: "application" | "compose";
icon?: string | null;
}
@@ -26,7 +27,8 @@ const svgToDataUrl = (icon: BundledIcon): string => {
};
export const ShowIconSettings = ({
applicationId,
serviceId,
serviceType,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
@@ -48,6 +50,17 @@ export const ShowIconSettings = ({
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
const { mutateAsync: updateCompose } = api.compose.update.useMutation();
const updateIcon = async (newIcon: string | null) => {
if (serviceType === "compose") {
await updateCompose({ composeId: serviceId, icon: newIcon });
await utils.compose.one.invalidate({ composeId: serviceId });
} else {
await updateApplication({ applicationId: serviceId, icon: newIcon });
await utils.application.one.invalidate({ applicationId: serviceId });
}
};
useEffect(() => {
if (open) {
@@ -59,12 +72,8 @@ export const ShowIconSettings = ({
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
await updateIcon(dataUrl);
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -73,12 +82,8 @@ export const ShowIconSettings = ({
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
await updateIcon(null);
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
@@ -130,12 +135,8 @@ export const ShowIconSettings = ({
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
await updateIcon(sanitizedDataUrl);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -147,12 +148,8 @@ export const ShowIconSettings = ({
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
await updateIcon(result);
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
@@ -172,9 +169,11 @@ export const ShowIconSettings = ({
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
alt="Service icon"
className="h-8 w-8 object-contain"
/>
) : serviceType === "compose" ? (
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
@@ -0,0 +1 @@
ALTER TABLE "compose" ADD COLUMN "icon" text;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1233,6 +1233,13 @@
"when": 1784965709630,
"tag": "0175_fantastic_peter_quill",
"breakpoints": true
},
{
"idx": 176,
"version": "7",
"when": 1785274179336,
"tag": "0176_slimy_marten_broadcloak",
"breakpoints": true
}
]
}
@@ -259,6 +259,7 @@ export const extractServicesFromEnvironment = (
serverId: item.serverId,
serverName: item?.server?.name || null,
lastDeployDate,
icon: item.icon || null,
};
}) || [];
@@ -1701,9 +1702,17 @@ const EnvironmentPage = (
) : (
<GlobeIcon className="h-6 w-6" />
))}
{service.type === "compose" && (
<CircuitBoard className="h-6 w-6" />
)}
{service.type === "compose" &&
(service.icon ? (
// biome-ignore lint/performance/noImgElement: compose icon is data URL
<img
src={service.icon}
alt={service.name}
className="size-7 object-contain rounded-sm"
/>
) : (
<CircuitBoard className="h-6 w-6" />
))}
{service.type === "libsql" && (
<LibsqlIcon className="h-6 w-6" />
)}
@@ -127,7 +127,8 @@ const Service = (
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<div className="relative flex flex-row gap-4 items-center">
<ShowIconSettings
applicationId={applicationId}
serviceId={applicationId}
serviceType="application"
icon={data?.icon}
/>
<div className="absolute -right-1 -top-2 z-10">
@@ -1,7 +1,7 @@
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import copy from "copy-to-clipboard";
import { CircuitBoard, HelpCircle, ServerOff } from "lucide-react";
import { HelpCircle, ServerOff } from "lucide-react";
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
@@ -17,6 +17,7 @@ import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-environment";
import { ShowIconSettings } from "@/components/dashboard/application/icon/show-icon-settings";
import { ShowPatches } from "@/components/dashboard/application/patches/show-patches";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { ShowVolumeBackups } from "@/components/dashboard/application/volume-backups/show-volume-backups";
@@ -113,13 +114,16 @@ const Service = (
<div className="flex flex-col gap-4">
<CardHeader className="flex flex-row justify-between items-center">
<div className="flex flex-col">
<CardTitle className="text-xl flex flex-row gap-2">
<div className="relative flex flex-row gap-4">
<div className="absolute -right-1 -top-2">
<CardTitle className="text-xl flex flex-row gap-2 items-center">
<div className="relative flex flex-row gap-4 items-center">
<ShowIconSettings
serviceId={composeId}
serviceType="compose"
icon={data?.icon}
/>
<div className="absolute -right-1 -top-2 z-10">
<StatusTooltip status={data?.composeStatus} />
</div>
<CircuitBoard className="h-6 w-6 text-muted-foreground" />
</div>
{data?.name}
</CardTitle>
+6 -1
View File
@@ -43,6 +43,7 @@ import {
import {
type CompleteTemplate,
fetchTemplateFiles,
fetchTemplateLogo,
fetchTemplatesList,
} from "@dokploy/server/templates/github";
import { processTemplate } from "@dokploy/server/templates/processors";
@@ -601,7 +602,10 @@ export const composeRouter = createTRPCRouter({
}
}
const template = await fetchTemplateFiles(input.id, input.baseUrl);
const [template, templateLogo] = await Promise.all([
fetchTemplateFiles(input.id, input.baseUrl),
fetchTemplateLogo(input.id, input.baseUrl),
]);
let serverIp = "127.0.0.1";
@@ -639,6 +643,7 @@ export const composeRouter = createTRPCRouter({
name: input.id,
sourceType: "raw",
appName: appName,
icon: templateLogo,
});
await addNewService(ctx, compose.composeId);
+6
View File
@@ -98,6 +98,7 @@ export const compose = pgTable("compose", {
.default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
icon: text("icon"),
environmentId: text("environmentId")
.notNull()
.references(() => environments.environmentId, { onDelete: "cascade" }),
@@ -190,6 +191,11 @@ const createSchema = createInsertSchema(compose, {
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
icon: z
.string()
.max(2 * 1024 * 1024, "Icon must be less than 2MB")
.nullable()
.optional(),
serviceNetworks: z
.array(
z.object({
+7 -1
View File
@@ -168,6 +168,7 @@ export const findEnvironmentById = async (environmentId: string) => {
composeStatus: true,
description: true,
serverId: true,
icon: true,
},
},
libsql: {
@@ -239,7 +240,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
with: { server: { columns: { name: true } } },
},
compose: {
columns: { ...serviceColumns, composeId: true, composeStatus: true },
columns: {
...serviceColumns,
composeId: true,
composeStatus: true,
icon: true,
},
with: { server: { columns: { name: true } } },
},
libsql: {
+43
View File
@@ -74,6 +74,49 @@ export async function fetchTemplatesList(
}));
}
const LOGO_MIME_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
svg: "image/svg+xml",
webp: "image/webp",
gif: "image/gif",
};
export async function fetchTemplateLogo(
templateId: string,
baseUrl = "https://templates.dokploy.com",
): Promise<string | null> {
try {
const templates = await fetchTemplatesList(baseUrl);
const template = templates.find((t) => t.id === templateId);
if (!template?.logo) return null;
const response = await fetch(
`${baseUrl}/blueprints/${templateId}/${template.logo}`,
{ signal: AbortSignal.timeout(10000) },
);
if (!response.ok) return null;
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length === 0) return null;
const contentType = response.headers.get("content-type")?.split(";")[0];
const extension = template.logo.split(".").pop()?.toLowerCase() ?? "";
const mimeType = contentType?.startsWith("image/")
? contentType
: LOGO_MIME_TYPES[extension];
if (!mimeType) return null;
const dataUrl = `data:${mimeType};base64,${buffer.toString("base64")}`;
if (dataUrl.length > 2 * 1024 * 1024) return null;
return dataUrl;
} catch {
return null;
}
}
/**
* Fetches a specific template's files
*/