diff --git a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx index 9575c59c42..5444ee1b9d 100644 --- a/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx +++ b/apps/dokploy/components/dashboard/application/advanced/volumes/show-volumes.tsx @@ -100,7 +100,7 @@ export const ShowVolumes = ({ id, type }: Props) => { {mount.type === "file" && (
Content - + {mount.content}
@@ -113,12 +113,21 @@ export const ShowVolumes = ({ id, type }: Props) => { )} -
- Mount Path - - {mount.mountPath} - -
+ {mount.type === "file" ? ( +
+ File Path + + {mount.filePath} + +
+ ) : ( +
+ Mount Path + + {mount.mountPath} + +
+ )}
{ - Add a project + {projectId ? "Update" : "Add a"} project The home of something big! {isError && {error?.message}} diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index c4b2f672de..afeabf309f 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -87,9 +87,12 @@ export const ShowProjects = () => { Create and manage your projects -
- -
+ + {(auth?.rol === "admin" || user?.canCreateProjects) && ( +
+ +
+ )}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index b2f87e4173..c711b862a4 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -1,7 +1,6 @@ "use client"; import { Activity, - AudioWaveform, BarChartHorizontalBigIcon, Bell, BlocksIcon, @@ -9,7 +8,6 @@ import { Boxes, ChevronRight, CircleHelp, - Command, CreditCard, Database, Folder, @@ -27,8 +25,8 @@ import { Users, } from "lucide-react"; import { usePathname } from "next/navigation"; -import { useEffect, useState } from "react"; import type * as React from "react"; +import { useEffect, useState } from "react"; import { Breadcrumb, @@ -65,243 +63,290 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { cn } from "@/lib/utils"; +import type { AppRouter } from "@/server/api/root"; import { api } from "@/utils/api"; +import type { inferRouterOutputs } from "@trpc/server"; import Link from "next/link"; import { useRouter } from "next/router"; import { Logo } from "../shared/logo"; import { UpdateServerButton } from "./update-server"; import { UserNav } from "./user-nav"; -// This is sample data. -interface NavItem { + +// The types of the queries we are going to use +type AuthQueryOutput = inferRouterOutputs["auth"]["get"]; +type UserQueryOutput = inferRouterOutputs["user"]["byAuthId"]; + +type SingleNavItem = { + isSingle?: true; title: string; url: string; - icon: LucideIcon; - isSingle: boolean; - isActive: boolean; - items?: { - title: string; - url: string; - icon?: LucideIcon; - }[]; -} + icon?: LucideIcon; + isEnabled?: (opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; + }) => boolean; +}; -interface ExternalLink { +// NavItem type +// Consists of a single item or a group of items +// If `isSingle` is true or undefined, the item is a single item +// If `isSingle` is false, the item is a group of items +type NavItem = + | SingleNavItem + | { + isSingle: false; + title: string; + icon: LucideIcon; + items: SingleNavItem[]; + isEnabled?: (opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; + }) => boolean; + }; + +// ExternalLink type +// Represents an external link item (used for the help section) +type ExternalLink = { name: string; url: string; icon: React.ComponentType<{ className?: string }>; -} + isEnabled?: (opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; + }) => boolean; +}; -const data = { - user: { - name: "shadcn", - email: "m@example.com", - avatar: "/avatars/shadcn.jpg", - }, - teams: [ - { - name: "Dokploy", - logo: Logo, - plan: "Enterprise", - }, - { - name: "Acme Corp.", - logo: AudioWaveform, - plan: "Startup", - }, - { - name: "Evil Corp.", - logo: Command, - plan: "Free", - }, - ], +// Menu type +// Consists of home, settings, and help items +type Menu = { + home: NavItem[]; + settings: NavItem[]; + help: ExternalLink[]; +}; + +// Menu items +// Consists of unfiltered home, settings, and help items +// The items are filtered based on the user's role and permissions +// The `isEnabled` function is called to determine if the item should be displayed +const MENU: Menu = { home: [ { + isSingle: true, title: "Projects", url: "/dashboard/projects", icon: Folder, - isSingle: true, - isActive: false, }, { + isSingle: true, title: "Monitoring", url: "/dashboard/monitoring", icon: BarChartHorizontalBigIcon, - isSingle: true, - isActive: false, + // Only enabled in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => !isCloud, }, { + isSingle: true, title: "Traefik File System", url: "/dashboard/traefik", icon: GalleryVerticalEnd, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Traefik files in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!( + (auth?.rol === "admin" || user?.canAccessToTraefikFiles) && + !isCloud + ), }, { + isSingle: true, title: "Docker", url: "/dashboard/docker", icon: BlocksIcon, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud), }, { + isSingle: true, title: "Swarm", url: "/dashboard/swarm", icon: PieChart, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud), }, { + isSingle: true, title: "Requests", url: "/dashboard/requests", icon: Forward, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!((auth?.rol === "admin" || user?.canAccessToDocker) && !isCloud), }, + // Legacy unused menu, adjusted to the new structure // { + // isSingle: true, // title: "Projects", // url: "/dashboard/projects", // icon: Folder, - // isSingle: true, // }, // { + // isSingle: true, // title: "Monitoring", // icon: BarChartHorizontalBigIcon, // url: "/dashboard/settings/monitoring", - // isSingle: true, // }, - // { - // title: "Settings", - // url: "#", - // icon: Settings2, - // isActive: true, - // items: [ - // { - // title: "Profile", - // url: "/dashboard/settings/profile", - // }, - // { - // title: "Users", - // url: "/dashboard/settings/users", - // }, - // { - // title: "SSH Key", - // url: "/dashboard/settings/ssh-keys", - // }, - // { - // title: "Git", - // url: "/dashboard/settings/git-providers", - // }, - // ], + // isSingle: false, + // title: "Settings", + // icon: Settings2, + // items: [ + // { + // title: "Profile", + // url: "/dashboard/settings/profile", + // }, + // { + // title: "Users", + // url: "/dashboard/settings/users", + // }, + // { + // title: "SSH Key", + // url: "/dashboard/settings/ssh-keys", + // }, + // { + // title: "Git", + // url: "/dashboard/settings/git-providers", + // }, + // ], // }, - // { - // title: "Integrations", - // icon: BlocksIcon, - // items: [ - // { - // title: "S3 Destinations", - // url: "/dashboard/settings/destinations", - // }, - // { - // title: "Registry", - // url: "/dashboard/settings/registry", - // }, - // { - // title: "Notifications", - // url: "/dashboard/settings/notifications", - // }, - // ], - ] as NavItem[], + // isSingle: false, + // title: "Integrations", + // icon: BlocksIcon, + // items: [ + // { + // title: "S3 Destinations", + // url: "/dashboard/settings/destinations", + // }, + // { + // title: "Registry", + // url: "/dashboard/settings/registry", + // }, + // { + // title: "Notifications", + // url: "/dashboard/settings/notifications", + // }, + // ], + // }, + ], + settings: [ { + isSingle: true, title: "Server", url: "/dashboard/settings/server", icon: Activity, - isSingle: true, - isActive: false, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!(auth?.rol === "admin" && !isCloud), }, { + isSingle: true, title: "Profile", url: "/dashboard/settings/profile", icon: User, - isSingle: true, - isActive: false, }, { + isSingle: true, title: "Servers", url: "/dashboard/settings/servers", icon: Server, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Users", icon: Users, url: "/dashboard/settings/users", - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "SSH Keys", icon: KeyRound, url: "/dashboard/settings/ssh-keys", - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to SSH keys + isEnabled: ({ auth, user }) => + !!(auth?.rol === "admin" || user?.canAccessToSSHKeys), }, - { + isSingle: true, title: "Git", url: "/dashboard/settings/git-providers", icon: GitBranch, - isSingle: true, - isActive: false, + // Only enabled for admins and users with access to Git providers + isEnabled: ({ auth, user }) => + !!(auth?.rol === "admin" || user?.canAccessToGitProviders), }, { + isSingle: true, title: "Registry", url: "/dashboard/settings/registry", icon: Package, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "S3 Destinations", url: "/dashboard/settings/destinations", icon: Database, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Certificates", url: "/dashboard/settings/certificates", icon: ShieldCheck, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Cluster", url: "/dashboard/settings/cluster", icon: Boxes, - isSingle: true, - isActive: false, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!(auth?.rol === "admin" && !isCloud), }, { + isSingle: true, title: "Notifications", url: "/dashboard/settings/notifications", icon: Bell, - isSingle: true, - isActive: false, + // Only enabled for admins + isEnabled: ({ auth, user, isCloud }) => !!(auth?.rol === "admin"), }, { + isSingle: true, title: "Billing", url: "/dashboard/settings/billing", icon: CreditCard, - isSingle: true, - isActive: false, + // Only enabled for admins in cloud environments + isEnabled: ({ auth, user, isCloud }) => + !!(auth?.rol === "admin" && isCloud), }, - ] as NavItem[], + ], + help: [ { name: "Documentation", @@ -325,8 +370,108 @@ const data = { /> ), }, - ] as ExternalLink[], -}; + ], +} as const; + +/** + * Creates a menu based on the current user's role and permissions + * @returns a menu object with the home, settings, and help items + */ +function createMenuForAuthUser(opts: { + auth?: AuthQueryOutput; + user?: UserQueryOutput; + isCloud: boolean; +}): Menu { + return { + // Filter the home items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + home: MENU.home.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + user: opts.user, + isCloud: opts.isCloud, + }), + ), + // Filter the settings items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + settings: MENU.settings.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + user: opts.user, + isCloud: opts.isCloud, + }), + ), + // Filter the help items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + help: MENU.help.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + user: opts.user, + isCloud: opts.isCloud, + }), + ), + }; +} + +/** + * Determines if an item url is active based on the current pathname + * @returns true if the item url is active, false otherwise + */ +function isActiveRoute(opts: { + /** The url of the item. Usually obtained from `item.url` */ + itemUrl: string; + /** The current pathname. Usually obtained from `usePathname()` */ + pathname: string; +}): boolean { + const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project"); + const normalizedPathname = opts.pathname?.replace("/projects", "/project"); + + if (!normalizedPathname) return false; + + if (normalizedPathname === normalizedItemUrl) return true; + + if (normalizedPathname.startsWith(normalizedItemUrl)) { + const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); + return nextChar === "/"; + } + + return false; +} + +/** + * Finds the active nav item based on the current pathname + * @returns the active nav item with `SingleNavItem` type or undefined if none is active + */ +function findActiveNavItem( + navItems: NavItem[], + pathname: string, +): SingleNavItem | undefined { + const found = navItems.find((item) => + item.isSingle !== false + ? // The current item is single, so check if the item url is active + isActiveRoute({ itemUrl: item.url, pathname }) + : // The current item is not single, so check if any of the sub items are active + item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ), + ); + + if (found?.isSingle !== false) { + // The found item is single, so return it + return found; + } + + // The found item is not single, so find the active sub item + return found?.items.find((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); +} interface Props { children: React.ReactNode; @@ -398,64 +543,21 @@ export default function Page({ children }: Props) { const includesProjects = pathname?.includes("/dashboard/project"); const { data: isCloud, isLoading } = api.settings.isCloud.useQuery(); - const isActiveRoute = (itemUrl: string) => { - const normalizedItemUrl = itemUrl?.replace("/projects", "/project"); - const normalizedPathname = pathname?.replace("/projects", "/project"); - - if (!normalizedPathname) return false; - if (normalizedPathname === normalizedItemUrl) return true; + const { + home: filteredHome, + settings: filteredSettings, + help, + } = createMenuForAuthUser({ auth, user, isCloud: !!isCloud }); - if (normalizedPathname.startsWith(normalizedItemUrl)) { - const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); - return nextChar === "/"; - } - - return false; - }; + const activeItem = findActiveNavItem( + [...filteredHome, ...filteredSettings], + pathname, + ); - let filteredHome = isCloud - ? data.home.filter( - (item) => - ![ - "/dashboard/monitoring", - "/dashboard/traefik", - "/dashboard/docker", - "/dashboard/swarm", - "/dashboard/requests", - ].includes(item.url), - ) - : data.home; - - let filteredSettings = isCloud - ? data.settings.filter( - (item) => - ![ - "/dashboard/settings/server", - "/dashboard/settings/cluster", - ].includes(item.url), - ) - : data.settings.filter( - (item) => !["/dashboard/settings/billing"].includes(item.url), - ); - - filteredHome = filteredHome.map((item) => ({ - ...item, - isActive: isActiveRoute(item.url), - })); - - filteredSettings = filteredSettings.map((item) => ({ - ...item, - isActive: isActiveRoute(item.url), - })); - - const activeItem = - filteredHome.find((item) => item.isActive) || - filteredSettings.find((item) => item.isActive); - - const showProjectsButton = - currentPath === "/dashboard/projects" && - (auth?.rol === "admin" || user?.canCreateProjects); + // const showProjectsButton = + // currentPath === "/dashboard/projects" && + // (auth?.rol === "admin" || user?.canCreateProjects); return ( Home - {filteredHome.map((item) => ( - - - {item.isSingle ? ( - - { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); + + return ( + + + {isSingle ? ( + - - {item.title} - - - ) : ( - <> - - - {item.icon && } - - {item.title} - {item.items?.length && ( - + {item.icon && ( + )} - - - - - {item.items?.map((subItem) => ( - - - {item.title} + + + ) : ( + <> + + + {item.icon && } + + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ))} + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} Settings - {filteredSettings.map((item) => ( - - - {item.isSingle ? ( - - { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); + + return ( + + + {isSingle ? ( + - - {item.title} - - - ) : ( - <> - - - {item.icon && } - - {item.title} - {item.items?.length && ( - + {item.icon && ( + )} - - - - - {item.items?.map((subItem) => ( - - - {item.title} + + + ) : ( + <> + + + {item.icon && } + + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ))} + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} Extra - {data.help.map((item: ExternalLink) => ( + {help.map((item: ExternalLink) => ( + + Superset + + + + + + diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index 449a223336..cb0e32d9a7 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -345,7 +345,7 @@ export const settingsRouter = createTRPCRouter({ writeConfig("middlewares", input.traefikConfig); return true; }), - getUpdateData: adminProcedure.mutation(async () => { + getUpdateData: protectedProcedure.mutation(async () => { if (IS_CLOUD) { return DEFAULT_UPDATE_DATA; } @@ -373,10 +373,10 @@ export const settingsRouter = createTRPCRouter({ return true; }), - getDokployVersion: adminProcedure.query(() => { + getDokployVersion: protectedProcedure.query(() => { return packageInfo.version; }), - getReleaseTag: adminProcedure.query(() => { + getReleaseTag: protectedProcedure.query(() => { return getDokployImageTag(); }), readDirectories: protectedProcedure diff --git a/apps/dokploy/templates/superset/docker-compose.yml b/apps/dokploy/templates/superset/docker-compose.yml new file mode 100644 index 0000000000..1766b86b7c --- /dev/null +++ b/apps/dokploy/templates/superset/docker-compose.yml @@ -0,0 +1,62 @@ +# Note: this is an UNOFFICIAL production docker image build for Superset: +# - https://github.com/amancevice/docker-superset +# +# After deploying this image, you will need to run one of the two +# commands below in a terminal within the superset container: +# $ superset-demo # Initialise database + load demo charts/datasets +# $ superset-init # Initialise database only +# +# You will be prompted to enter the credentials for the admin user. + +services: + superset: + image: amancevice/superset + restart: always + depends_on: + - db + - redis + environment: + SECRET_KEY: ${SECRET_KEY} + MAPBOX_API_KEY: ${MAPBOX_API_KEY} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + REDIS_PASSWORD: ${REDIS_PASSWORD} + volumes: + # Note: superset_config.py can be edited in Dokploy's UI Volume Mount + - ../files/superset/superset_config.py:/etc/superset/superset_config.py + + db: + image: postgres + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - dokploy-network + + redis: + image: redis + restart: always + volumes: + - redis:/data + command: redis-server --requirepass ${REDIS_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 30s + timeout: 10s + retries: 3 + networks: + - dokploy-network + +volumes: + postgres: + redis: diff --git a/apps/dokploy/templates/superset/index.ts b/apps/dokploy/templates/superset/index.ts new file mode 100644 index 0000000000..6132f978f6 --- /dev/null +++ b/apps/dokploy/templates/superset/index.ts @@ -0,0 +1,67 @@ +import { + type DomainSchema, + type Schema, + type Template, + generatePassword, + generateRandomDomain, +} from "../utils"; + +export function generate(schema: Schema): Template { + const mapboxApiKey = ""; + const secretKey = generatePassword(30); + const postgresDb = "superset"; + const postgresUser = "superset"; + const postgresPassword = generatePassword(30); + const redisPassword = generatePassword(30); + + const domains: DomainSchema[] = [ + { + host: generateRandomDomain(schema), + port: 8088, + serviceName: "superset", + }, + ]; + + const envs = [ + `SECRET_KEY=${secretKey}`, + `MAPBOX_API_KEY=${mapboxApiKey}`, + `POSTGRES_DB=${postgresDb}`, + `POSTGRES_USER=${postgresUser}`, + `POSTGRES_PASSWORD=${postgresPassword}`, + `REDIS_PASSWORD=${redisPassword}`, + ]; + + const mounts: Template["mounts"] = [ + { + filePath: "./superset/superset_config.py", + content: ` +import os + +SECRET_KEY = os.getenv("SECRET_KEY") +MAPBOX_API_KEY = os.getenv("MAPBOX_API_KEY", "") + +CACHE_CONFIG = { + "CACHE_TYPE": "RedisCache", + "CACHE_DEFAULT_TIMEOUT": 300, + "CACHE_KEY_PREFIX": "superset_", + "CACHE_REDIS_HOST": "redis", + "CACHE_REDIS_PORT": 6379, + "CACHE_REDIS_DB": 1, + "CACHE_REDIS_URL": f"redis://:{os.getenv('REDIS_PASSWORD')}@redis:6379/1", +} + +FILTER_STATE_CACHE_CONFIG = {**CACHE_CONFIG, "CACHE_KEY_PREFIX": "superset_filter_"} +EXPLORE_FORM_DATA_CACHE_CONFIG = {**CACHE_CONFIG, "CACHE_KEY_PREFIX": "superset_explore_form_"} + +SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{os.getenv('POSTGRES_USER')}:{os.getenv('POSTGRES_PASSWORD')}@db:5432/{os.getenv('POSTGRES_DB')}" +SQLALCHEMY_TRACK_MODIFICATIONS = True + `.trim(), + }, + ]; + + return { + envs, + domains, + mounts, + }; +} diff --git a/apps/dokploy/templates/templates.ts b/apps/dokploy/templates/templates.ts index 9531eb7aec..4cd167a501 100644 --- a/apps/dokploy/templates/templates.ts +++ b/apps/dokploy/templates/templates.ts @@ -1298,4 +1298,18 @@ export const templates: TemplateData[] = [ tags: ["developer", "tools"], load: () => import("./it-tools/index").then((m) => m.generate), }, + { + id: "superset", + name: "Superset (Unofficial)", + version: "latest", + description: "Data visualization and data exploration platform.", + logo: "superset.svg", + links: { + github: "https://github.com/amancevice/docker-superset", + website: "https://superset.apache.org", + docs: "https://superset.apache.org/docs/intro", + }, + tags: ["analytics", "bi", "dashboard", "database", "sql"], + load: () => import("./superset/index").then((m) => m.generate), + }, ];