From f9dedd979ee2572092f10462f84182ef7e28e6fd Mon Sep 17 00:00:00 2001 From: Luka Klacar Date: Sat, 28 Feb 2026 23:12:31 +0100 Subject: [PATCH 01/18] fix: Fixed service card behavior #3837 --- .../environment/[environmentId].tsx | 172 +++++++++--------- 1 file changed, 85 insertions(+), 87 deletions(-) diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index af901311e2..37a5364bb0 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -23,7 +23,7 @@ import type { InferGetServerSidePropsType, } from "next"; import Head from "next/head"; -import { useRouter } from "next/router"; +import Link from "next/link"; import { type ReactElement, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import superjson from "superjson"; @@ -366,7 +366,6 @@ const EnvironmentPage = ( environmentId, }); const { data: allProjects } = api.project.all.useQuery(); - const router = useRouter(); const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); const [selectedTargetProject, setSelectedTargetProject] = @@ -420,6 +419,7 @@ const EnvironmentPage = ( }; const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => { + event.preventDefault(); event.stopPropagation(); setSelectedServices((prev) => prev.includes(serviceId) @@ -1471,101 +1471,99 @@ const EnvironmentPage = (
{filteredServices?.map((service) => ( - { - router.push( - `/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`, - ); - }} - className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border" + href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`} + className="block" > - {service.serverId && ( -
- + + {service.serverId && ( +
+ +
+ )} +
+
- )} -
- -
-
- handleServiceSelect(service.id, e) - } - > -
- +
+ handleServiceSelect(service.id, e) + } + > +
+ +
-
- - -
-
- - {service.name} - - {service.description && ( - - {service.description} + + +
+
+ + {service.name} - )} -
+ {service.description && ( + + {service.description} + + )} +
- - {service.type === "postgres" && ( - - )} - {service.type === "redis" && ( - - )} - {service.type === "mariadb" && ( - - )} - {service.type === "mongo" && ( - - )} - {service.type === "mysql" && ( - - )} - {service.type === "application" && ( - - )} - {service.type === "compose" && ( - - )} - -
- - - -
- {service.serverName && ( -
- - - {service.serverName} + + {service.type === "postgres" && ( + + )} + {service.type === "redis" && ( + + )} + {service.type === "mariadb" && ( + + )} + {service.type === "mongo" && ( + + )} + {service.type === "mysql" && ( + + )} + {service.type === "application" && ( + + )} + {service.type === "compose" && ( + + )}
- )} - - Created - -
-
- + + + +
+ {service.serverName && ( +
+ + + {service.serverName} + +
+ )} + + Created + +
+
+ + ))}
From 705c5bc1c9baaebfcceb55ba7c7c0196bb9406a1 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 01:14:46 -0600 Subject: [PATCH 02/18] feat: add search functionality across multiple routers with member access control Implemented a search feature in application, compose, environment, mariadb, mongo, mysql, postgres, project, and redis routers. Each search allows filtering by various parameters and respects user permissions based on their role. The search queries utilize optimized conditions for efficient data retrieval. --- .../dokploy/server/api/routers/application.ts | 135 +++++++++++++++++- apps/dokploy/server/api/routers/compose.ts | 114 ++++++++++++++- .../dokploy/server/api/routers/environment.ts | 91 ++++++++++++ apps/dokploy/server/api/routers/mariadb.ts | 101 ++++++++++++- apps/dokploy/server/api/routers/mongo.ts | 101 ++++++++++++- apps/dokploy/server/api/routers/mysql.ts | 100 ++++++++++++- apps/dokploy/server/api/routers/postgres.ts | 101 ++++++++++++- apps/dokploy/server/api/routers/project.ts | 79 +++++++++- apps/dokploy/server/api/routers/redis.ts | 101 ++++++++++++- 9 files changed, 915 insertions(+), 8 deletions(-) diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 97dba570e3..3b61fd8260 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -7,6 +7,7 @@ import { findApplicationById, findEnvironmentById, findGitProviderById, + findMemberById, findProjectById, getApplicationStats, IS_CLOUD, @@ -32,7 +33,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; @@ -53,6 +54,8 @@ import { apiSaveGitProvider, apiUpdateApplication, applications, + environments, + projects, } from "@/server/db/schema"; import { deploymentWorker } from "@/server/queues/deployments-queue"; import type { DeploymentJob } from "@/server/queues/queue-types"; @@ -1002,4 +1005,134 @@ export const applicationRouter = createTRPCRouter({ message: "Deployment cancellation only available in cloud version", }); }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + repository: z.string().optional(), + owner: z.string().optional(), + dockerImage: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(applications.environmentId, input.environmentId), + ); + } + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(applications.name, term), + ilike(applications.appName, term), + ilike(applications.description ?? "", term), + ilike(applications.repository ?? "", term), + ilike(applications.owner ?? "", term), + ilike(applications.dockerImage ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push( + ilike(applications.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(applications.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(applications.description ?? "", `%${input.description.trim()}%`), + ); + } + if (input.repository?.trim()) { + baseConditions.push( + ilike(applications.repository ?? "", `%${input.repository.trim()}%`), + ); + } + if (input.owner?.trim()) { + baseConditions.push( + ilike(applications.owner ?? "", `%${input.owner.trim()}%`), + ); + } + if (input.dockerImage?.trim()) { + baseConditions.push( + ilike(applications.dockerImage ?? "", `%${input.dockerImage.trim()}%`), + ); + } + + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${applications.applicationId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db + .select({ + applicationId: applications.applicationId, + name: applications.name, + appName: applications.appName, + description: applications.description, + environmentId: applications.environmentId, + applicationStatus: applications.applicationStatus, + sourceType: applications.sourceType, + createdAt: applications.createdAt, + }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(applications.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index f868e2ae1f..390a0696cc 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -16,6 +16,7 @@ import { findDomainsByComposeId, findEnvironmentById, findGitProviderById, + findMemberById, findProjectById, findServerById, getComposeContainer, @@ -41,7 +42,7 @@ import { } from "@dokploy/server/templates/github"; import { processTemplate } from "@dokploy/server/templates/processors"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import _ from "lodash"; import { nanoid } from "nanoid"; import { parse } from "toml"; @@ -58,6 +59,8 @@ import { apiRedeployCompose, apiUpdateCompose, compose as composeTable, + environments, + projects, } from "@/server/db/schema"; import { deploymentWorker } from "@/server/queues/deployments-queue"; import type { DeploymentJob } from "@/server/queues/queue-types"; @@ -1054,4 +1057,113 @@ export const composeRouter = createTRPCRouter({ message: "Deployment cancellation only available in cloud version", }); }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(composeTable.environmentId, input.environmentId), + ); + } + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(composeTable.name, term), + ilike(composeTable.appName, term), + ilike(composeTable.description ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push( + ilike(composeTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(composeTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(composeTable.description ?? "", `%${input.description.trim()}%`), + ); + } + + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${composeTable.composeId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db + .select({ + composeId: composeTable.composeId, + name: composeTable.name, + appName: composeTable.appName, + description: composeTable.description, + environmentId: composeTable.environmentId, + composeStatus: composeTable.composeStatus, + sourceType: composeTable.sourceType, + createdAt: composeTable.createdAt, + }) + .from(composeTable) + .innerJoin( + environments, + eq(composeTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(composeTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(composeTable) + .innerJoin( + environments, + eq(composeTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), }); diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 9f5eb45c2d..173d3bd6cf 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -11,7 +11,9 @@ import { findMemberById, updateEnvironmentById, } from "@dokploy/server"; +import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -21,6 +23,7 @@ import { apiRemoveEnvironment, apiUpdateEnvironment, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; // Helper function to filter services within an environment based on user permissions const filterEnvironmentServices = ( @@ -358,4 +361,92 @@ export const environmentRouter = createTRPCRouter({ }); } }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(environments.name, term), + ilike(environments.description ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push( + ilike(environments.name, `%${input.name.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(environments.description ?? "", `%${input.description.trim()}%`), + ); + } + + if (ctx.user.role === "member") { + const { accessedEnvironments } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedEnvironments.length === 0) + return { items: [], total: 0 }; + baseConditions.push( + sql`${environments.environmentId} IN (${sql.join( + accessedEnvironments.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db + .select({ + environmentId: environments.environmentId, + name: environments.name, + description: environments.description, + createdAt: environments.createdAt, + env: environments.env, + projectId: environments.projectId, + isDefault: environments.isDefault, + }) + .from(environments) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(environments.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(environments) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index bddc71b096..08a352e115 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -8,6 +8,7 @@ import { findBackupsByDbId, findEnvironmentById, findMariadbById, + findMemberById, findProjectById, IS_CLOUD, rebuildDatabase, @@ -22,7 +23,7 @@ import { import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -37,6 +38,7 @@ import { apiUpdateMariaDB, mariadb as mariadbTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; export const mariadbRouter = createTRPCRouter({ create: protectedProcedure @@ -446,4 +448,101 @@ export const mariadbRouter = createTRPCRouter({ await rebuildDatabase(mariadb.mariadbId, "mariadb"); return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(mariadbTable.environmentId, input.environmentId), + ); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(mariadbTable.name, term), + ilike(mariadbTable.appName, term), + ilike(mariadbTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push( + ilike(mariadbTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(mariadbTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(mariadbTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${mariadbTable.mariadbId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + mariadbId: mariadbTable.mariadbId, + name: mariadbTable.name, + appName: mariadbTable.appName, + description: mariadbTable.description, + environmentId: mariadbTable.environmentId, + applicationStatus: mariadbTable.applicationStatus, + createdAt: mariadbTable.createdAt, + }) + .from(mariadbTable) + .innerJoin( + environments, + eq(mariadbTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(mariadbTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(mariadbTable) + .innerJoin( + environments, + eq(mariadbTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index e8454c8a41..c1a4a1fbdc 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -7,6 +7,7 @@ import { deployMongo, findBackupsByDbId, findEnvironmentById, + findMemberById, findMongoById, findProjectById, IS_CLOUD, @@ -21,7 +22,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -36,6 +37,7 @@ import { apiUpdateMongo, mongo as mongoTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; export const mongoRouter = createTRPCRouter({ create: protectedProcedure @@ -476,4 +478,101 @@ export const mongoRouter = createTRPCRouter({ return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(mongoTable.environmentId, input.environmentId), + ); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(mongoTable.name, term), + ilike(mongoTable.appName, term), + ilike(mongoTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push( + ilike(mongoTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(mongoTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(mongoTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${mongoTable.mongoId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + mongoId: mongoTable.mongoId, + name: mongoTable.name, + appName: mongoTable.appName, + description: mongoTable.description, + environmentId: mongoTable.environmentId, + applicationStatus: mongoTable.applicationStatus, + createdAt: mongoTable.createdAt, + }) + .from(mongoTable) + .innerJoin( + environments, + eq(mongoTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(mongoTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(mongoTable) + .innerJoin( + environments, + eq(mongoTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index b1bc10f32e..4ffaa96113 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -7,6 +7,7 @@ import { deployMySql, findBackupsByDbId, findEnvironmentById, + findMemberById, findMySqlById, findProjectById, IS_CLOUD, @@ -21,7 +22,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -34,7 +35,9 @@ import { apiSaveEnvironmentVariablesMySql, apiSaveExternalPortMySql, apiUpdateMySql, + environments, mysql as mysqlTable, + projects, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; @@ -471,4 +474,99 @@ export const mysqlRouter = createTRPCRouter({ return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push(eq(mysqlTable.environmentId, input.environmentId)); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(mysqlTable.name, term), + ilike(mysqlTable.appName, term), + ilike(mysqlTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push( + ilike(mysqlTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(mysqlTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${mysqlTable.mysqlId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + mysqlId: mysqlTable.mysqlId, + name: mysqlTable.name, + appName: mysqlTable.appName, + description: mysqlTable.description, + environmentId: mysqlTable.environmentId, + applicationStatus: mysqlTable.applicationStatus, + createdAt: mysqlTable.createdAt, + }) + .from(mysqlTable) + .innerJoin( + environments, + eq(mysqlTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(mysqlTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(mysqlTable) + .innerJoin( + environments, + eq(mysqlTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index d9f69330c3..6701615c8f 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -7,6 +7,7 @@ import { deployPostgres, findBackupsByDbId, findEnvironmentById, + findMemberById, findPostgresById, findProjectById, getMountPath, @@ -22,7 +23,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -37,6 +38,7 @@ import { apiUpdatePostgres, postgres as postgresTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; export const postgresRouter = createTRPCRouter({ @@ -483,4 +485,101 @@ export const postgresRouter = createTRPCRouter({ return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(postgresTable.environmentId, input.environmentId), + ); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(postgresTable.name, term), + ilike(postgresTable.appName, term), + ilike(postgresTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push( + ilike(postgresTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(postgresTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(postgresTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${postgresTable.postgresId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + postgresId: postgresTable.postgresId, + name: postgresTable.name, + appName: postgresTable.appName, + description: postgresTable.description, + environmentId: postgresTable.environmentId, + applicationStatus: postgresTable.applicationStatus, + createdAt: postgresTable.createdAt, + }) + .from(postgresTable) + .innerJoin( + environments, + eq(postgresTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(postgresTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(postgresTable) + .innerJoin( + environments, + eq(postgresTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index b1df689519..ee353594c9 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -34,7 +34,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; @@ -277,6 +277,83 @@ export const projectRouter = createTRPCRouter({ }); }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(projects.name, term), + ilike(projects.description ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push(ilike(projects.name, `%${input.name.trim()}%`)); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(projects.description ?? "", `%${input.description.trim()}%`), + ); + } + + if (ctx.user.role === "member") { + const { accessedProjects } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedProjects.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${projects.projectId} IN (${sql.join( + accessedProjects.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db.query.projects.findMany({ + where, + limit: input.limit, + offset: input.offset, + orderBy: desc(projects.createdAt), + columns: { + projectId: true, + name: true, + description: true, + createdAt: true, + organizationId: true, + env: true, + }, + }), + db + .select({ count: sql`count(*)::int` }) + .from(projects) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), + remove: protectedProcedure .input(apiRemoveProject) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index dfeff82bbc..5c058276be 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -6,6 +6,7 @@ import { createRedis, deployRedis, findEnvironmentById, + findMemberById, findProjectById, findRedisById, IS_CLOUD, @@ -20,7 +21,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -35,6 +36,7 @@ import { apiUpdateRedis, redis as redisTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; export const redisRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateRedis) @@ -450,4 +452,101 @@ export const redisRouter = createTRPCRouter({ await rebuildDatabase(redis.redisId, "redis"); return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(redisTable.environmentId, input.environmentId), + ); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(redisTable.name, term), + ilike(redisTable.appName, term), + ilike(redisTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push( + ilike(redisTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(redisTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(redisTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${redisTable.redisId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + redisId: redisTable.redisId, + name: redisTable.name, + appName: redisTable.appName, + description: redisTable.description, + environmentId: redisTable.environmentId, + applicationStatus: redisTable.applicationStatus, + createdAt: redisTable.createdAt, + }) + .from(redisTable) + .innerJoin( + environments, + eq(redisTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(redisTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(redisTable) + .innerJoin( + environments, + eq(redisTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); From 60a6dc5fab9a264e6b44a7a1cf17440703ac3a39 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 07:15:20 +0000 Subject: [PATCH 03/18] [autofix.ci] apply automated fixes --- apps/dokploy/server/api/routers/application.ts | 14 +++++++++----- apps/dokploy/server/api/routers/compose.ts | 9 +++++---- apps/dokploy/server/api/routers/environment.ts | 12 ++++++------ apps/dokploy/server/api/routers/mariadb.ts | 9 +++++---- apps/dokploy/server/api/routers/mongo.ts | 8 ++------ apps/dokploy/server/api/routers/mysql.ts | 4 +--- apps/dokploy/server/api/routers/postgres.ts | 5 ++++- apps/dokploy/server/api/routers/redis.ts | 8 ++------ 8 files changed, 34 insertions(+), 35 deletions(-) diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 3b61fd8260..df3e81c82b 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -1051,9 +1051,7 @@ export const applicationRouter = createTRPCRouter({ } if (input.name?.trim()) { - baseConditions.push( - ilike(applications.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(applications.name, `%${input.name.trim()}%`)); } if (input.appName?.trim()) { baseConditions.push( @@ -1062,7 +1060,10 @@ export const applicationRouter = createTRPCRouter({ } if (input.description?.trim()) { baseConditions.push( - ilike(applications.description ?? "", `%${input.description.trim()}%`), + ilike( + applications.description ?? "", + `%${input.description.trim()}%`, + ), ); } if (input.repository?.trim()) { @@ -1077,7 +1078,10 @@ export const applicationRouter = createTRPCRouter({ } if (input.dockerImage?.trim()) { baseConditions.push( - ilike(applications.dockerImage ?? "", `%${input.dockerImage.trim()}%`), + ilike( + applications.dockerImage ?? "", + `%${input.dockerImage.trim()}%`, + ), ); } diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index 390a0696cc..e3c803cd4f 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -1097,9 +1097,7 @@ export const composeRouter = createTRPCRouter({ } if (input.name?.trim()) { - baseConditions.push( - ilike(composeTable.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(composeTable.name, `%${input.name.trim()}%`)); } if (input.appName?.trim()) { baseConditions.push( @@ -1108,7 +1106,10 @@ export const composeRouter = createTRPCRouter({ } if (input.description?.trim()) { baseConditions.push( - ilike(composeTable.description ?? "", `%${input.description.trim()}%`), + ilike( + composeTable.description ?? "", + `%${input.description.trim()}%`, + ), ); } diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 173d3bd6cf..16376e9e01 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -393,13 +393,14 @@ export const environmentRouter = createTRPCRouter({ } if (input.name?.trim()) { - baseConditions.push( - ilike(environments.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(environments.name, `%${input.name.trim()}%`)); } if (input.description?.trim()) { baseConditions.push( - ilike(environments.description ?? "", `%${input.description.trim()}%`), + ilike( + environments.description ?? "", + `%${input.description.trim()}%`, + ), ); } @@ -408,8 +409,7 @@ export const environmentRouter = createTRPCRouter({ ctx.user.id, ctx.session.activeOrganizationId, ); - if (accessedEnvironments.length === 0) - return { items: [], total: 0 }; + if (accessedEnvironments.length === 0) return { items: [], total: 0 }; baseConditions.push( sql`${environments.environmentId} IN (${sql.join( accessedEnvironments.map((id) => sql`${id}`), diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index 08a352e115..567cd4ad86 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -484,9 +484,7 @@ export const mariadbRouter = createTRPCRouter({ ); } if (input.name?.trim()) { - baseConditions.push( - ilike(mariadbTable.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(mariadbTable.name, `%${input.name.trim()}%`)); } if (input.appName?.trim()) { baseConditions.push( @@ -495,7 +493,10 @@ export const mariadbRouter = createTRPCRouter({ } if (input.description?.trim()) { baseConditions.push( - ilike(mariadbTable.description ?? "", `%${input.description.trim()}%`), + ilike( + mariadbTable.description ?? "", + `%${input.description.trim()}%`, + ), ); } if (ctx.user.role === "member") { diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index c1a4a1fbdc..ec0a4041c7 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -499,9 +499,7 @@ export const mongoRouter = createTRPCRouter({ baseConditions.push(eq(environments.projectId, input.projectId)); } if (input.environmentId) { - baseConditions.push( - eq(mongoTable.environmentId, input.environmentId), - ); + baseConditions.push(eq(mongoTable.environmentId, input.environmentId)); } if (input.q?.trim()) { const term = `%${input.q.trim()}%`; @@ -514,9 +512,7 @@ export const mongoRouter = createTRPCRouter({ ); } if (input.name?.trim()) { - baseConditions.push( - ilike(mongoTable.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(mongoTable.name, `%${input.name.trim()}%`)); } if (input.appName?.trim()) { baseConditions.push( diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index 4ffaa96113..5a00ef0d01 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -508,9 +508,7 @@ export const mysqlRouter = createTRPCRouter({ ); } if (input.name?.trim()) { - baseConditions.push( - ilike(mysqlTable.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(mysqlTable.name, `%${input.name.trim()}%`)); } if (input.appName?.trim()) { baseConditions.push( diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index 6701615c8f..48de9d5a2a 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -532,7 +532,10 @@ export const postgresRouter = createTRPCRouter({ } if (input.description?.trim()) { baseConditions.push( - ilike(postgresTable.description ?? "", `%${input.description.trim()}%`), + ilike( + postgresTable.description ?? "", + `%${input.description.trim()}%`, + ), ); } if (ctx.user.role === "member") { diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index 5c058276be..94939bd208 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -473,9 +473,7 @@ export const redisRouter = createTRPCRouter({ baseConditions.push(eq(environments.projectId, input.projectId)); } if (input.environmentId) { - baseConditions.push( - eq(redisTable.environmentId, input.environmentId), - ); + baseConditions.push(eq(redisTable.environmentId, input.environmentId)); } if (input.q?.trim()) { const term = `%${input.q.trim()}%`; @@ -488,9 +486,7 @@ export const redisRouter = createTRPCRouter({ ); } if (input.name?.trim()) { - baseConditions.push( - ilike(redisTable.name, `%${input.name.trim()}%`), - ); + baseConditions.push(ilike(redisTable.name, `%${input.name.trim()}%`)); } if (input.appName?.trim()) { baseConditions.push( From 4ede21eda93d2e33ff71aab0c8f880c808c6a312 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 13:42:34 -0600 Subject: [PATCH 04/18] feat: enhance project and environment services with additional column selections Updated project and environment services to include specific column selections for various database entities. This improves data retrieval efficiency and allows for more granular control over the returned data structure. Added columns for application, mariadb, mongo, mysql, postgres, redis, and compose entities, as well as enhancements to the environment query structure. --- apps/dokploy/server/api/routers/project.ts | 65 ++++++++++- packages/server/src/services/environment.ts | 115 ++++++++++++++++++-- 2 files changed, 167 insertions(+), 13 deletions(-) diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index ee353594c9..3c9cc23cc1 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -220,30 +220,55 @@ export const projectRouter = createTRPCRouter({ accessedServices, ), with: { domains: true }, + columns: { + applicationId: true, + }, }, mariadb: { where: buildServiceFilter(mariadb.mariadbId, accessedServices), + columns: { + mariadbId: true, + }, }, mongo: { where: buildServiceFilter(mongo.mongoId, accessedServices), + columns: { + mongoId: true, + }, }, mysql: { where: buildServiceFilter(mysql.mysqlId, accessedServices), + columns: { + mysqlId: true, + }, }, postgres: { where: buildServiceFilter( postgres.postgresId, accessedServices, ), + columns: { + postgresId: true, + }, }, redis: { where: buildServiceFilter(redis.redisId, accessedServices), + columns: { + redisId: true, + }, }, compose: { where: buildServiceFilter(compose.composeId, accessedServices), with: { domains: true }, + columns: { + composeId: true, + }, }, }, + columns: { + environmentId: true, + isDefault: true, + }, }, }, orderBy: desc(projects.createdAt), @@ -258,18 +283,48 @@ export const projectRouter = createTRPCRouter({ with: { domains: true, }, + columns: { + applicationId: true, + }, + }, + mariadb: { + columns: { + mariadbId: true, + }, + }, + mongo: { + columns: { + mongoId: true, + }, + }, + mysql: { + columns: { + mysqlId: true, + }, + }, + postgres: { + columns: { + postgresId: true, + }, + }, + redis: { + columns: { + redisId: true, + }, }, - mariadb: true, - mongo: true, - mysql: true, - postgres: true, - redis: true, compose: { with: { domains: true, }, + columns: { + composeId: true, + }, }, }, + columns: { + environmentId: true, + isDefault: true, + }, }, }, where: eq(projects.organizationId, ctx.session.activeOrganizationId), diff --git a/packages/server/src/services/environment.ts b/packages/server/src/services/environment.ts index d37e7b789f..bf4920193f 100644 --- a/packages/server/src/services/environment.ts +++ b/packages/server/src/services/environment.ts @@ -34,42 +34,134 @@ export const createEnvironment = async ( export const findEnvironmentById = async (environmentId: string) => { const environment = await db.query.environments.findFirst({ where: eq(environments.environmentId, environmentId), + columns: { + name: true, + description: true, + environmentId: true, + isDefault: true, + projectId: true, + env: true, + }, with: { applications: { with: { - deployments: true, - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + name: true, + applicationId: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, mariadb: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + mariadbId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, mongo: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + mongoId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, mysql: { with: { server: true, }, + columns: { + mysqlId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, }, postgres: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + postgresId: true, + name: true, + description: true, + createdAt: true, + applicationStatus: true, + serverId: true, }, }, redis: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + redisId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, compose: { with: { - deployments: true, - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + composeId: true, + name: true, + createdAt: true, + composeStatus: true, + description: true, + serverId: true, }, }, project: true, @@ -98,6 +190,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => { compose: true, project: true, }, + columns: { + name: true, + description: true, + environmentId: true, + isDefault: true, + }, }); return projectEnvironments; }; @@ -169,6 +267,7 @@ export const duplicateEnvironment = async ( name: input.name, description: input.description || originalEnvironment.description, projectId: originalEnvironment.projectId, + env: originalEnvironment.env, }) .returning() .then((value) => value[0]); From a8a5e1c6f108ebef4b942aa87e41328348694ca2 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 13:47:38 -0600 Subject: [PATCH 05/18] refactor: remove unused environment property in duplicateEnvironment function Eliminated the 'env' property from the duplicateEnvironment function to streamline the code and improve clarity. This change enhances maintainability by removing unnecessary parameters. --- .../components/dashboard/project/duplicate-project.tsx | 1 - .../project/[projectId]/environment/[environmentId].tsx | 8 -------- 2 files changed, 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx index f84cf35dda..e754b1d8b5 100644 --- a/apps/dokploy/components/dashboard/project/duplicate-project.tsx +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -25,7 +25,6 @@ import { import { api } from "@/utils/api"; export type Services = { - appName: string; serverId?: string | null; name: string; type: diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index af901311e2..89d77af24f 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -100,7 +100,6 @@ import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; export type Services = { - appName: string; serverId?: string | null; serverName?: string | null; name: string; @@ -146,7 +145,6 @@ export const extractServicesFromEnvironment = ( } } return { - appName: item.appName, name: item.name, type: "application", id: item.applicationId, @@ -161,7 +159,6 @@ export const extractServicesFromEnvironment = ( const mariadb: Services[] = environment.mariadb?.map((item) => ({ - appName: item.appName, name: item.name, type: "mariadb", id: item.mariadbId, @@ -174,7 +171,6 @@ export const extractServicesFromEnvironment = ( const postgres: Services[] = environment.postgres?.map((item) => ({ - appName: item.appName, name: item.name, type: "postgres", id: item.postgresId, @@ -187,7 +183,6 @@ export const extractServicesFromEnvironment = ( const mongo: Services[] = environment.mongo?.map((item) => ({ - appName: item.appName, name: item.name, type: "mongo", id: item.mongoId, @@ -200,7 +195,6 @@ export const extractServicesFromEnvironment = ( const redis: Services[] = environment.redis?.map((item) => ({ - appName: item.appName, name: item.name, type: "redis", id: item.redisId, @@ -213,7 +207,6 @@ export const extractServicesFromEnvironment = ( const mysql: Services[] = environment.mysql?.map((item) => ({ - appName: item.appName, name: item.name, type: "mysql", id: item.mysqlId, @@ -242,7 +235,6 @@ export const extractServicesFromEnvironment = ( } } return { - appName: item.appName, name: item.name, type: "compose", id: item.composeId, From 149293f4d33e0c04a2ad8a5d57e462e97349c0b5 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 13:57:17 -0600 Subject: [PATCH 06/18] feat: enhance mysql configuration with specific column selections Updated the mysql configuration in the environment service to include specific column selections for the server object. This change improves data structure clarity and allows for more precise data handling in future queries. --- packages/server/src/services/environment.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/server/src/services/environment.ts b/packages/server/src/services/environment.ts index bf4920193f..9be18a2871 100644 --- a/packages/server/src/services/environment.ts +++ b/packages/server/src/services/environment.ts @@ -99,7 +99,12 @@ export const findEnvironmentById = async (environmentId: string) => { }, mysql: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, }, columns: { mysqlId: true, From a360a259f5961a29542af7d1f1afe9765098bec8 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 14:02:00 -0600 Subject: [PATCH 07/18] feat: add admin-only endpoint for project permissions with detailed environment data Introduced a new API endpoint `allForPermissions` to retrieve projects along with their environments and services specifically for admin users. This enhancement allows for a more comprehensive permissions UI by including detailed information about each environment and its associated applications, improving the overall user experience in managing permissions. --- .../settings/users/add-permissions.tsx | 12 +- apps/dokploy/server/api/routers/project.ts | 106 +++++++++++++++++- 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index d3f8af31c7..6fff4e80c1 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -28,8 +28,12 @@ import { import { Switch } from "@/components/ui/switch"; import { api, type RouterOutputs } from "@/utils/api"; -type Project = RouterOutputs["project"]["all"][number]; -type Environment = Project["environments"][number]; +/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */ +type ProjectForPermissions = RouterOutputs["project"]["allForPermissions"][number]; +type EnvironmentForPermissions = ProjectForPermissions["environments"][number]; + +type Project = ProjectForPermissions; +type Environment = EnvironmentForPermissions; export type Services = { appName: string; @@ -173,7 +177,9 @@ interface Props { export const AddUserPermissions = ({ userId }: Props) => { const [isOpen, setIsOpen] = useState(false); - const { data: projects } = api.project.all.useQuery(); + const { data: projects } = api.project.allForPermissions.useQuery(undefined, { + enabled: isOpen, + }); const { data, refetch } = api.user.one.useQuery( { diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 3c9cc23cc1..395530c43a 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -37,7 +37,11 @@ import { TRPCError } from "@trpc/server"; import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, +} from "@/server/api/trpc"; import { apiCreateProject, apiFindOneProject, @@ -332,6 +336,106 @@ export const projectRouter = createTRPCRouter({ }); }), + /** All projects with full environments and services for the admin permissions UI. Admin only. */ + allForPermissions: adminProcedure.query(async ({ ctx }) => { + return await db.query.projects.findMany({ + where: eq(projects.organizationId, ctx.session.activeOrganizationId), + orderBy: desc(projects.createdAt), + columns: { + projectId: true, + name: true, + }, + with: { + environments: { + columns: { + environmentId: true, + name: true, + isDefault: true, + }, + with: { + applications: { + columns: { + applicationId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + mariadb: { + columns: { + mariadbId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + postgres: { + columns: { + postgresId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + mysql: { + columns: { + mysqlId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + mongo: { + columns: { + mongoId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + redis: { + columns: { + redisId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + compose: { + columns: { + composeId: true, + appName: true, + name: true, + createdAt: true, + composeStatus: true, + description: true, + serverId: true, + }, + }, + }, + }, + }, + }); + }), + search: protectedProcedure .input( z.object({ From 612e73bb80aa269414f197c10a50e0d76a45adcd Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 20:02:48 +0000 Subject: [PATCH 08/18] [autofix.ci] apply automated fixes --- .../components/dashboard/settings/users/add-permissions.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index 6fff4e80c1..eb3ca3f7ac 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -29,7 +29,8 @@ import { Switch } from "@/components/ui/switch"; import { api, type RouterOutputs } from "@/utils/api"; /** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */ -type ProjectForPermissions = RouterOutputs["project"]["allForPermissions"][number]; +type ProjectForPermissions = + RouterOutputs["project"]["allForPermissions"][number]; type EnvironmentForPermissions = ProjectForPermissions["environments"][number]; type Project = ProjectForPermissions; From 7da69862e1d965828d16fc22e9c0d716383c815a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 14:07:16 -0600 Subject: [PATCH 09/18] refactor: update project query to use permissions-aware endpoint Replaced the existing project query with the new `allForPermissions` endpoint to enhance data retrieval for server monitoring settings. This change aligns with recent API enhancements aimed at improving permissions management. --- .../components/dashboard/settings/servers/setup-monitoring.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx b/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx index 6468ce25d6..7015934362 100644 --- a/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx +++ b/apps/dokploy/components/dashboard/settings/servers/setup-monitoring.tsx @@ -100,7 +100,7 @@ export const SetupMonitoring = ({ serverId }: Props) => { const url = useUrl(); - const { data: projects } = api.project.all.useQuery(); + const { data: projects } = api.project.allForPermissions.useQuery(); const extractServicesFromProjects = () => { if (!projects) return []; From 6c1f2372ed6aa203d1f89bfcc6487bf3501b3ca6 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 14:15:47 -0600 Subject: [PATCH 10/18] refactor: clean up project dashboard and API response structure Removed unused imports and redundant code in the project dashboard component to enhance readability. Updated the API project router to streamline the data structure by eliminating unnecessary domain retrievals, while ensuring essential application and compose details are still included. This refactor improves maintainability and optimizes data handling for the project management interface. --- .../components/dashboard/projects/show.tsx | 128 ------------------ .../settings/users/add-permissions.tsx | 1 - apps/dokploy/server/api/routers/project.ts | 10 +- 3 files changed, 4 insertions(+), 135 deletions(-) diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index c3d4d498be..f25fb6d478 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -2,7 +2,6 @@ import { AlertTriangle, ArrowUpDown, BookIcon, - ExternalLinkIcon, FolderInput, Loader2, MoreHorizontalIcon, @@ -16,7 +15,6 @@ import { toast } from "sonner"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; -import { StatusTooltip } from "@/components/shared/status-tooltip"; import { AlertDialog, AlertDialogAction, @@ -40,10 +38,8 @@ import { import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -280,14 +276,6 @@ export const ShowProjects = () => { ) .reduce((acc, curr) => acc + curr, 0); - const haveServicesWithDomains = project?.environments - .map( - (env) => - env.applications.length > 0 || - env.compose.length > 0, - ) - .some(Boolean); - // Find default environment from accessible environments, or fall back to first accessible environment const accessibleEnvironment = project?.environments.find((env) => env.isDefault) || @@ -313,122 +301,6 @@ export const ShowProjects = () => { }} > - {haveServicesWithDomains ? ( - - - - - e.stopPropagation()} - > - {project.environments.some( - (env) => env.applications.length > 0, - ) && ( - - - Applications - - {project.environments.map((env) => - env.applications.map((app) => ( -
- - - - {app.name} - - - - {app.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} - {project.environments.some( - (env) => env.compose.length > 0, - ) && ( - - - Compose - - {project.environments.map((env) => - env.compose.map((comp) => ( -
- - - - {comp.name} - - - - {comp.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} -
-
- ) : null} diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index eb3ca3f7ac..d0a2a26fa8 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -33,7 +33,6 @@ type ProjectForPermissions = RouterOutputs["project"]["allForPermissions"][number]; type EnvironmentForPermissions = ProjectForPermissions["environments"][number]; -type Project = ProjectForPermissions; type Environment = EnvironmentForPermissions; export type Services = { diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 395530c43a..b026b6d2cd 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -284,11 +284,10 @@ export const projectRouter = createTRPCRouter({ environments: { with: { applications: { - with: { - domains: true, - }, columns: { applicationId: true, + name: true, + applicationStatus: true, }, }, mariadb: { @@ -317,11 +316,10 @@ export const projectRouter = createTRPCRouter({ }, }, compose: { - with: { - domains: true, - }, columns: { composeId: true, + name: true, + composeStatus: true, }, }, }, From cc3b902d1e359e8746285ead0f48f9bd7734003a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 14:20:08 -0600 Subject: [PATCH 11/18] feat: include project name in API response columns Added the 'name' column to the project API response structure to enhance the data returned for project queries. This change improves the clarity and usability of the API by ensuring that project names are included in the response, facilitating better data handling for clients. --- apps/dokploy/server/api/routers/project.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index b026b6d2cd..e270ee4b40 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -223,27 +223,34 @@ export const projectRouter = createTRPCRouter({ applications.applicationId, accessedServices, ), - with: { domains: true }, columns: { applicationId: true, + name: true, + applicationStatus: true, }, }, mariadb: { where: buildServiceFilter(mariadb.mariadbId, accessedServices), columns: { mariadbId: true, + name: true, + applicationStatus: true, }, }, mongo: { where: buildServiceFilter(mongo.mongoId, accessedServices), columns: { mongoId: true, + name: true, + applicationStatus: true, }, }, mysql: { where: buildServiceFilter(mysql.mysqlId, accessedServices), columns: { mysqlId: true, + name: true, + applicationStatus: true, }, }, postgres: { @@ -253,25 +260,31 @@ export const projectRouter = createTRPCRouter({ ), columns: { postgresId: true, + name: true, + applicationStatus: true, }, }, redis: { where: buildServiceFilter(redis.redisId, accessedServices), columns: { redisId: true, + name: true, + applicationStatus: true, }, }, compose: { where: buildServiceFilter(compose.composeId, accessedServices), - with: { domains: true }, columns: { composeId: true, + name: true, + composeStatus: true, }, }, }, columns: { environmentId: true, isDefault: true, + name: true, }, }, }, @@ -324,6 +337,7 @@ export const projectRouter = createTRPCRouter({ }, }, columns: { + name: true, environmentId: true, isDefault: true, }, From 964e3c415048694f22fdd703891140362cb11b05 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 1 Mar 2026 15:11:41 -0600 Subject: [PATCH 12/18] feat: enhance request logging display with formatted status and duration Added helper functions to format status labels and execution durations in the requests dashboard. Updated the display logic to show "N/A" for zero status and improved duration representation in microseconds and milliseconds. This enhances the clarity and usability of the request logs for better monitoring and analysis. --- .../components/dashboard/requests/columns.tsx | 25 +++++++++++++++++-- .../dashboard/requests/requests-table.tsx | 16 ++++++++++-- apps/dokploy/components/ui/command.tsx | 4 +-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/apps/dokploy/components/dashboard/requests/columns.tsx b/apps/dokploy/components/dashboard/requests/columns.tsx index 3648261fb9..997074fdee 100644 --- a/apps/dokploy/components/dashboard/requests/columns.tsx +++ b/apps/dokploy/components/dashboard/requests/columns.tsx @@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button"; import type { LogEntry } from "./show-requests"; export const getStatusColor = (status: number) => { + if (status === 0) { + return "secondary"; + } if (status >= 100 && status < 200) { return "outline"; } @@ -21,6 +24,24 @@ export const getStatusColor = (status: number) => { return "destructive"; }; +const formatStatusLabel = (status: number) => { + if (status === 0) { + return "N/A"; + } + return status; +}; + +const formatDuration = (nanos: number) => { + const ms = nanos / 1000000; + if (ms < 1) { + return `${(nanos / 1000).toFixed(2)} µs`; + } + if (ms < 1000) { + return `${ms.toFixed(2)} ms`; + } + return `${(ms / 1000).toFixed(2)} s`; +}; + export const columns: ColumnDef[] = [ { accessorKey: "level", @@ -59,10 +80,10 @@ export const columns: ColumnDef[] = [
- Status: {log.OriginStatus} + Status: {formatStatusLabel(log.OriginStatus)} - Exec Time: {`${log.Duration / 1000000000}s`} + Exec Time: {formatDuration(log.Duration)} IP: {log.ClientAddr}
diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 45a531324f..e804b065bd 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { return JSON.stringify(value, null, 2); } if (key === "Duration" || key === "OriginDuration" || key === "Overhead") { - return `${value / 1000000000} s`; + const nanos = Number(value); + const ms = nanos / 1000000; + if (ms < 1) { + return `${(nanos / 1000).toFixed(2)} µs`; + } + if (ms < 1000) { + return `${ms.toFixed(2)} ms`; + } + return `${(ms / 1000).toFixed(2)} s`; } if (key === "level") { return {value}; @@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { return {value}; } if (key === "DownstreamStatus" || key === "OriginStatus") { - return {value}; + const num = Number(value); + if (num === 0) { + return N/A; + } + return {value}; } return value; }; diff --git a/apps/dokploy/components/ui/command.tsx b/apps/dokploy/components/ui/command.tsx index ffaff86857..409823c560 100644 --- a/apps/dokploy/components/ui/command.tsx +++ b/apps/dokploy/components/ui/command.tsx @@ -13,7 +13,7 @@ const Command = React.forwardRef< Date: Mon, 2 Mar 2026 00:06:27 -0600 Subject: [PATCH 13/18] feat: add deployments dashboard with tables for deployments and queue Introduced a new deployments page that includes a table for viewing all application and compose deployments, as well as a queue table for monitoring deployment jobs. Updated the sidebar to include a link to the new deployments section. Enhanced the API to support centralized deployment queries and job queue retrieval, improving overall deployment management and visibility. --- .../deployments/show-deployments-table.tsx | 613 ++++++++++++++++++ .../deployments/show-queue-table.tsx | 146 +++++ .../components/dashboard/search-command.tsx | 8 + apps/dokploy/components/layouts/side.tsx | 7 + apps/dokploy/pages/dashboard/deployments.tsx | 71 ++ apps/dokploy/server/api/routers/deployment.ts | 36 + packages/server/src/services/deployment.ts | 137 +++- 7 files changed, 1017 insertions(+), 1 deletion(-) create mode 100644 apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx create mode 100644 apps/dokploy/components/dashboard/deployments/show-queue-table.tsx create mode 100644 apps/dokploy/pages/dashboard/deployments.tsx diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx new file mode 100644 index 0000000000..770d4efd05 --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + ChevronLeft, + ChevronRight, + ExternalLink, + Loader2, + Rocket, + Server, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DeploymentRow = + inferRouterOutputs["deployment"]["allCentralized"][number]; + +const statusVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + running: "yellow", + done: "green", + error: "red", + cancelled: "outline", +}; + +function getServiceInfo(d: DeploymentRow) { + const app = d.application; + const comp = d.compose; + if (app?.environment?.project && app.environment) { + return { + type: "Application" as const, + name: app.name, + projectId: app.environment.project.projectId, + environmentId: app.environment.environmentId, + projectName: app.environment.project.name, + environmentName: app.environment.name, + serviceId: app.applicationId, + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + }; + } + if (comp?.environment?.project && comp.environment) { + return { + type: "Compose" as const, + name: comp.name, + projectId: comp.environment.project.projectId, + environmentId: comp.environment.environmentId, + projectName: comp.environment.project.name, + environmentName: comp.environment.name, + serviceId: comp.composeId, + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + }; + } + return null; +} + +export function ShowDeploymentsTable() { + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + + const { data: deploymentsList, isLoading } = + api.deployment.allCentralized.useQuery(undefined, { + refetchInterval: 5000, + }); + + const filteredData = useMemo(() => { + if (!deploymentsList) return []; + let list = deploymentsList; + if (statusFilter !== "all") { + list = list.filter((d) => d.status === statusFilter); + } + if (typeFilter === "application") { + list = list.filter((d) => d.applicationId != null); + } else if (typeFilter === "compose") { + list = list.filter((d) => d.composeId != null); + } + if (globalFilter.trim()) { + const q = globalFilter.toLowerCase(); + list = list.filter((d) => { + const info = getServiceInfo(d); + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + ""; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? ""; + if (!info) return false; + return ( + info.name.toLowerCase().includes(q) || + info.projectName.toLowerCase().includes(q) || + info.environmentName.toLowerCase().includes(q) || + (d.title?.toLowerCase().includes(q) ?? false) || + serverName.toLowerCase().includes(q) || + buildServerName.toLowerCase().includes(q) + ); + }); + } + return list; + }, [deploymentsList, statusFilter, typeFilter, globalFilter]); + + const columns = useMemo( + () => [ + { + id: "serviceName", + accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return ; + return ( +
+ {info.type === "Application" ? ( + + ) : ( + + )} +
+ {info.name} + + {info.type} + +
+
+ ); + }, + }, + { + id: "projectName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.projectName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.projectName ?? "—"} + + ); + }, + }, + { + id: "environmentName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.environmentName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.environmentName ?? "—"} + + ); + }, + }, + { + id: "serverName", + accessorFn: (row: DeploymentRow) => + row.server?.name ?? + row.application?.server?.name ?? + row.compose?.server?.name ?? + "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const d = row.original; + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + null; + const serverType = + d.server?.serverType ?? + d.application?.server?.serverType ?? + d.compose?.server?.serverType ?? + null; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? null; + const buildServerType = + d.buildServer?.serverType ?? + d.application?.buildServer?.serverType ?? + null; + const showBuild = + buildServerName != null && buildServerName !== serverName; + if (!serverName && !showBuild) { + return ; + } + return ( +
+ {serverName && ( +
+ + {serverName} + {serverType && ( + + {serverType} + + )} +
+ )} + {showBuild && buildServerName && ( +
+ Build: + {buildServerName} + {buildServerType && ( + + {buildServerType} + + )} +
+ )} +
+ ); + }, + }, + { + accessorKey: "title", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.title || "—"} + + ), + }, + { + accessorKey: "status", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const status = row.original.status ?? "running"; + return ( + + {status} + + ); + }, + }, + { + accessorKey: "createdAt", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.createdAt + ? new Date(row.original.createdAt).toLocaleString() + : "—"} + + ), + }, + { + header: "", + id: "actions", + enableSorting: false, + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return null; + return ( + + ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + columnFilters, + globalFilter, + pagination, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + +
+ +

No deployments found

+

+ Deployments from applications and compose will + appear here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries + +
+
+ + +
+
+ + )} +
+
+ ); +} diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx new file mode 100644 index 0000000000..ad8f3d551c --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx @@ -0,0 +1,146 @@ +"use client"; + +import type { inferRouterOutputs } from "@trpc/server"; +import { ListTodo, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type QueueRow = + inferRouterOutputs["deployment"]["queueList"][number]; + +const stateVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + waiting: "secondary", + active: "yellow", + delayed: "outline", + completed: "green", + failed: "destructive", + paused: "outline", +}; + +function formatTs(ts?: number): string { + if (ts == null) return "—"; + const d = new Date(ts); + return d.toLocaleString(); +} + +function getJobLabel(row: QueueRow): string { + const d = row.data as { + applicationType?: string; + applicationId?: string; + composeId?: string; + previewDeploymentId?: string; + titleLog?: string; + type?: string; + }; + if (!d) return String(row.id); + const type = d.applicationType ?? "job"; + const title = d.titleLog ?? ""; + if (title) return title; + if (d.applicationId) return `Application ${d.applicationId.slice(0, 8)}…`; + if (d.composeId) return `Compose ${d.composeId.slice(0, 8)}…`; + if (d.previewDeploymentId) + return `Preview ${d.previewDeploymentId.slice(0, 8)}…`; + return `${type} ${String(row.id)}`; +} + +export function ShowQueueTable(props: { embedded?: boolean }) { + const { embedded = false } = props; + const { data: queueList, isLoading } = api.deployment.queueList.useQuery( + undefined, + { refetchInterval: 3000 }, + ); + + return ( +
+ {isLoading ? ( +
+ + Loading queue... +
+ ) : ( +
+ + + + Job ID + Label + Type + State + Added + Processed + Finished + Error + + + + {queueList?.length ? ( + queueList.map((row) => { + const d = row.data as Record; + const appType = d?.applicationType as string | undefined; + return ( + + + {String(row.id)} + + + {getJobLabel(row)} + + {appType ?? row.name ?? "—"} + + + {row.state} + + + + {formatTs(row.timestamp)} + + + {formatTs(row.processedOn)} + + + {formatTs(row.finishedOn)} + + + {row.failedReason ?? "—"} + + + ); + }) + ) : ( + + +
+ +

Queue is empty

+

+ Deployment jobs will appear here when they are queued. +

+
+
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 6fd7989553..bbd612d92f 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -174,6 +174,14 @@ export const SearchCommand = () => { > Projects + { + router.push("/dashboard/deployments"); + setOpen(false); + }} + > + Deployments + {!isCloud && ( <> + +
+ +
+
+ + + Deployments + + + All application and compose deployments in one place. + +
+
+ + + Deployments + Queue + + + + + + + + +
+
+
+
+ ); +} + +export default DeploymentsPage; + +DeploymentsPage.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const { user } = await validateRequest(ctx.req); + if (!user) { + return { + redirect: { + permanent: true, + destination: "/", + }, + }; + } + return { + props: {}, + }; +} diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 5aac2e8a77..5b1765bdbc 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -4,9 +4,11 @@ import { findAllDeploymentsByApplicationId, findAllDeploymentsByComposeId, findAllDeploymentsByServerId, + findAllDeploymentsCentralized, findApplicationById, findComposeById, findDeploymentById, + findMemberById, findServerById, removeDeployment, updateDeploymentStatus, @@ -22,6 +24,7 @@ import { apiFindAllByType, deployments, } from "@/server/db/schema"; +import { myQueue } from "@/server/queues/queueSetup"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const deploymentRouter = createTRPCRouter({ @@ -68,6 +71,39 @@ export const deploymentRouter = createTRPCRouter({ } return await findAllDeploymentsByServerId(input.serverId); }), + allCentralized: protectedProcedure.query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + const accessedServices = + ctx.user.role === "member" + ? (await findMemberById(ctx.user.id, orgId)).accessedServices + : null; + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + return findAllDeploymentsCentralized(orgId, accessedServices); + }), + + queueList: protectedProcedure.query(async () => { + const jobs = await myQueue.getJobs(); + const rows = await Promise.all( + jobs.map(async (job) => { + const state = await job.getState(); + console.log(job.data); + return { + id: job.id, + name: job.name ?? undefined, + data: job.data as Record, + timestamp: job.timestamp, + processedOn: job.processedOn, + finishedOn: job.finishedOn, + failedReason: job.failedReason ?? undefined, + state, + }; + }), + ); + rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + return rows; + }), allByType: protectedProcedure .input(apiFindAllByType) diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index fd6e597dc4..5d7a36f150 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -10,7 +10,11 @@ import { type apiCreateDeploymentSchedule, type apiCreateDeploymentServer, type apiCreateDeploymentVolumeBackup, + applications, + compose, deployments, + environments, + projects, } from "@dokploy/server/db/schema"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; import { @@ -19,7 +23,7 @@ import { } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { format } from "date-fns"; -import { desc, eq } from "drizzle-orm"; +import { desc, eq, and, inArray, or, sql } from "drizzle-orm"; import type { z } from "zod"; import { type Application, @@ -738,6 +742,137 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => { return deploymentsList; }; +const centralizedDeploymentsWith = { + application: { + columns: { applicationId: true, name: true, appName: true }, + with: { + environment: { + columns: { environmentId: true, name: true }, + with: { + project: { + columns: { projectId: true, name: true }, + }, + }, + }, + server: { + columns: { serverId: true, name: true, serverType: true }, + }, + buildServer: { + columns: { serverId: true, name: true, serverType: true }, + }, + }, + }, + compose: { + columns: { composeId: true, name: true, appName: true }, + with: { + environment: { + columns: { environmentId: true, name: true }, + with: { + project: { + columns: { projectId: true, name: true }, + }, + }, + }, + server: { + columns: { serverId: true, name: true, serverType: true }, + }, + }, + }, + server: { + columns: { serverId: true, name: true, serverType: true }, + }, + buildServer: { + columns: { serverId: true, name: true, serverType: true }, + }, +} as const; + +async function getApplicationIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise { + const rows = await db + .select({ applicationId: applications.applicationId }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(applications.applicationId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ); + return rows.map((r) => r.applicationId); +} + +async function getComposeIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise { + const rows = await db + .select({ composeId: compose.composeId }) + .from(compose) + .innerJoin( + environments, + eq(compose.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(compose.composeId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ); + return rows.map((r) => r.composeId); +} + +/** + * All deployments for applications and compose in the org. + * Pass accessedServices for members (only those services), null for owner/admin. + */ +export const findAllDeploymentsCentralized = async ( + orgId: string, + accessedServices: string[] | null, +) => { + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + + const [appIds, compIds] = await Promise.all([ + getApplicationIdsInOrg(orgId, accessedServices), + getComposeIdsInOrg(orgId, accessedServices), + ]); + + if (appIds.length === 0 && compIds.length === 0) { + return []; + } + + const conditions = [ + ...(appIds.length > 0 + ? [inArray(deployments.applicationId, appIds)] + : []), + ...(compIds.length > 0 ? [inArray(deployments.composeId, compIds)] : []), + ]; + const whereClause = + conditions.length === 0 + ? sql`1 = 0` + : conditions.length === 1 + ? conditions[0] + : or(...conditions); + + return db.query.deployments.findMany({ + where: whereClause, + orderBy: desc(deployments.createdAt), + with: centralizedDeploymentsWith, + }); +}; + export const updateDeployment = async ( deploymentId: string, deploymentData: Partial, From 08c9113405fa3d2c28ef7a5981c43b15ba39bbe8 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Tue, 3 Mar 2026 01:04:26 -0600 Subject: [PATCH 14/18] feat: implement deployment jobs API and enhance queue management Added a new endpoint to fetch deployment jobs for a server, integrating with the Inngest API to retrieve job details. Updated the queue management system to support centralized job retrieval for cloud environments, improving the deployment monitoring experience. Enhanced the UI to include action buttons for job cancellation and improved error handling for job fetching. --- apps/api/src/index.ts | 25 +- apps/api/src/service.ts | 240 ++++++++++++++++++ .../deployments/show-queue-table.tsx | 70 ++++- apps/dokploy/pages/dashboard/deployments.tsx | 25 +- apps/dokploy/server/api/routers/deployment.ts | 72 ++++-- apps/dokploy/server/utils/deploy.ts | 31 +++ packages/server/src/services/deployment.ts | 35 +++ 7 files changed, 472 insertions(+), 26 deletions(-) create mode 100644 apps/api/src/service.ts diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8ddb56dec0..0bb6e1401e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { type DeployJob, deployJobSchema, } from "./schema.js"; +import { fetchDeploymentJobs } from "./service.js"; import { deploy } from "./utils.js"; const app = new Hono(); @@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => { 200, ); } catch (error) { - console.log("error", error); logger.error("Failed to send deployment event", error); return c.json( { @@ -176,6 +176,29 @@ app.get("/health", async (c) => { return c.json({ status: "ok" }); }); +// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI +app.get("/jobs", async (c) => { + const serverId = c.req.query("serverId"); + if (!serverId) { + return c.json({ message: "serverId is required" }, 400); + } + + try { + const rows = await fetchDeploymentJobs(serverId); + return c.json(rows); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("INNGEST_BASE_URL")) { + return c.json( + { message: "INNGEST_BASE_URL is required to list deployment jobs" }, + 503, + ); + } + logger.error("Failed to fetch jobs from Inngest", { serverId, error }); + return c.json([], 200); + } +}); + // Serve Inngest functions endpoint app.on( ["GET", "POST", "PUT"], diff --git a/apps/api/src/service.ts b/apps/api/src/service.ts new file mode 100644 index 0000000000..2890ecf6b0 --- /dev/null +++ b/apps/api/src/service.ts @@ -0,0 +1,240 @@ +import { logger } from "./logger"; + +const baseUrl = process.env.INNGEST_BASE_URL ?? ""; +const signingKey = process.env.INNGEST_SIGNING_KEY ?? ""; + +/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */ +type InngestEventRow = { + internal_id?: string; + accountID?: string; + environmentID?: string; + source?: string; + sourceID?: string | null; + /** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */ + receivedAt?: string; + received_at?: string; + id: string; + name: string; + data: Record; + user?: unknown; + ts: number; + v?: string | null; + metadata?: { + fetchedAt: string; + cachedUntil: string | null; + }; +}; + +/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */ +type InngestRun = { + run_id: string; + event_id: string; + status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"? + run_started_at?: string; + ended_at?: string | null; + output?: unknown; + // dev server / API may use different casing + run_started_at_ms?: number; +}; + +function getEventReceivedAt(ev: InngestEventRow): string | undefined { + return ev.receivedAt ?? ev.received_at; +} + +/** Map Inngest run status to BullMQ-style state for the UI */ +function runStatusToState( + status: string, +): "pending" | "active" | "completed" | "failed" | "cancelled" { + const s = status.toLowerCase(); + if (s === "running") return "active"; + if (s === "completed") return "completed"; + if (s === "failed") return "failed"; + if (s === "cancelled") return "cancelled"; + if (s === "queued") return "pending"; + return "pending"; +} + +export const fetchInngestEvents = async () => { + const maxEvents = MAX_EVENTS; + const all: InngestEventRow[] = []; + let cursor: string | undefined; + + do { + const params = new URLSearchParams({ limit: "100" }); + if (cursor) { + params.set("cursor", cursor); + } + + const res = await fetch(`${baseUrl}/v1/events?${params}`, { + headers: { + Authorization: `Bearer ${signingKey}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + logger.warn("Inngest API error", { + status: res.status, + body: await res.text(), + }); + break; + } + + const body = (await res.json()) as { + data?: InngestEventRow[]; + cursor?: string; + nextCursor?: string; + }; + const data = Array.isArray(body.data) ? body.data : []; + all.push(...data); + + // Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs) + const nextCursor = + body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id; + const hasMore = data.length === 100 && nextCursor && all.length < maxEvents; + cursor = hasMore ? nextCursor : undefined; + } while (cursor); + + return all.slice(0, maxEvents); +}; + +/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */ +export const fetchInngestRunsForEvent = async ( + eventId: string, +): Promise => { + const res = await fetch( + `${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`, + { + headers: { + Authorization: `Bearer ${signingKey}`, + "Content-Type": "application/json", + }, + }, + ); + if (!res.ok) { + logger.warn("Inngest runs API error", { + eventId, + status: res.status, + body: await res.text(), + }); + return []; + } + const body = (await res.json()) as { data?: InngestRun[] }; + return Array.isArray(body.data) ? body.data : []; +}; + +/** One row for the queue UI (BullMQ-compatible shape) */ +export type DeploymentJobRow = { + id: string; + name: string; + data: Record; + timestamp: number; + processedOn?: number; + finishedOn?: number; + failedReason?: string; + state: string; +}; + +/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */ +function buildDeploymentRowsFromRuns( + events: InngestEventRow[], + runsByEventId: Map, + serverId: string, +): DeploymentJobRow[] { + const requested = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + const rows: DeploymentJobRow[] = []; + + for (const ev of requested) { + const data = (ev.data ?? {}) as Record; + const runs = runsByEventId.get(ev.id) ?? []; + + if (runs.length === 0) { + // Queued: event received but no run yet + rows.push({ + id: ev.id, + name: ev.name, + data, + timestamp: ev.ts, + processedOn: ev.ts, + finishedOn: undefined, + failedReason: undefined, + state: "pending", + }); + continue; + } + + for (const run of runs) { + const state = runStatusToState(run.status); + const runStartedMs = + run.run_started_at_ms ?? + (run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts); + const endedMs = run.ended_at + ? new Date(run.ended_at).getTime() + : undefined; + const failedReason = + state === "failed" && + run.output && + typeof run.output === "object" && + "error" in run.output + ? String((run.output as { error?: unknown }).error) + : undefined; + + rows.push({ + id: run.run_id, + name: ev.name, + data, + timestamp: runStartedMs, + processedOn: runStartedMs, + finishedOn: + state === "completed" || state === "failed" || state === "cancelled" + ? endedMs + : undefined, + failedReason, + state, + }); + } + } + + return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); +} + +/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */ +export const fetchDeploymentJobs = async ( + serverId: string, +): Promise => { + if (!signingKey) { + logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list"); + return []; + } + if (!baseUrl) { + throw new Error("INNGEST_BASE_URL is required to list deployment jobs"); + } + + const events = await fetchInngestEvents(); + + const requestedForServer = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + // Limit to avoid too many run fetches + const toFetch = requestedForServer.slice(0, 50); + const runsByEventId = new Map(); + + await Promise.all( + toFetch.map(async (ev) => { + const runs = await fetchInngestRunsForEvent(ev.id); + runsByEventId.set(ev.id, runs); + }), + ); + + return buildDeploymentRowsFromRuns(events, runsByEventId, serverId); +}; + +const DEFAULT_MAX_EVENTS = 500; + +const MAX_EVENTS = DEFAULT_MAX_EVENTS; diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx index ad8f3d551c..c9f6d9dde9 100644 --- a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx @@ -1,8 +1,10 @@ "use client"; import type { inferRouterOutputs } from "@trpc/server"; -import { ListTodo, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Table, TableBody, @@ -27,11 +29,13 @@ const stateVariants: Record< | "green" | "red" > = { + pending: "secondary", waiting: "secondary", active: "yellow", delayed: "outline", completed: "green", failed: "destructive", + cancelled: "outline", paused: "outline", }; @@ -62,11 +66,22 @@ function getJobLabel(row: QueueRow): string { } export function ShowQueueTable(props: { embedded?: boolean }) { - const { embedded = false } = props; + const { embedded: _embedded = false } = props; const { data: queueList, isLoading } = api.deployment.queueList.useQuery( undefined, { refetchInterval: 3000 }, ); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const utils = api.useUtils(); + const { mutateAsync: cancelApplicationDeployment, isPending: isCancellingApp } = + api.application.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const { mutateAsync: cancelComposeDeployment, isPending: isCancellingCompose } = + api.compose.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const isCancelling = isCancellingApp || isCancellingCompose; return (
@@ -88,6 +103,7 @@ export function ShowQueueTable(props: { embedded?: boolean }) { Processed Finished Error + Actions @@ -95,6 +111,8 @@ export function ShowQueueTable(props: { embedded?: boolean }) { queueList.map((row) => { const d = row.data as Record; const appType = d?.applicationType as string | undefined; + const pathInfo = row.servicePath; + const hasLink = pathInfo?.href != null; return ( @@ -121,12 +139,58 @@ export function ShowQueueTable(props: { embedded?: boolean }) { {row.failedReason ?? "—"} + +
+ {hasLink ? ( + + ) : ( + + )} + {isCloud && + row.state === "active" && + (d?.applicationId != null || d?.composeId != null) && ( + + )} +
+
); }) ) : ( - +

Queue is empty

diff --git a/apps/dokploy/pages/dashboard/deployments.tsx b/apps/dokploy/pages/dashboard/deployments.tsx index 9d2ce4e535..744301abf2 100644 --- a/apps/dokploy/pages/dashboard/deployments.tsx +++ b/apps/dokploy/pages/dashboard/deployments.tsx @@ -1,6 +1,7 @@ import { validateRequest } from "@dokploy/server/lib/auth"; import { Rocket } from "lucide-react"; import type { GetServerSidePropsContext } from "next"; +import { useRouter } from "next/router"; import type { ReactElement } from "react"; import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; @@ -13,7 +14,29 @@ import { } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +const TAB_VALUES = ["deployments", "queue"] as const; +type TabValue = (typeof TAB_VALUES)[number]; + +function isValidTab(t: string): t is TabValue { + return TAB_VALUES.includes(t as TabValue); +} + function DeploymentsPage() { + const router = useRouter(); + const tab = + router.query.tab && isValidTab(router.query.tab as string) + ? (router.query.tab as TabValue) + : "deployments"; + + const setTab = (value: string) => { + if (!isValidTab(value)) return; + router.replace( + { pathname: "/dashboard/deployments", query: { tab: value } }, + undefined, + { shallow: true }, + ); + }; + return (
@@ -30,7 +53,7 @@ function DeploymentsPage() {
- + Deployments Queue diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 5b1765bdbc..bcf4ddbf79 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -10,7 +10,9 @@ import { findDeploymentById, findMemberById, findServerById, + IS_CLOUD, removeDeployment, + resolveServicePath, updateDeploymentStatus, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -23,8 +25,13 @@ import { apiFindAllByServer, apiFindAllByType, deployments, + server, } from "@/server/db/schema"; import { myQueue } from "@/server/queues/queueSetup"; +import { + fetchDeployApiJobs, + type QueueJobRow, +} from "@/server/utils/deploy"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const deploymentRouter = createTRPCRouter({ @@ -83,26 +90,51 @@ export const deploymentRouter = createTRPCRouter({ return findAllDeploymentsCentralized(orgId, accessedServices); }), - queueList: protectedProcedure.query(async () => { - const jobs = await myQueue.getJobs(); - const rows = await Promise.all( - jobs.map(async (job) => { - const state = await job.getState(); - console.log(job.data); - return { - id: job.id, - name: job.name ?? undefined, - data: job.data as Record, - timestamp: job.timestamp, - processedOn: job.processedOn, - finishedOn: job.finishedOn, - failedReason: job.failedReason ?? undefined, - state, - }; - }), + queueList: protectedProcedure.query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + let rows: QueueJobRow[]; + + if (IS_CLOUD) { + const servers = await db.query.server.findMany({ + where: eq(server.organizationId, orgId), + columns: { serverId: true }, + }); + rows = []; + for (const { serverId } of servers) { + const serverRows = await fetchDeployApiJobs(serverId); + rows.push(...serverRows); + } + rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + } else { + const jobs = await myQueue.getJobs(); + const jobRows = await Promise.all( + jobs.map(async (job) => { + const state = await job.getState(); + return { + id: String(job.id), + name: job.name ?? undefined, + data: job.data as Record, + timestamp: job.timestamp, + processedOn: job.processedOn, + finishedOn: job.finishedOn, + failedReason: job.failedReason ?? undefined, + state, + }; + }), + ); + jobRows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + rows = jobRows; + } + + return Promise.all( + rows.map(async (row) => ({ + ...row, + servicePath: await resolveServicePath( + orgId, + (row.data ?? {}) as Record, + ), + })), ); - rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); - return rows; }), allByType: protectedProcedure @@ -115,10 +147,8 @@ export const deploymentRouter = createTRPCRouter({ rollback: true, }, }); - return deploymentsList; }), - killProcess: protectedProcedure .input( z.object({ diff --git a/apps/dokploy/server/utils/deploy.ts b/apps/dokploy/server/utils/deploy.ts index f4591e3b3a..bb429002a7 100644 --- a/apps/dokploy/server/utils/deploy.ts +++ b/apps/dokploy/server/utils/deploy.ts @@ -50,3 +50,34 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => { throw error; } }; + +export type QueueJobRow = { + id: string; + name?: string; + data: Record; + timestamp?: number; + processedOn?: number; + finishedOn?: number; + failedReason?: string; + state: string; +}; + +export const fetchDeployApiJobs = async ( + serverId: string, +): Promise => { + try { + const res = await fetch( + `${process.env.SERVER_URL}/jobs?serverId=${encodeURIComponent(serverId)}`, + { + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + }, + ); + if (!res.ok) return []; + return (await res.json()) as QueueJobRow[]; + } catch { + return []; + } +}; diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 5d7a36f150..dbb632bf7c 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -42,6 +42,41 @@ import { findScheduleById } from "./schedule"; import { findServerById, type Server } from "./server"; import { findVolumeBackupById } from "./volume-backups"; +export type ServicePath = { href: string | null; label: string }; + +export async function resolveServicePath( + orgId: string, + data: Record, +): Promise { + try { + const applicationId = data?.applicationId as string | undefined; + const composeId = data?.composeId as string | undefined; + if (applicationId) { + const app = await findApplicationById(applicationId); + if (app.environment.project.organizationId !== orgId) { + return { href: null, label: "Application" }; + } + return { + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + label: "Application", + }; + } + if (composeId) { + const comp = await findComposeById(composeId); + if (comp.environment.project.organizationId !== orgId) { + return { href: null, label: "Compose" }; + } + return { + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + label: "Compose", + }; + } + } catch { + // not found or unauthorized + } + return { href: null, label: "—" }; +} + export type Deployment = typeof deployments.$inferSelect; export const findDeploymentById = async (deploymentId: string) => { From 7599565e73b8680950f40eaf0a42958dc7fe6101 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 3 Mar 2026 07:05:09 +0000 Subject: [PATCH 15/18] [autofix.ci] apply automated fixes --- .../deployments/show-queue-table.tsx | 27 ++++++++++++------- apps/dokploy/server/api/routers/deployment.ts | 5 +--- packages/server/src/services/deployment.ts | 4 +-- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx index c9f6d9dde9..f86df48b09 100644 --- a/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx +++ b/apps/dokploy/components/dashboard/deployments/show-queue-table.tsx @@ -73,14 +73,18 @@ export function ShowQueueTable(props: { embedded?: boolean }) { ); const { data: isCloud } = api.settings.isCloud.useQuery(); const utils = api.useUtils(); - const { mutateAsync: cancelApplicationDeployment, isPending: isCancellingApp } = - api.application.cancelDeployment.useMutation({ - onSuccess: () => void utils.deployment.queueList.invalidate(), - }); - const { mutateAsync: cancelComposeDeployment, isPending: isCancellingCompose } = - api.compose.cancelDeployment.useMutation({ - onSuccess: () => void utils.deployment.queueList.invalidate(), - }); + const { + mutateAsync: cancelApplicationDeployment, + isPending: isCancellingApp, + } = api.application.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); + const { + mutateAsync: cancelComposeDeployment, + isPending: isCancellingCompose, + } = api.compose.cancelDeployment.useMutation({ + onSuccess: () => void utils.deployment.queueList.invalidate(), + }); const isCancelling = isCancellingApp || isCancellingCompose; return ( @@ -149,11 +153,14 @@ export function ShowQueueTable(props: { embedded?: boolean }) { ) : ( - + + — + )} {isCloud && row.state === "active" && - (d?.applicationId != null || d?.composeId != null) && ( + (d?.applicationId != null || + d?.composeId != null) && (