From 58f2ad176a6a310eec348a76b70c1b6bd85d91e9 Mon Sep 17 00:00:00 2001 From: Sagar Chhetri Date: Fri, 7 Aug 2026 14:08:55 +0545 Subject: [PATCH 1/3] feat: turn home into a comprehensive org dashboard Replace the thin welcome + recent-deploys view with inventory KPIs, attention (failed deploys / errored services), server summary including the Dokploy host, deploy queue, and recent projects. Add a limited homeSummary API so the page no longer loads full deployment history. Co-authored-by: Cursor --- .../components/dashboard/home/show-home.tsx | 876 +++++++++++++++--- apps/dokploy/components/layouts/user-nav.tsx | 2 +- apps/dokploy/pages/dashboard/home.tsx | 23 +- apps/dokploy/server/api/routers/deployment.ts | 69 +- apps/dokploy/server/api/routers/project.ts | 293 +++++- packages/server/src/services/deployment.ts | 105 ++- 6 files changed, 1181 insertions(+), 187 deletions(-) diff --git a/apps/dokploy/components/dashboard/home/show-home.tsx b/apps/dokploy/components/dashboard/home/show-home.tsx index f77b71f716..c322187e92 100644 --- a/apps/dokploy/components/dashboard/home/show-home.tsx +++ b/apps/dokploy/components/dashboard/home/show-home.tsx @@ -1,39 +1,81 @@ +import type { inferRouterOutputs } from "@trpc/server"; import { formatDistanceToNow } from "date-fns"; -import { ArrowRight, Rocket, Server } from "lucide-react"; +import { + Activity, + AlertTriangle, + ArrowRight, + Boxes, + CheckCircle2, + Folder, + HardDrive, + Loader2, + type LucideIcon, + Monitor, + Rocket, + Server, + XCircle, +} from "lucide-react"; import Link from "next/link"; import { useMemo } from "react"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import type { AppRouter } from "@/server/api/root"; import { api } from "@/utils/api"; -type DeploymentStatus = "idle" | "running" | "done" | "error"; +type HomeStats = inferRouterOutputs["project"]["homeStats"]; +type HomeSummary = inferRouterOutputs["deployment"]["homeSummary"]; +type DeploymentRow = HomeSummary["recent"][number]; +type ErroredService = HomeStats["erroredServices"][number]; -const statusDotClass: Record = { - done: "bg-emerald-500", - running: "bg-amber-500", - error: "bg-red-500", - idle: "bg-muted-foreground/40", +const statusVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + running: "yellow", + done: "green", + error: "red", + cancelled: "outline", + idle: "secondary", }; -function getServiceInfo(d: any) { +const serviceTypePath: Record = { + application: "application", + compose: "compose", + libsql: "libsql", + mariadb: "mariadb", + mongo: "mongo", + mysql: "mysql", + postgres: "postgres", + redis: "redis", +}; + +function getServiceInfo(d: DeploymentRow) { const app = d.application; const comp = d.compose; const serverName: string = d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy"; if (app?.environment?.project && app.environment) { return { - name: app.name as string, - environment: app.environment.name as string, - projectName: app.environment.project.name as string, + name: app.name, + environment: app.environment.name, + projectName: app.environment.project.name, serverName, href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, }; } if (comp?.environment?.project && comp.environment) { return { - name: comp.name as string, - environment: comp.environment.name as string, - projectName: comp.environment.project.name as string, + name: comp.name, + environment: comp.environment.name, + projectName: comp.environment.project.name, serverName, href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, }; @@ -41,14 +83,20 @@ function getServiceInfo(d: any) { return null; } +function erroredServiceHref(service: ErroredService) { + return `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${serviceTypePath[service.type]}/${service.id}`; +} + function StatCard({ label, value, delta, + loading, }: { label: string; value: string; delta?: string; + loading?: boolean; }) { return (
@@ -56,9 +104,17 @@ function StatCard({ {label}
- {value} - {delta && ( - {delta} + {loading ? ( + + ) : ( + {value} + )} + {loading ? ( + + ) : ( + delta && ( + {delta} + ) )}
@@ -68,45 +124,203 @@ function StatCard({ function StatusListCard({ label, items, + loading, }: { label: string; items: { dotClass: string; label: string; count: number }[]; + loading?: boolean; }) { return (
{label} -
    - {items.map((item) => ( -
  • - - {item.count} - {item.label} -
  • - ))} -
+ {loading ? ( +
+ + + +
+ ) : ( +
    + {items.map((item) => ( +
  • + + + {item.count} + + {item.label} +
  • + ))} +
+ )} +
+ ); +} + +function SectionHeader({ + icon: Icon, + title, + href, + linkLabel = "view all →", +}: { + icon: LucideIcon; + title: string; + href?: string; + linkLabel?: string; +}) { + return ( +
+
+ +

{title}

+
+ {href && ( + + {linkLabel} + + )} +
+ ); +} + +function EmptyBlock({ + icon: Icon, + message, + compact, +}: { + icon: LucideIcon; + message: string; + compact?: boolean; +}) { + return ( +
+ + {message}
); } +function DeploymentList({ + items, + emptyMessage, +}: { + items: DeploymentRow[]; + emptyMessage: string; +}) { + const rows = items + .map((d) => ({ d, info: getServiceInfo(d) })) + .filter( + ( + row, + ): row is { + d: DeploymentRow; + info: NonNullable>; + } => !!row.info, + ); + + if (rows.length === 0) { + return ; + } + + return ( +
    + {rows.map(({ d, info }) => { + const status = d.status ?? "idle"; + return ( +
  • + +
    + {info.name} + + {info.projectName} · {info.environment} + +
    + + + {info.serverName} + + + {status} + + + {formatDistanceToNow(new Date(d.createdAt), { + addSuffix: true, + })} + + +
  • + ); + })} +
+ ); +} + +function deployDelta(last7d: number, prev7d: number) { + if (prev7d > 0) { + const pct = Math.round(((last7d - prev7d) / prev7d) * 100); + return `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`; + } + if (last7d > 0) return "no prior data"; + return "no activity yet"; +} + export const ShowHome = () => { - const { data: auth } = api.user.get.useQuery(); - const { data: homeStats } = api.project.homeStats.useQuery(); - const { data: permissions } = api.user.getPermissions.useQuery(); + const { data: auth, isLoading: authLoading } = api.user.get.useQuery(); + const { data: homeStats, isLoading: statsLoading } = + api.project.homeStats.useQuery(); + const { data: permissions, isLoading: permissionsLoading } = + api.user.getPermissions.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const canReadDeployments = !!permissions?.deployment.read; - const { data: deployments } = api.deployment.allCentralized.useQuery( - undefined, - { + const canReadServers = !!permissions?.server.read; + const canReadDocker = !!permissions?.docker.read; + const canReadMonitoring = !!permissions?.monitoring.read; + const isAdmin = auth?.role === "owner" || auth?.role === "admin"; + + const { data: deploySummary, isLoading: deployLoading } = + api.deployment.homeSummary.useQuery(undefined, { enabled: canReadDeployments, refetchInterval: 10000, + }); + + const { data: servers, isLoading: serversLoading } = api.server.all.useQuery( + undefined, + { + enabled: canReadServers, }, ); + const { data: queue, isLoading: queueLoading } = + api.deployment.queueList.useQuery(undefined, { + enabled: canReadDeployments, + refetchInterval: 10000, + }); + + const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); + const { data: infraHealth, isLoading: healthLoading } = + api.settings.checkInfrastructureHealth.useQuery(undefined, { + enabled: !!isAdmin && isCloud === false, + retry: false, + }); + const firstName = auth?.user?.firstName?.trim(); + const loadingStats = statsLoading || authLoading || permissionsLoading; const totals = homeStats ?? { projects: 0, @@ -122,59 +336,61 @@ export const ShowHome = () => { idle: 0, }; - const recentDeployments = useMemo(() => { - if (!deployments) return []; - return [...deployments] - .sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ) - .slice(0, 10); - }, [deployments]); - - const deployStats = useMemo(() => { - const now = Date.now(); - const weekMs = 7 * 24 * 60 * 60 * 1000; - const lastStart = now - weekMs; - const prevStart = now - 2 * weekMs; - - const last: NonNullable = []; - const prev: NonNullable = []; - for (const d of deployments ?? []) { - const t = new Date(d.createdAt).getTime(); - if (t >= lastStart) last.push(d); - else if (t >= prevStart) prev.push(d); - } + const deployStats = deploySummary?.stats; + const failedDeploys = deploySummary?.failed ?? []; + const recentDeployments = deploySummary?.recent ?? []; + const erroredServices = homeStats?.erroredServices ?? []; + const recentProjects = homeStats?.recentProjects ?? []; + const dokployHostServices = homeStats?.dokployHostServices ?? 0; + const servicesByServerId = homeStats?.servicesByServerId ?? {}; - const lastCount = last.length; - const prevCount = prev.length; - let delta: string | undefined; - if (prevCount > 0) { - const pct = Math.round(((lastCount - prevCount) / prevCount) * 100); - delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`; - } else if (lastCount > 0) { - delta = "no prior data"; - } else { - delta = "no activity yet"; + const serverSummary = useMemo(() => { + if (!servers) return { total: 0, active: 0, inactive: 0, services: 0 }; + let active = 0; + let inactive = 0; + let services = 0; + for (const s of servers) { + if (s.serverStatus === "inactive") inactive++; + else active++; + services += servicesByServerId[s.serverId] ?? s.totalSum ?? 0; } + return { total: servers.length, active, inactive, services }; + }, [servers, servicesByServerId]); - return { value: String(lastCount), delta }; - }, [deployments]); + const attentionCount = + erroredServices.length + (canReadDeployments ? failedDeploys.length : 0); + const showAttention = !loadingStats && attentionCount > 0; + const showServerSummary = !isCloud || canReadServers; return (
-
-

- {firstName ? `Welcome back, ${firstName}` : "Welcome back"} -

- +
+
+

+ {firstName ? `Welcome back, ${firstName}` : "Welcome back"} +

+

+ Overview of your projects, services, and deployments +

+
+
+ {canReadDeployments && ( + + )} + +
@@ -182,19 +398,32 @@ export const ShowHome = () => { label="Projects" value={String(totals.projects)} delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`} + loading={loadingStats} /> { />
-
-
-
- -

Recent deployments

+ {showAttention && ( +
+
+ +

Needs attention

+ {canReadDeployments && (deployStats?.failed7d ?? 0) > 0 && ( + + {deployStats?.failed7d} failed / 7d + + )} +
+
+ {erroredServices.length > 0 && ( +
+
+ Errored services +
+
    + {erroredServices.map((service) => ( +
  • + + + {service.type} + +
    + + {service.name} + + + {service.projectName} ·{" "} + {service.environmentName} + +
    + + +
  • + ))} +
+
+ )} + {canReadDeployments && failedDeploys.length > 0 && ( +
+
+ Failed deployments +
+ +
+ )}
+
+ )} + + {showServerSummary && ( +
+ + {(statsLoading || (canReadServers && serversLoading)) && ( +
+ + Loading servers… +
+ )} + {!statsLoading && !(canReadServers && serversLoading) && ( +
+
+
+ + Hosts + + + {(isCloud ? 0 : 1) + serverSummary.total} + + + {isCloud + ? "remote only" + : `${serverSummary.total} remote`} + +
+
+ + On Dokploy + + + {isCloud ? "—" : dokployHostServices} + + + {isCloud ? "cloud host" : "local services"} + +
+
+ + On remotes + + + {serverSummary.services} + + + {serverSummary.active} active + {serverSummary.inactive > 0 + ? ` · ${serverSummary.inactive} inactive` + : ""} + +
+
+ + Version + + + {dokployVersion ?? "—"} + + + Dokploy + +
+
+ + {!isCloud && isAdmin && ( +
+ + Infrastructure + + {healthLoading ? ( + + + Checking… + + ) : infraHealth ? ( + ( + [ + ["Postgres", infraHealth.postgres], + ["Redis", infraHealth.redis], + ["Traefik", infraHealth.traefik], + ] as const + ).map(([name, service]) => ( + + {service.status === "healthy" ? ( + + ) : ( + + )} + {name} + + )) + ) : ( + + Unavailable + + )} +
+ )} + +
    + {!isCloud && ( +
  • + + + + +
    + + Dokploy host + + + Local web server + +
    + + {dokployHostServices}{" "} + {dokployHostServices === 1 ? "service" : "services"} + + + local + + + +
  • + )} + {canReadServers && + servers?.map((server) => { + const serviceCount = + servicesByServerId[server.serverId] ?? + server.totalSum ?? + 0; + const inactive = server.serverStatus === "inactive"; + return ( +
  • + + + + +
    + + {server.name} + + + {server.ipAddress || "Remote server"} + {server.serverType === "build" + ? " · build" + : ""} + +
    + + {serviceCount}{" "} + {serviceCount === 1 ? "service" : "services"} + + + {server.serverStatus ?? "active"} + + + +
  • + ); + })} + {canReadServers && serverSummary.total === 0 && ( +
  • + No remote servers yet.{" "} + + Add a server + {" "} + to deploy remotely. +
  • + )} +
+
+ )} +
+ )} + +
+
+ + {canReadDeployments && (deployStats?.running ?? 0) > 0 && ( +
+ + {deployStats?.running} currently deploying + +
+ )} + {permissionsLoading || (canReadDeployments && deployLoading) ? ( +
+ + Loading deployments… +
+ ) : !canReadDeployments ? ( + + ) : ( + + )} +
+ +
{canReadDeployments && ( - - view all → - +
+ + {queueLoading ? ( +
+ +
+ ) : ( +
+ + {queue?.length ?? 0} + + + {(queue?.length ?? 0) === 1 + ? "job in queue" + : "jobs in queue"} + +
+ )} +
+ )} + + {!isCloud && canReadMonitoring && ( +
+ +
+

+ Host and container CPU, memory, and disk metrics. +

+
+ + {dokployHostServices} on host + + {serverSummary.total > 0 && ( + + {serverSummary.total} remotes + + )} +
+
+
)}
- {!canReadDeployments ? ( -
- - You do not have permission to view deployments. -
- ) : recentDeployments.length === 0 ? ( -
- - No deployments yet. +
+ +
+ + {statsLoading ? ( +
+ + Loading projects…
+ ) : recentProjects.length === 0 ? ( + ) : ( -
    - {recentDeployments.map((d) => { - const info = getServiceInfo(d); - if (!info) return null; - const status = (d.status ?? "idle") as DeploymentStatus; +
    + {recentProjects.map((project) => { + const href = project.defaultEnvironmentId + ? `/dashboard/project/${project.projectId}/environment/${project.defaultEnvironmentId}` + : `/dashboard/project/${project.projectId}`; return ( -
  • - - -
    - {info.name} - - {info.projectName} · {info.environment} - -
    - - - {info.serverName} + +
    + + {project.name} - - {status} + +
    + {project.description && ( +

    + {project.description} +

    + )} +
    + + {project.services}{" "} + {project.services === 1 ? "service" : "services"} - - {formatDistanceToNow(new Date(d.createdAt), { + + {formatDistanceToNow(new Date(project.createdAt), { addSuffix: true, })} - - logs → - - -
  • +
    + ); })} -
+
)}
+ + {(canReadDocker || + canReadServers || + (!isCloud && canReadMonitoring)) && ( +
+ {!isCloud && canReadMonitoring && ( + + )} + {canReadServers && ( + + )} + {canReadDocker && ( + + )} + {canReadDeployments && ( + + )} +
+ )}
diff --git a/apps/dokploy/components/layouts/user-nav.tsx b/apps/dokploy/components/layouts/user-nav.tsx index aa9747cfbe..6dfba914cb 100644 --- a/apps/dokploy/components/layouts/user-nav.tsx +++ b/apps/dokploy/components/layouts/user-nav.tsx @@ -80,7 +80,7 @@ export const UserNav = () => { { - router.push("/dashboard/home"); + router.push("/dashboard/projects"); }} > Projects diff --git a/apps/dokploy/pages/dashboard/home.tsx b/apps/dokploy/pages/dashboard/home.tsx index a1e933abe5..756d90598b 100644 --- a/apps/dokploy/pages/dashboard/home.tsx +++ b/apps/dokploy/pages/dashboard/home.tsx @@ -1,4 +1,5 @@ import { validateRequest } from "@dokploy/server/lib/auth"; +import { hasPermission } from "@dokploy/server/services/permission"; import { createServerSideHelpers } from "@trpc/react-query/server"; import type { GetServerSidePropsContext } from "next"; import type { ReactElement } from "react"; @@ -42,8 +43,26 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { transformer: superjson, }); - await helpers.settings.isCloud.prefetch(); - await helpers.user.get.prefetch(); + const permissionCtx = { + user: { id: user.id }, + session: { activeOrganizationId: session?.activeOrganizationId || "" }, + }; + + const [canReadDeployments, canReadServers] = await Promise.all([ + hasPermission(permissionCtx, { deployment: ["read"] }), + hasPermission(permissionCtx, { server: ["read"] }), + ]); + + await Promise.all([ + helpers.settings.isCloud.prefetch(), + helpers.user.get.prefetch(), + helpers.user.getPermissions.prefetch(), + helpers.project.homeStats.prefetch(), + canReadDeployments + ? helpers.deployment.homeSummary.prefetch() + : Promise.resolve(), + canReadServers ? helpers.server.all.prefetch() : Promise.resolve(), + ]); return { props: { diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index dcc74226a7..0fc087a086 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -1,4 +1,5 @@ import { + countDeploymentsCentralized, execAsync, execAsyncRemote, findAllDeploymentsByApplicationId, @@ -64,8 +65,16 @@ export const deploymentRouter = createTRPCRouter({ } return await findAllDeploymentsByServerId(input.serverId); }), - allCentralized: withPermission("deployment", "read").query( - async ({ ctx }) => { + allCentralized: withPermission("deployment", "read") + .input( + z + .object({ + limit: z.number().min(1).max(500).optional(), + status: z.enum(["running", "done", "error", "cancelled"]).optional(), + }) + .optional(), + ) + .query(async ({ ctx, input }) => { const orgId = ctx.session.activeOrganizationId; const accessedServices = ctx.user.role !== "owner" && ctx.user.role !== "admin" @@ -74,9 +83,59 @@ export const deploymentRouter = createTRPCRouter({ if (accessedServices !== null && accessedServices.length === 0) { return []; } - return findAllDeploymentsCentralized(orgId, accessedServices); - }, - ), + return findAllDeploymentsCentralized(orgId, accessedServices, { + limit: input?.limit, + status: input?.status, + }); + }), + + homeSummary: withPermission("deployment", "read").query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + const accessedServices = + ctx.user.role !== "owner" && ctx.user.role !== "admin" + ? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices + : null; + + const now = Date.now(); + const weekMs = 7 * 24 * 60 * 60 * 1000; + const last7dStart = new Date(now - weekMs); + const prev7dStart = new Date(now - 2 * weekMs); + + const [recent, failed, last7d, prev7d, failed7d, runningCount] = + await Promise.all([ + findAllDeploymentsCentralized(orgId, accessedServices, { limit: 10 }), + findAllDeploymentsCentralized(orgId, accessedServices, { + limit: 5, + status: "error", + since: last7dStart, + }), + countDeploymentsCentralized(orgId, accessedServices, { + since: last7dStart, + }), + countDeploymentsCentralized(orgId, accessedServices, { + since: prev7dStart, + until: last7dStart, + }), + countDeploymentsCentralized(orgId, accessedServices, { + status: "error", + since: last7dStart, + }), + countDeploymentsCentralized(orgId, accessedServices, { + status: "running", + }), + ]); + + return { + recent, + failed, + stats: { + last7d, + prev7d, + failed7d, + running: runningCount, + }, + }; + }), queueList: withPermission("deployment", "read").query(async ({ ctx }) => { const orgId = ctx.session.activeOrganizationId; diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index 2e35aee2a1..090177b914 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -494,6 +494,44 @@ export const projectRouter = createTRPCRouter({ let accessedEnvironments: string[] = []; let accessedServices: string[] = []; + const empty = { + projects: 0, + environments: 0, + applications: 0, + compose: 0, + databases: 0, + services: 0, + status: { running: 0, error: 0, idle: 0 }, + dokployHostServices: 0, + servicesByServerId: {} as Record, + recentProjects: [] as { + projectId: string; + name: string; + description: string | null; + createdAt: string; + environments: number; + services: number; + defaultEnvironmentId: string | null; + }[], + erroredServices: [] as { + id: string; + name: string; + type: + | "application" + | "compose" + | "libsql" + | "mariadb" + | "mongo" + | "mysql" + | "postgres" + | "redis"; + projectId: string; + projectName: string; + environmentId: string; + environmentName: string; + }[], + }; + if (!isPrivileged) { const member = await findMemberByUserId( ctx.user.id, @@ -504,15 +542,7 @@ export const projectRouter = createTRPCRouter({ accessedServices = member.accessedServices; if (accessedProjects.length === 0) { - return { - projects: 0, - environments: 0, - applications: 0, - compose: 0, - databases: 0, - services: 0, - status: { running: 0, error: 0, idle: 0 }, - }; + return empty; } } @@ -540,43 +570,89 @@ export const projectRouter = createTRPCRouter({ const rows = await db.query.projects.findMany({ where: projectIdFilter, - columns: { projectId: true }, + orderBy: desc(projects.createdAt), + columns: { + projectId: true, + name: true, + description: true, + createdAt: true, + }, with: { environments: { where: environmentFilter, - columns: { environmentId: true }, + columns: { environmentId: true, name: true }, with: { applications: { where: applyFilter(applications.applicationId), - columns: { applicationStatus: true }, + columns: { + applicationId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, compose: { where: applyFilter(compose.composeId), - columns: { composeStatus: true }, + columns: { + composeId: true, + name: true, + composeStatus: true, + serverId: true, + }, }, libsql: { where: applyFilter(libsql.libsqlId), - columns: { applicationStatus: true }, + columns: { + libsqlId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, mariadb: { where: applyFilter(mariadb.mariadbId), - columns: { applicationStatus: true }, + columns: { + mariadbId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, mongo: { where: applyFilter(mongo.mongoId), - columns: { applicationStatus: true }, + columns: { + mongoId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, mysql: { where: applyFilter(mysql.mysqlId), - columns: { applicationStatus: true }, + columns: { + mysqlId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, postgres: { where: applyFilter(postgres.postgresId), - columns: { applicationStatus: true }, + columns: { + postgresId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, redis: { where: applyFilter(redis.redisId), - columns: { applicationStatus: true }, + columns: { + redisId: true, + name: true, + applicationStatus: true, + serverId: true, + }, }, }, }, @@ -587,34 +663,191 @@ export const projectRouter = createTRPCRouter({ let composeCount = 0; let databasesCount = 0; let environmentsCount = 0; + let dokployHostServices = 0; + const servicesByServerId: Record = {}; const status = { running: 0, error: 0, idle: 0 }; + const erroredServices: (typeof empty)["erroredServices"] = []; + const bump = (s?: string | null) => { if (s === "done") status.running++; else if (s === "error") status.error++; else status.idle++; }; + const bumpServer = (serverId?: string | null) => { + if (!serverId) { + dokployHostServices++; + return; + } + servicesByServerId[serverId] = (servicesByServerId[serverId] ?? 0) + 1; + }; + + const pushError = ( + service: { + id: string; + name: string; + type: (typeof empty)["erroredServices"][number]["type"]; + status?: string | null; + }, + project: { projectId: string; name: string }, + env: { environmentId: string; name: string }, + ) => { + if (service.status !== "error") return; + if (erroredServices.length >= 8) return; + erroredServices.push({ + id: service.id, + name: service.name, + type: service.type, + projectId: project.projectId, + projectName: project.name, + environmentId: env.environmentId, + environmentName: env.name, + }); + }; + + const recentProjects: (typeof empty)["recentProjects"] = []; + for (const project of rows) { + let projectServices = 0; for (const env of project.environments) { environmentsCount++; applicationsCount += env.applications.length; composeCount += env.compose.length; - databasesCount += + const dbCount = env.libsql.length + env.mariadb.length + env.mongo.length + env.mysql.length + env.postgres.length + env.redis.length; + databasesCount += dbCount; + projectServices += + env.applications.length + env.compose.length + dbCount; + + for (const a of env.applications) { + bump(a.applicationStatus); + bumpServer(a.serverId); + pushError( + { + id: a.applicationId, + name: a.name, + type: "application", + status: a.applicationStatus, + }, + project, + env, + ); + } + for (const c of env.compose) { + bump(c.composeStatus); + bumpServer(c.serverId); + pushError( + { + id: c.composeId, + name: c.name, + type: "compose", + status: c.composeStatus, + }, + project, + env, + ); + } + for (const s of env.libsql) { + bump(s.applicationStatus); + bumpServer(s.serverId); + pushError( + { + id: s.libsqlId, + name: s.name, + type: "libsql", + status: s.applicationStatus, + }, + project, + env, + ); + } + for (const s of env.mariadb) { + bump(s.applicationStatus); + bumpServer(s.serverId); + pushError( + { + id: s.mariadbId, + name: s.name, + type: "mariadb", + status: s.applicationStatus, + }, + project, + env, + ); + } + for (const s of env.mongo) { + bump(s.applicationStatus); + bumpServer(s.serverId); + pushError( + { + id: s.mongoId, + name: s.name, + type: "mongo", + status: s.applicationStatus, + }, + project, + env, + ); + } + for (const s of env.mysql) { + bump(s.applicationStatus); + bumpServer(s.serverId); + pushError( + { + id: s.mysqlId, + name: s.name, + type: "mysql", + status: s.applicationStatus, + }, + project, + env, + ); + } + for (const s of env.postgres) { + bump(s.applicationStatus); + bumpServer(s.serverId); + pushError( + { + id: s.postgresId, + name: s.name, + type: "postgres", + status: s.applicationStatus, + }, + project, + env, + ); + } + for (const s of env.redis) { + bump(s.applicationStatus); + bumpServer(s.serverId); + pushError( + { + id: s.redisId, + name: s.name, + type: "redis", + status: s.applicationStatus, + }, + project, + env, + ); + } + } - for (const a of env.applications) bump(a.applicationStatus); - for (const c of env.compose) bump(c.composeStatus); - for (const s of env.libsql) bump(s.applicationStatus); - for (const s of env.mariadb) bump(s.applicationStatus); - for (const s of env.mongo) bump(s.applicationStatus); - for (const s of env.mysql) bump(s.applicationStatus); - for (const s of env.postgres) bump(s.applicationStatus); - for (const s of env.redis) bump(s.applicationStatus); + if (recentProjects.length < 6) { + recentProjects.push({ + projectId: project.projectId, + name: project.name, + description: project.description, + createdAt: project.createdAt, + environments: project.environments.length, + services: projectServices, + defaultEnvironmentId: project.environments[0]?.environmentId ?? null, + }); } } @@ -626,6 +859,10 @@ export const projectRouter = createTRPCRouter({ databases: databasesCount, services: applicationsCount + composeCount + databasesCount, status, + dokployHostServices, + servicesByServerId, + recentProjects, + erroredServices, }; }), diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index b78e6c2039..170e30bfe1 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -23,7 +23,17 @@ import { } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { format } from "date-fns"; -import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; +import { + and, + desc, + eq, + gte, + inArray, + lt, + or, + type SQL, + sql, +} from "drizzle-orm"; import type { z } from "zod"; import { type Application, @@ -890,16 +900,19 @@ async function getComposeIdsInOrg( 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 ( +export type CentralizedDeploymentOptions = { + limit?: number; + status?: "running" | "done" | "error" | "cancelled"; + since?: Date; + until?: Date; +}; + +async function getCentralizedDeploymentScope( orgId: string, accessedServices: string[] | null, -) => { +): Promise<{ whereClause: SQL; empty: boolean }> { if (accessedServices !== null && accessedServices.length === 0) { - return []; + return { whereClause: sql`1 = 0`, empty: true }; } const [appIds, compIds] = await Promise.all([ @@ -908,27 +921,87 @@ export const findAllDeploymentsCentralized = async ( ]); if (appIds.length === 0 && compIds.length === 0) { - return []; + return { whereClause: sql`1 = 0`, empty: true }; } - const conditions = [ + const conditions: SQL[] = [ ...(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); + conditions.length === 1 ? conditions[0]! : or(...conditions)!; + + return { whereClause, empty: false }; +} + +function withDeploymentFilters( + baseWhere: SQL, + options?: CentralizedDeploymentOptions, +): SQL { + const filters: SQL[] = [baseWhere]; + if (options?.status) { + filters.push(eq(deployments.status, options.status)); + } + if (options?.since) { + filters.push(gte(deployments.createdAt, options.since.toISOString())); + } + if (options?.until) { + filters.push(lt(deployments.createdAt, options.until.toISOString())); + } + return filters.length === 1 ? filters[0]! : and(...filters)!; +} + +/** + * Deployments for applications and compose in the org. + * Pass accessedServices for members (only those services), null for owner/admin. + * Optional limit/status/since/until avoid loading the full history on home. + */ +export const findAllDeploymentsCentralized = async ( + orgId: string, + accessedServices: string[] | null, + options?: CentralizedDeploymentOptions, +) => { + const { whereClause, empty } = await getCentralizedDeploymentScope( + orgId, + accessedServices, + ); + if (empty) { + return []; + } return db.query.deployments.findMany({ - where: whereClause, + where: withDeploymentFilters(whereClause, options), orderBy: desc(deployments.createdAt), + limit: options?.limit, with: centralizedDeploymentsWith, }); }; +/** + * Lightweight deployment counts for the home dashboard KPIs. + */ +export const countDeploymentsCentralized = async ( + orgId: string, + accessedServices: string[] | null, + options?: Omit, +) => { + const { whereClause, empty } = await getCentralizedDeploymentScope( + orgId, + accessedServices, + ); + if (empty) { + return 0; + } + + const [row] = await db + .select({ count: sql`cast(count(*) as integer)` }) + .from(deployments) + .where(withDeploymentFilters(whereClause, options)); + + return row?.count ?? 0; +}; + export const updateDeployment = async ( deploymentId: string, deploymentData: Partial, From 5213ade12cf11f21b7418cd14428b87e2176f9e7 Mon Sep 17 00:00:00 2001 From: Sagar Chhetri Date: Fri, 7 Aug 2026 14:17:42 +0545 Subject: [PATCH 2/3] fix: skip org-scoped home SSR prefetch without active org Avoid UNAUTHORIZED during /dashboard/home SSR when an authenticated user has no active organization by only prefetching permissions and dashboard data when a membership is selected. Co-authored-by: Cursor --- apps/dokploy/pages/dashboard/home.tsx | 48 +++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/pages/dashboard/home.tsx b/apps/dokploy/pages/dashboard/home.tsx index 756d90598b..744f865a7e 100644 --- a/apps/dokploy/pages/dashboard/home.tsx +++ b/apps/dokploy/pages/dashboard/home.tsx @@ -43,26 +43,40 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { transformer: superjson, }); - const permissionCtx = { - user: { id: user.id }, - session: { activeOrganizationId: session?.activeOrganizationId || "" }, - }; - - const [canReadDeployments, canReadServers] = await Promise.all([ - hasPermission(permissionCtx, { deployment: ["read"] }), - hasPermission(permissionCtx, { server: ["read"] }), - ]); + const activeOrganizationId = session?.activeOrganizationId; - await Promise.all([ + const prefetchTasks: Promise[] = [ helpers.settings.isCloud.prefetch(), helpers.user.get.prefetch(), - helpers.user.getPermissions.prefetch(), - helpers.project.homeStats.prefetch(), - canReadDeployments - ? helpers.deployment.homeSummary.prefetch() - : Promise.resolve(), - canReadServers ? helpers.server.all.prefetch() : Promise.resolve(), - ]); + ]; + + // Org-scoped queries require an active membership; skipping them avoids + // UNAUTHORIZED during SSR when the user has no organization selected. + if (activeOrganizationId) { + const permissionCtx = { + user: { id: user.id }, + session: { activeOrganizationId }, + }; + + const [canReadDeployments, canReadServers] = await Promise.all([ + hasPermission(permissionCtx, { deployment: ["read"] }), + hasPermission(permissionCtx, { server: ["read"] }), + ]); + + prefetchTasks.push( + helpers.user.getPermissions.prefetch(), + helpers.project.homeStats.prefetch(), + ); + + if (canReadDeployments) { + prefetchTasks.push(helpers.deployment.homeSummary.prefetch()); + } + if (canReadServers) { + prefetchTasks.push(helpers.server.all.prefetch()); + } + } + + await Promise.all(prefetchTasks); return { props: { From e4d9b64d855a4502d0ae7a7d71e159d77b05737b Mon Sep 17 00:00:00 2001 From: Sagar Chhetri Date: Fri, 7 Aug 2026 14:35:47 +0545 Subject: [PATCH 3/3] fix: gate home dashboard queries until an org is active Skip org-scoped client queries when organization.active is null and show an empty state instead of firing UNAUTHORIZED homeStats/permissions calls after hydration. Co-authored-by: Cursor --- .../components/dashboard/home/show-home.tsx | 51 +++++++++++++++---- apps/dokploy/pages/dashboard/home.tsx | 1 + 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/home/show-home.tsx b/apps/dokploy/components/dashboard/home/show-home.tsx index c322187e92..cbe238020c 100644 --- a/apps/dokploy/components/dashboard/home/show-home.tsx +++ b/apps/dokploy/components/dashboard/home/show-home.tsx @@ -280,12 +280,25 @@ function deployDelta(last7d: number, prev7d: number) { } export const ShowHome = () => { - const { data: auth, isLoading: authLoading } = api.user.get.useQuery(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: activeOrganization, isLoading: orgLoading } = + api.organization.active.useQuery(); + const hasOrg = !!activeOrganization; + + const { data: auth, isLoading: authLoading } = api.user.get.useQuery( + undefined, + { + enabled: hasOrg, + }, + ); const { data: homeStats, isLoading: statsLoading } = - api.project.homeStats.useQuery(); + api.project.homeStats.useQuery(undefined, { + enabled: hasOrg, + }); const { data: permissions, isLoading: permissionsLoading } = - api.user.getPermissions.useQuery(); - const { data: isCloud } = api.settings.isCloud.useQuery(); + api.user.getPermissions.useQuery(undefined, { + enabled: hasOrg, + }); const canReadDeployments = !!permissions?.deployment.read; const canReadServers = !!permissions?.server.read; @@ -295,32 +308,34 @@ export const ShowHome = () => { const { data: deploySummary, isLoading: deployLoading } = api.deployment.homeSummary.useQuery(undefined, { - enabled: canReadDeployments, + enabled: hasOrg && canReadDeployments, refetchInterval: 10000, }); const { data: servers, isLoading: serversLoading } = api.server.all.useQuery( undefined, { - enabled: canReadServers, + enabled: hasOrg && canReadServers, }, ); const { data: queue, isLoading: queueLoading } = api.deployment.queueList.useQuery(undefined, { - enabled: canReadDeployments, + enabled: hasOrg && canReadDeployments, refetchInterval: 10000, }); const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); const { data: infraHealth, isLoading: healthLoading } = api.settings.checkInfrastructureHealth.useQuery(undefined, { - enabled: !!isAdmin && isCloud === false, + enabled: hasOrg && !!isAdmin && isCloud === false, retry: false, }); const firstName = auth?.user?.firstName?.trim(); - const loadingStats = statsLoading || authLoading || permissionsLoading; + const loadingStats = + orgLoading || + (hasOrg && (statsLoading || authLoading || permissionsLoading)); const totals = homeStats ?? { projects: 0, @@ -362,6 +377,24 @@ export const ShowHome = () => { const showAttention = !loadingStats && attentionCount > 0; const showServerSummary = !isCloud || canReadServers; + if (!orgLoading && !hasOrg) { + return ( +
+ +
+ +

+ No organization selected +

+

+ Select or create an organization to view your dashboard overview. +

+
+
+
+ ); + } + return (
diff --git a/apps/dokploy/pages/dashboard/home.tsx b/apps/dokploy/pages/dashboard/home.tsx index 744f865a7e..0cce6f65e7 100644 --- a/apps/dokploy/pages/dashboard/home.tsx +++ b/apps/dokploy/pages/dashboard/home.tsx @@ -48,6 +48,7 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) { const prefetchTasks: Promise[] = [ helpers.settings.isCloud.prefetch(), helpers.user.get.prefetch(), + helpers.organization.active.prefetch(), ]; // Org-scoped queries require an active membership; skipping them avoids