From 0f100c7bc8749b930fd5ee287f922b058a73b8c9 Mon Sep 17 00:00:00 2001 From: Aathil Felix Date: Sat, 1 Nov 2025 18:03:40 +0530 Subject: [PATCH 01/11] feat: add server time clock --- .../components/dashboard/projects/show.tsx | 952 ++++----- apps/dokploy/components/layouts/side.tsx | 1868 ++++++++--------- apps/dokploy/components/ui/time-badge.tsx | 60 + apps/dokploy/server/api/routers/server.ts | 6 + 4 files changed, 1463 insertions(+), 1423 deletions(-) create mode 100644 apps/dokploy/components/ui/time-badge.tsx diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 783c5bb32..8531e6b97 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -1,13 +1,13 @@ import { - AlertTriangle, - ArrowUpDown, - BookIcon, - ExternalLinkIcon, - FolderInput, - Loader2, - MoreHorizontalIcon, - Search, - TrashIcon, + AlertTriangle, + ArrowUpDown, + BookIcon, + ExternalLinkIcon, + FolderInput, + Loader2, + MoreHorizontalIcon, + Search, + TrashIcon, } from "lucide-react"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; @@ -16,497 +16,497 @@ import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { api } from "@/utils/api"; import { HandleProject } from "./handle-project"; import { ProjectEnvironment } from "./project-environment"; +import { TimeBadge } from "@/components/ui/time-badge"; export const ShowProjects = () => { - const utils = api.useUtils(); - const { data, isLoading } = api.project.all.useQuery(); - const { data: auth } = api.user.get.useQuery(); - const { mutateAsync } = api.project.remove.useMutation(); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState(() => { - if (typeof window !== "undefined") { - return localStorage.getItem("projectsSort") || "createdAt-desc"; - } - return "createdAt-desc"; - }); + const utils = api.useUtils(); + const { data, isLoading } = api.project.all.useQuery(); + const { data: auth } = api.user.get.useQuery(); + const { mutateAsync } = api.project.remove.useMutation(); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState(() => { + if (typeof window !== "undefined") { + return localStorage.getItem("projectsSort") || "createdAt-desc"; + } + return "createdAt-desc"; + }); - useEffect(() => { - localStorage.setItem("projectsSort", sortBy); - }, [sortBy]); + useEffect(() => { + localStorage.setItem("projectsSort", sortBy); + }, [sortBy]); - const filteredProjects = useMemo(() => { - if (!data) return []; + const filteredProjects = useMemo(() => { + if (!data) return []; - // First filter by search query - const filtered = data.filter( - (project) => - project.name.toLowerCase().includes(searchQuery.toLowerCase()) || - project.description?.toLowerCase().includes(searchQuery.toLowerCase()), - ); + // First filter by search query + const filtered = data.filter( + (project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()) || + project.description?.toLowerCase().includes(searchQuery.toLowerCase()) + ); - // Then sort the filtered results - const [field, direction] = sortBy.split("-"); - return [...filtered].sort((a, b) => { - let comparison = 0; - switch (field) { - case "name": - comparison = a.name.localeCompare(b.name); - break; - case "createdAt": - comparison = - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - break; - case "services": { - const aTotalServices = a.environments.reduce((total, env) => { - return ( - total + - (env.applications?.length || 0) + - (env.mariadb?.length || 0) + - (env.mongo?.length || 0) + - (env.mysql?.length || 0) + - (env.postgres?.length || 0) + - (env.redis?.length || 0) + - (env.compose?.length || 0) - ); - }, 0); - const bTotalServices = b.environments.reduce((total, env) => { - return ( - total + - (env.applications?.length || 0) + - (env.mariadb?.length || 0) + - (env.mongo?.length || 0) + - (env.mysql?.length || 0) + - (env.postgres?.length || 0) + - (env.redis?.length || 0) + - (env.compose?.length || 0) - ); - }, 0); - comparison = aTotalServices - bTotalServices; - break; - } - default: - comparison = 0; - } - return direction === "asc" ? comparison : -comparison; - }); - }, [data, searchQuery, sortBy]); + // Then sort the filtered results + const [field, direction] = sortBy.split("-"); + return [...filtered].sort((a, b) => { + let comparison = 0; + switch (field) { + case "name": + comparison = a.name.localeCompare(b.name); + break; + case "createdAt": + comparison = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case "services": { + const aTotalServices = a.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + const bTotalServices = b.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + comparison = aTotalServices - bTotalServices; + break; + } + default: + comparison = 0; + } + return direction === "asc" ? comparison : -comparison; + }); + }, [data, searchQuery, sortBy]); - return ( - <> - -
- -
-
- - - - Projects - - - Create and manage your projects - - + return ( + <> + +
+ +
+
+ +
+
+ + + + Projects + + + Create and manage your projects + + + {(auth?.role === "owner" || auth?.canCreateProjects) && ( +
+ +
+ )} +
- {(auth?.role === "owner" || auth?.canCreateProjects) && ( -
- -
- )} -
+ + {isLoading ? ( +
+ Loading... + +
+ ) : ( + <> +
+
+ setSearchQuery(e.target.value)} + className="pr-10" + /> - - {isLoading ? ( -
- Loading... - -
- ) : ( - <> -
-
- setSearchQuery(e.target.value)} - className="pr-10" - /> + +
+
+ + +
+
+ {filteredProjects?.length === 0 && ( +
+ + + No projects found + +
+ )} +
+ {filteredProjects?.map((project) => { + const emptyServices = project?.environments + .map( + (env) => + env.applications.length === 0 && + env.mariadb.length === 0 && + env.mongo.length === 0 && + env.mysql.length === 0 && + env.postgres.length === 0 && + env.redis.length === 0 && + env.applications.length === 0 && + env.compose.length === 0 + ) + .every(Boolean); - -
-
- - -
-
- {filteredProjects?.length === 0 && ( -
- - - No projects found - -
- )} -
- {filteredProjects?.map((project) => { - const emptyServices = project?.environments - .map( - (env) => - env.applications.length === 0 && - env.mariadb.length === 0 && - env.mongo.length === 0 && - env.mysql.length === 0 && - env.postgres.length === 0 && - env.redis.length === 0 && - env.applications.length === 0 && - env.compose.length === 0, - ) - .every(Boolean); + const totalServices = project?.environments + .map( + (env) => + env.mariadb.length + + env.mongo.length + + env.mysql.length + + env.postgres.length + + env.redis.length + + env.applications.length + + env.compose.length + ) + .reduce((acc, curr) => acc + curr, 0); - const totalServices = project?.environments - .map( - (env) => - env.mariadb.length + - env.mongo.length + - env.mysql.length + - env.postgres.length + - env.redis.length + - env.applications.length + - env.compose.length, - ) - .reduce((acc, curr) => acc + curr, 0); + const haveServicesWithDomains = project?.environments + .map( + (env) => + env.applications.length > 0 || + env.compose.length > 0 + ) + .some(Boolean); - const haveServicesWithDomains = project?.environments - .map( - (env) => - env.applications.length > 0 || - env.compose.length > 0, - ) - .some(Boolean); + return ( +
+ + + {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} + + + +
+ + + {project.name} + +
- return ( -
- - - {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} - - - -
- - - {project.name} - -
+ + {project.description} + +
+
+ + + + + e.stopPropagation()}> + + Actions + +
e.stopPropagation()}> + +
+
e.stopPropagation()}> + +
- - {project.description} - - -
- - - - - e.stopPropagation()} - > - - Actions - -
e.stopPropagation()} - > - -
-
e.stopPropagation()} - > - -
- -
e.stopPropagation()} - > - {(auth?.role === "owner" || - auth?.canDeleteProjects) && ( - - - - e.preventDefault() - } - > - - Delete - - - - - - Are you sure to delete this - project? - - {!emptyServices ? ( -
- - - You have active - services, please delete - them first - -
- ) : ( - - This action cannot be - undone - - )} -
- - - Cancel - - { - await mutateAsync({ - projectId: - project.projectId, - }) - .then(() => { - toast.success( - "Project deleted successfully", - ); - }) - .catch(() => { - toast.error( - "Error deleting this project", - ); - }) - .finally(() => { - utils.project.all.invalidate(); - }); - }} - > - Delete - - -
-
- )} -
-
-
-
- - - -
- - Created - - - {totalServices}{" "} - {totalServices === 1 - ? "service" - : "services"} - -
-
- - -
- ); - })} -
- - )} - -
- -
- - ); +
e.stopPropagation()}> + {(auth?.role === "owner" || + auth?.canDeleteProjects) && ( + + + + e.preventDefault() + }> + + Delete + + + + + + Are you sure to delete this + project? + + {!emptyServices ? ( +
+ + + You have active + services, please delete + them first + +
+ ) : ( + + This action cannot be + undone + + )} +
+ + + Cancel + + { + await mutateAsync({ + projectId: + project.projectId, + }) + .then(() => { + toast.success( + "Project deleted successfully" + ); + }) + .catch(() => { + toast.error( + "Error deleting this project" + ); + }) + .finally(() => { + utils.project.all.invalidate(); + }); + }}> + Delete + + +
+
+ )} +
+ + +
+ + + +
+ + Created + + + {totalServices}{" "} + {totalServices === 1 + ? "service" + : "services"} + +
+
+
+ +
+ ); + })} +
+ + )} + +
+
+
+ + ); }; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index d1d4ae273..9833766f0 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -1,78 +1,78 @@ "use client"; import type { inferRouterOutputs } from "@trpc/server"; import { - Activity, - BarChartHorizontalBigIcon, - Bell, - BlocksIcon, - BookIcon, - BotIcon, - Boxes, - ChevronRight, - ChevronsUpDown, - CircleHelp, - Clock, - CreditCard, - Database, - Folder, - Forward, - GalleryVerticalEnd, - GitBranch, - HeartIcon, - KeyRound, - Loader2, - type LucideIcon, - Package, - PieChart, - Server, - ShieldCheck, - Trash2, - User, - Users, + Activity, + BarChartHorizontalBigIcon, + Bell, + BlocksIcon, + BookIcon, + BotIcon, + Boxes, + ChevronRight, + ChevronsUpDown, + CircleHelp, + Clock, + CreditCard, + Database, + Folder, + Forward, + GalleryVerticalEnd, + GitBranch, + HeartIcon, + KeyRound, + Loader2, + type LucideIcon, + Package, + PieChart, + Server, + ShieldCheck, + Trash2, + User, + Users, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, } from "@/components/ui/breadcrumb"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, } from "@/components/ui/collapsible"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { - SIDEBAR_COOKIE_NAME, - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarInset, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarTrigger, - useSidebar, + SIDEBAR_COOKIE_NAME, + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarTrigger, + useSidebar, } from "@/components/ui/sidebar"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; @@ -82,6 +82,7 @@ import { AddOrganization } from "../dashboard/organization/handle-organization"; import { DialogAction } from "../shared/dialog-action"; import { Logo } from "../shared/logo"; import { Button } from "../ui/button"; +import { TimeBadge } from "../ui/time-badge"; import { UpdateServerButton } from "./update-server"; import { UserNav } from "./user-nav"; @@ -89,11 +90,11 @@ import { UserNav } from "./user-nav"; type AuthQueryOutput = inferRouterOutputs["user"]["get"]; type SingleNavItem = { - isSingle?: true; - title: string; - url: string; - icon?: LucideIcon; - isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; + isSingle?: true; + title: string; + url: string; + icon?: LucideIcon; + isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; }; // NavItem type @@ -101,33 +102,33 @@ type SingleNavItem = { // If `isSingle` is true or undefined, the item is a single item // If `isSingle` is false, the item is a group of items type NavItem = - | SingleNavItem - | { - isSingle: false; - title: string; - icon: LucideIcon; - items: SingleNavItem[]; - isEnabled?: (opts: { - auth?: AuthQueryOutput; - isCloud: boolean; - }) => boolean; - }; + | SingleNavItem + | { + isSingle: false; + title: string; + icon: LucideIcon; + items: SingleNavItem[]; + isEnabled?: (opts: { + auth?: AuthQueryOutput; + isCloud: boolean; + }) => boolean; + }; // ExternalLink type // Represents an external link item (used for the help section) type ExternalLink = { - name: string; - url: string; - icon: React.ComponentType<{ className?: string }>; - isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; + name: string; + url: string; + icon: React.ComponentType<{ className?: string }>; + isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; }; // Menu type // Consists of home, settings, and help items type Menu = { - home: NavItem[]; - settings: NavItem[]; - help: ExternalLink[]; + home: NavItem[]; + settings: NavItem[]; + help: ExternalLink[]; }; // Menu items @@ -135,257 +136,257 @@ type Menu = { // The items are filtered based on the user's role and permissions // The `isEnabled` function is called to determine if the item should be displayed const MENU: Menu = { - home: [ - { - isSingle: true, - title: "Projects", - url: "/dashboard/projects", - icon: Folder, - }, - { - isSingle: true, - title: "Monitoring", - url: "/dashboard/monitoring", - icon: BarChartHorizontalBigIcon, - // Only enabled in non-cloud environments - isEnabled: ({ isCloud }) => !isCloud, - }, - { - isSingle: true, - title: "Schedules", - url: "/dashboard/schedules", - icon: Clock, - // Only enabled in non-cloud environments - isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner", - }, - { - isSingle: true, - title: "Traefik File System", - url: "/dashboard/traefik", - icon: GalleryVerticalEnd, - // Only enabled for admins and users with access to Traefik files in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!( - (auth?.role === "owner" || auth?.canAccessToTraefikFiles) && - !isCloud - ), - }, - { - isSingle: true, - title: "Docker", - url: "/dashboard/docker", - icon: BlocksIcon, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), - }, - { - isSingle: true, - title: "Swarm", - url: "/dashboard/swarm", - icon: PieChart, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), - }, - { - isSingle: true, - title: "Requests", - url: "/dashboard/requests", - icon: Forward, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), - }, + home: [ + { + isSingle: true, + title: "Projects", + url: "/dashboard/projects", + icon: Folder, + }, + { + isSingle: true, + title: "Monitoring", + url: "/dashboard/monitoring", + icon: BarChartHorizontalBigIcon, + // Only enabled in non-cloud environments + isEnabled: ({ isCloud }) => !isCloud, + }, + { + isSingle: true, + title: "Schedules", + url: "/dashboard/schedules", + icon: Clock, + // Only enabled in non-cloud environments + isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner", + }, + { + isSingle: true, + title: "Traefik File System", + url: "/dashboard/traefik", + icon: GalleryVerticalEnd, + // Only enabled for admins and users with access to Traefik files in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!( + (auth?.role === "owner" || auth?.canAccessToTraefikFiles) && + !isCloud + ), + }, + { + isSingle: true, + title: "Docker", + url: "/dashboard/docker", + icon: BlocksIcon, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + }, + { + isSingle: true, + title: "Swarm", + url: "/dashboard/swarm", + icon: PieChart, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + }, + { + isSingle: true, + title: "Requests", + url: "/dashboard/requests", + icon: Forward, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + }, - // Legacy unused menu, adjusted to the new structure - // { - // isSingle: true, - // title: "Projects", - // url: "/dashboard/projects", - // icon: Folder, - // }, - // { - // isSingle: true, - // title: "Monitoring", - // icon: BarChartHorizontalBigIcon, - // url: "/dashboard/settings/monitoring", - // }, - // { - // isSingle: false, - // title: "Settings", - // icon: Settings2, - // items: [ - // { - // title: "Profile", - // url: "/dashboard/settings/profile", - // }, - // { - // title: "Users", - // url: "/dashboard/settings/users", - // }, - // { - // title: "SSH Key", - // url: "/dashboard/settings/ssh-keys", - // }, - // { - // title: "Git", - // url: "/dashboard/settings/git-providers", - // }, - // ], - // }, - // { - // isSingle: false, - // title: "Integrations", - // icon: BlocksIcon, - // items: [ - // { - // title: "S3 Destinations", - // url: "/dashboard/settings/destinations", - // }, - // { - // title: "Registry", - // url: "/dashboard/settings/registry", - // }, - // { - // title: "Notifications", - // url: "/dashboard/settings/notifications", - // }, - // ], - // }, - ], + // Legacy unused menu, adjusted to the new structure + // { + // isSingle: true, + // title: "Projects", + // url: "/dashboard/projects", + // icon: Folder, + // }, + // { + // isSingle: true, + // title: "Monitoring", + // icon: BarChartHorizontalBigIcon, + // url: "/dashboard/settings/monitoring", + // }, + // { + // isSingle: false, + // title: "Settings", + // icon: Settings2, + // items: [ + // { + // title: "Profile", + // url: "/dashboard/settings/profile", + // }, + // { + // title: "Users", + // url: "/dashboard/settings/users", + // }, + // { + // title: "SSH Key", + // url: "/dashboard/settings/ssh-keys", + // }, + // { + // title: "Git", + // url: "/dashboard/settings/git-providers", + // }, + // ], + // }, + // { + // isSingle: false, + // title: "Integrations", + // icon: BlocksIcon, + // items: [ + // { + // title: "S3 Destinations", + // url: "/dashboard/settings/destinations", + // }, + // { + // title: "Registry", + // url: "/dashboard/settings/registry", + // }, + // { + // title: "Notifications", + // url: "/dashboard/settings/notifications", + // }, + // ], + // }, + ], - settings: [ - { - isSingle: true, - title: "Web Server", - url: "/dashboard/settings/server", - icon: Activity, - // Only enabled for admins in non-cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), - }, - { - isSingle: true, - title: "Profile", - url: "/dashboard/settings/profile", - icon: User, - }, - { - isSingle: true, - title: "Remote Servers", - url: "/dashboard/settings/servers", - icon: Server, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Users", - icon: Users, - url: "/dashboard/settings/users", - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "SSH Keys", - icon: KeyRound, - url: "/dashboard/settings/ssh-keys", - // Only enabled for admins and users with access to SSH keys - isEnabled: ({ auth }) => - !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), - }, - { - title: "AI", - icon: BotIcon, - url: "/dashboard/settings/ai", - isSingle: true, - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Git", - url: "/dashboard/settings/git-providers", - icon: GitBranch, - // Only enabled for admins and users with access to Git providers - isEnabled: ({ auth }) => - !!(auth?.role === "owner" || auth?.canAccessToGitProviders), - }, - { - isSingle: true, - title: "Registry", - url: "/dashboard/settings/registry", - icon: Package, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "S3 Destinations", - url: "/dashboard/settings/destinations", - icon: Database, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, + settings: [ + { + isSingle: true, + title: "Web Server", + url: "/dashboard/settings/server", + icon: Activity, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + }, + { + isSingle: true, + title: "Profile", + url: "/dashboard/settings/profile", + icon: User, + }, + { + isSingle: true, + title: "Remote Servers", + url: "/dashboard/settings/servers", + icon: Server, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Users", + icon: Users, + url: "/dashboard/settings/users", + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "SSH Keys", + icon: KeyRound, + url: "/dashboard/settings/ssh-keys", + // Only enabled for admins and users with access to SSH keys + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), + }, + { + title: "AI", + icon: BotIcon, + url: "/dashboard/settings/ai", + isSingle: true, + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Git", + url: "/dashboard/settings/git-providers", + icon: GitBranch, + // Only enabled for admins and users with access to Git providers + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.canAccessToGitProviders), + }, + { + isSingle: true, + title: "Registry", + url: "/dashboard/settings/registry", + icon: Package, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "S3 Destinations", + url: "/dashboard/settings/destinations", + icon: Database, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, - { - isSingle: true, - title: "Certificates", - url: "/dashboard/settings/certificates", - icon: ShieldCheck, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Cluster", - url: "/dashboard/settings/cluster", - icon: Boxes, - // Only enabled for admins in non-cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), - }, - { - isSingle: true, - title: "Notifications", - url: "/dashboard/settings/notifications", - icon: Bell, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Billing", - url: "/dashboard/settings/billing", - icon: CreditCard, - // Only enabled for admins in cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), - }, - ], + { + isSingle: true, + title: "Certificates", + url: "/dashboard/settings/certificates", + icon: ShieldCheck, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Cluster", + url: "/dashboard/settings/cluster", + icon: Boxes, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + }, + { + isSingle: true, + title: "Notifications", + url: "/dashboard/settings/notifications", + icon: Bell, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Billing", + url: "/dashboard/settings/billing", + icon: CreditCard, + // Only enabled for admins in cloud environments + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), + }, + ], - help: [ - { - name: "Documentation", - url: "https://docs.dokploy.com/docs/core", - icon: BookIcon, - }, - { - name: "Support", - url: "https://discord.gg/2tBnJ3jDJc", - icon: CircleHelp, - }, - { - name: "Sponsor", - url: "https://opencollective.com/dokploy", - icon: ({ className }) => ( - - ), - }, - ], + help: [ + { + name: "Documentation", + url: "https://docs.dokploy.com/docs/core", + icon: BookIcon, + }, + { + name: "Support", + url: "https://discord.gg/2tBnJ3jDJc", + icon: CircleHelp, + }, + { + name: "Sponsor", + url: "https://opencollective.com/dokploy", + icon: ({ className }) => ( + + ), + }, + ], } as const; /** @@ -393,41 +394,41 @@ const MENU: Menu = { * @returns a menu object with the home, settings, and help items */ function createMenuForAuthUser(opts: { - auth?: AuthQueryOutput; - isCloud: boolean; + auth?: AuthQueryOutput; + isCloud: boolean; }): Menu { - return { - // Filter the home items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - home: MENU.home.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }), - ), - // Filter the settings items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - settings: MENU.settings.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }), - ), - // Filter the help items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - help: MENU.help.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }), - ), - }; + return { + // Filter the home items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + home: MENU.home.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + isCloud: opts.isCloud, + }) + ), + // Filter the settings items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + settings: MENU.settings.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + isCloud: opts.isCloud, + }) + ), + // Filter the help items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + help: MENU.help.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + isCloud: opts.isCloud, + }) + ), + }; } /** @@ -435,24 +436,24 @@ function createMenuForAuthUser(opts: { * @returns true if the item url is active, false otherwise */ function isActiveRoute(opts: { - /** The url of the item. Usually obtained from `item.url` */ - itemUrl: string; - /** The current pathname. Usually obtained from `usePathname()` */ - pathname: string; + /** The url of the item. Usually obtained from `item.url` */ + itemUrl: string; + /** The current pathname. Usually obtained from `usePathname()` */ + pathname: string; }): boolean { - const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project"); - const normalizedPathname = opts.pathname?.replace("/projects", "/project"); + const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project"); + const normalizedPathname = opts.pathname?.replace("/projects", "/project"); - if (!normalizedPathname) return false; + if (!normalizedPathname) return false; - if (normalizedPathname === normalizedItemUrl) return true; + if (normalizedPathname === normalizedItemUrl) return true; - if (normalizedPathname.startsWith(normalizedItemUrl)) { - const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); - return nextChar === "/"; - } + if (normalizedPathname.startsWith(normalizedItemUrl)) { + const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); + return nextChar === "/"; + } - return false; + return false; } /** @@ -460,614 +461,587 @@ function isActiveRoute(opts: { * @returns the active nav item with `SingleNavItem` type or undefined if none is active */ function findActiveNavItem( - navItems: NavItem[], - pathname: string, + navItems: NavItem[], + pathname: string ): SingleNavItem | undefined { - const found = navItems.find((item) => - item.isSingle !== false - ? // The current item is single, so check if the item url is active - isActiveRoute({ itemUrl: item.url, pathname }) - : // The current item is not single, so check if any of the sub items are active - item.items.some((item) => - isActiveRoute({ itemUrl: item.url, pathname }), - ), - ); + const found = navItems.find((item) => + item.isSingle !== false + ? // The current item is single, so check if the item url is active + isActiveRoute({ itemUrl: item.url, pathname }) + : // The current item is not single, so check if any of the sub items are active + item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }) + ) + ); - if (found?.isSingle !== false) { - // The found item is single, so return it - return found; - } + if (found?.isSingle !== false) { + // The found item is single, so return it + return found; + } - // The found item is not single, so find the active sub item - return found?.items.find((item) => - isActiveRoute({ itemUrl: item.url, pathname }), - ); + // The found item is not single, so find the active sub item + return found?.items.find((item) => + isActiveRoute({ itemUrl: item.url, pathname }) + ); } interface Props { - children: React.ReactNode; + children: React.ReactNode; } function LogoWrapper() { - return ; + return ; } function SidebarLogo() { - const { state } = useSidebar(); - const { data: isCloud } = api.settings.isCloud.useQuery(); - const { data: user } = api.user.get.useQuery(); - const { data: session } = authClient.useSession(); + const { state } = useSidebar(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: user } = api.user.get.useQuery(); + const { data: session } = authClient.useSession(); - const { - data: organizations, - refetch, - isLoading, - } = api.organization.all.useQuery(); - const { mutateAsync: deleteOrganization, isLoading: isRemoving } = - api.organization.delete.useMutation(); - const { isMobile } = useSidebar(); - const { data: activeOrganization } = authClient.useActiveOrganization(); - const _utils = api.useUtils(); + const { + data: organizations, + refetch, + isLoading, + } = api.organization.all.useQuery(); + const { mutateAsync: deleteOrganization, isLoading: isRemoving } = + api.organization.delete.useMutation(); + const { isMobile } = useSidebar(); + const { data: activeOrganization } = authClient.useActiveOrganization(); + const _utils = api.useUtils(); - const { data: invitations, refetch: refetchInvitations } = - api.user.getInvitations.useQuery(); + const { data: invitations, refetch: refetchInvitations } = + api.user.getInvitations.useQuery(); - const [_activeTeam, setActiveTeam] = useState< - typeof activeOrganization | null - >(null); + const [_activeTeam, setActiveTeam] = useState< + typeof activeOrganization | null + >(null); - useEffect(() => { - if (activeOrganization) { - setActiveTeam(activeOrganization); - } - }, [activeOrganization]); + useEffect(() => { + if (activeOrganization) { + setActiveTeam(activeOrganization); + } + }, [activeOrganization]); - return ( - <> - {isLoading ? ( -
- -
- ) : ( - - {/* Organization Logo and Selector */} - - - - -
-
- -
-
-

- {activeOrganization?.name ?? "Select Organization"} -

-
-
- -
-
- - - Organizations - - {organizations?.map((org) => ( -
- { - await authClient.organization.setActive({ - organizationId: org.id, - }); - window.location.reload(); - }} - className="w-full gap-2 p-2" - > -
{org.name}
-
- -
-
- {org.ownerId === session?.user?.id && ( -
- - { - await deleteOrganization({ - organizationId: org.id, - }) - .then(() => { - refetch(); - toast.success( - "Organization deleted successfully", - ); - }) - .catch((error) => { - toast.error( - error?.message || - "Error deleting organization", - ); - }); - }} - > - - -
- )} -
- ))} - {(user?.role === "owner" || isCloud) && ( - <> - - - - )} -
-
-
+ return ( + <> + {isLoading ? ( +
+ +
+ ) : ( + + {/* Organization Logo and Selector */} + + + + +
+
+ +
+
+

+ {activeOrganization?.name ?? "Select Organization"} +

+
+
+ +
+
+ + + Organizations + + {organizations?.map((org) => ( +
+ { + await authClient.organization.setActive({ + organizationId: org.id, + }); + window.location.reload(); + }} + className="w-full gap-2 p-2"> +
{org.name}
+
+ +
+
+ {org.ownerId === session?.user?.id && ( +
+ + { + await deleteOrganization({ + organizationId: org.id, + }) + .then(() => { + refetch(); + toast.success( + "Organization deleted successfully" + ); + }) + .catch((error) => { + toast.error( + error?.message || + "Error deleting organization" + ); + }); + }}> + + +
+ )} +
+ ))} + {(user?.role === "owner" || isCloud) && ( + <> + + + + )} +
+
+
- {/* Notification Bell */} - - - - - - - Pending Invitations -
- {invitations && invitations.length > 0 ? ( - invitations.map((invitation) => ( -
- e.preventDefault()} - > -
- {invitation?.organization?.name} -
-
- Expires:{" "} - {new Date(invitation.expiresAt).toLocaleString()} -
-
- Role: {invitation.role} -
-
- { - const { error } = - await authClient.organization.acceptInvitation({ - invitationId: invitation.id, - }); + {/* Notification Bell */} + + + + + + + Pending Invitations +
+ {invitations && invitations.length > 0 ? ( + invitations.map((invitation) => ( +
+ e.preventDefault()}> +
+ {invitation?.organization?.name} +
+
+ Expires:{" "} + {new Date(invitation.expiresAt).toLocaleString()} +
+
+ Role: {invitation.role} +
+
+ { + const { error } = + await authClient.organization.acceptInvitation({ + invitationId: invitation.id, + }); - if (error) { - toast.error( - error.message || "Error accepting invitation", - ); - } else { - toast.success("Invitation accepted successfully"); - await refetchInvitations(); - await refetch(); - } - }} - > - - -
- )) - ) : ( - - No pending invitations - - )} -
-
-
-
- - )} - - ); + if (error) { + toast.error( + error.message || "Error accepting invitation" + ); + } else { + toast.success("Invitation accepted successfully"); + await refetchInvitations(); + await refetch(); + } + }}> + +
+
+ )) + ) : ( + + No pending invitations + + )} +
+
+
+
+
+ )} + + ); } export default function Page({ children }: Props) { - const [defaultOpen, setDefaultOpen] = useState( - undefined, - ); - const [isLoaded, setIsLoaded] = useState(false); + const [defaultOpen, setDefaultOpen] = useState( + undefined + ); + const [isLoaded, setIsLoaded] = useState(false); - useEffect(() => { - const cookieValue = document.cookie - .split("; ") - .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`)) - ?.split("=")[1]; + useEffect(() => { + const cookieValue = document.cookie + .split("; ") + .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`)) + ?.split("=")[1]; - setDefaultOpen(cookieValue === undefined ? true : cookieValue === "true"); - setIsLoaded(true); - }, []); + setDefaultOpen(cookieValue === undefined ? true : cookieValue === "true"); + setIsLoaded(true); + }, []); - const pathname = usePathname(); - const { data: auth } = api.user.get.useQuery(); - const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); + const pathname = usePathname(); + const { data: auth } = api.user.get.useQuery(); + const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); - const includesProjects = pathname?.includes("/dashboard/project"); - const { data: isCloud } = api.settings.isCloud.useQuery(); + const includesProjects = pathname?.includes("/dashboard/project"); + const { data: isCloud } = api.settings.isCloud.useQuery(); - const { - home: filteredHome, - settings: filteredSettings, - help, - } = createMenuForAuthUser({ auth, isCloud: !!isCloud }); + const { + home: filteredHome, + settings: filteredSettings, + help, + } = createMenuForAuthUser({ auth, isCloud: !!isCloud }); - const activeItem = findActiveNavItem( - [...filteredHome, ...filteredSettings], - pathname, - ); + const activeItem = findActiveNavItem( + [...filteredHome, ...filteredSettings], + pathname + ); - if (!isLoaded) { - return
; // Placeholder mientras se carga - } + if (!isLoaded) { + return
; // Placeholder mientras se carga + } - return ( - { - setDefaultOpen(open); + return ( + { + setDefaultOpen(open); - document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}`; - }} - style={ - { - "--sidebar-width": "19.5rem", - "--sidebar-width-mobile": "19.5rem", - } as React.CSSProperties - } - > - - - {/* + + + {/* */} - - {/* */} - - - - Home - - {filteredHome.map((item) => { - const isSingle = item.isSingle !== false; - const isActive = isSingle - ? isActiveRoute({ itemUrl: item.url, pathname }) - : item.items.some((item) => - isActiveRoute({ itemUrl: item.url, pathname }), - ); + + {/* */} + + + + Home + + {filteredHome.map((item) => { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }) + ); - return ( - - - {isSingle ? ( - - - {item.icon && ( - - )} - {item.title} - - - ) : ( - <> - - - {item.icon && } + return ( + + + {isSingle ? ( + + + {item.icon && ( + + )} + {item.title} + + + ) : ( + <> + + + {item.icon && } - {item.title} - {item.items?.length && ( - - )} - - - - - {item.items?.map((subItem) => ( - - - - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ); - })} - - - - Settings - - {filteredSettings.map((item) => { - const isSingle = item.isSingle !== false; - const isActive = isSingle - ? isActiveRoute({ itemUrl: item.url, pathname }) - : item.items.some((item) => - isActiveRoute({ itemUrl: item.url, pathname }), - ); + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} + + + + Settings + + {filteredSettings.map((item) => { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }) + ); - return ( - - - {isSingle ? ( - - - {item.icon && ( - - )} - {item.title} - - - ) : ( - <> - - - {item.icon && } + return ( + + + {isSingle ? ( + + + {item.icon && ( + + )} + {item.title} + + + ) : ( + <> + + + {item.icon && } - {item.title} - {item.items?.length && ( - - )} - - - - - {item.items?.map((subItem) => ( - - - - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ); - })} - - - - Extra - - {help.map((item: ExternalLink) => ( - - - - - - - {item.name} - - - - ))} - - - - - - {!isCloud && auth?.role === "owner" && ( - - - - )} - - - - {dokployVersion && ( - <> -
- Version {dokployVersion} -
-
- {dokployVersion} -
- - )} -
-
- -
- - {!includesProjects && ( -
-
-
- - - - - - - - {activeItem?.title} - - - - - -
-
-
- )} + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} + + + + Extra + + {help.map((item: ExternalLink) => ( + + + + + + + {item.name} + + + + ))} + + + + + + {!isCloud && auth?.role === "owner" && ( + + + + )} + + + + {dokployVersion && ( + <> +
+ Version {dokployVersion} +
+
+ {dokployVersion} +
+ + )} +
+
+ + + + {!includesProjects && ( +
+
+
+ + + + + + + + {activeItem?.title} + + + + + +
+ +
+
+ )} -
{children}
-
-
- ); +
{children}
+ +
+ ); } diff --git a/apps/dokploy/components/ui/time-badge.tsx b/apps/dokploy/components/ui/time-badge.tsx new file mode 100644 index 000000000..8d47297ea --- /dev/null +++ b/apps/dokploy/components/ui/time-badge.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { api } from "@/utils/api"; + +export function TimeBadge() { + const { data: serverTime } = api.server.getServerTime.useQuery(undefined, { + refetchInterval: 60000, // Refetch every 60 seconds + }); + const [time, setTime] = useState(null); + + useEffect(() => { + if (serverTime?.time) { + setTime(new Date(serverTime.time)); + } + }, [serverTime]); + + useEffect(() => { + const timer = setInterval(() => { + setTime((prevTime) => { + if (!prevTime) return null; + const newTime = new Date(prevTime.getTime() + 1000); + return newTime; + }); + }, 1000); + + return () => { + clearInterval(timer); + }; + }, []); + + if (!time || !serverTime?.timezone) { + return null; + } + + const getUtcOffset = (timeZone: string) => { + const date = new Date(); + const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" })); + const tzDate = new Date(date.toLocaleString("en-US", { timeZone })); + const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60); + const sign = offset >= 0 ? "+" : "-"; + const hours = Math.floor(Math.abs(offset)); + const minutes = (Math.abs(offset) * 60) % 60; + return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}`; + }; + + return ( +
+ Server Time: + + {time.toLocaleTimeString()} + + + ({serverTime.timezone} | {getUtcOffset(serverTime.timezone)}) + +
+ ); +} diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts index d6904a7ec..4eb75bdf0 100644 --- a/apps/dokploy/server/api/routers/server.ts +++ b/apps/dokploy/server/api/routers/server.ts @@ -383,6 +383,12 @@ export const serverRouter = createTRPCRouter({ const ip = await getPublicIpWithFallback(); return ip; }), + getServerTime: protectedProcedure.query(() => { + return { + time: new Date(), + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + }; + }), getServerMetrics: protectedProcedure .input( z.object({ From 53b66e41e2d2b37f676dfe345f92dec05fcd453f Mon Sep 17 00:00:00 2001 From: Aathil Felix Date: Sat, 1 Nov 2025 19:09:58 +0530 Subject: [PATCH 02/11] chore(ui): apply Biome format to time badge and headers --- .../components/dashboard/projects/show.tsx | 965 ++++----- apps/dokploy/components/layouts/side.tsx | 1868 +++++++++-------- apps/dokploy/components/ui/time-badge.tsx | 94 +- 3 files changed, 1485 insertions(+), 1442 deletions(-) diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 8531e6b97..92f7ed5cb 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -1,13 +1,13 @@ import { - AlertTriangle, - ArrowUpDown, - BookIcon, - ExternalLinkIcon, - FolderInput, - Loader2, - MoreHorizontalIcon, - Search, - TrashIcon, + AlertTriangle, + ArrowUpDown, + BookIcon, + ExternalLinkIcon, + FolderInput, + Loader2, + MoreHorizontalIcon, + Search, + TrashIcon, } from "lucide-react"; import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; @@ -16,41 +16,41 @@ import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { StatusTooltip } from "@/components/shared/status-tooltip"; import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, } from "@/components/ui/card"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, } from "@/components/ui/select"; import { api } from "@/utils/api"; import { HandleProject } from "./handle-project"; @@ -58,455 +58,470 @@ import { ProjectEnvironment } from "./project-environment"; import { TimeBadge } from "@/components/ui/time-badge"; export const ShowProjects = () => { - const utils = api.useUtils(); - const { data, isLoading } = api.project.all.useQuery(); - const { data: auth } = api.user.get.useQuery(); - const { mutateAsync } = api.project.remove.useMutation(); - const [searchQuery, setSearchQuery] = useState(""); - const [sortBy, setSortBy] = useState(() => { - if (typeof window !== "undefined") { - return localStorage.getItem("projectsSort") || "createdAt-desc"; - } - return "createdAt-desc"; - }); + const utils = api.useUtils(); + const { data, isLoading } = api.project.all.useQuery(); + const { data: auth } = api.user.get.useQuery(); + const { mutateAsync } = api.project.remove.useMutation(); + const [searchQuery, setSearchQuery] = useState(""); + const [sortBy, setSortBy] = useState(() => { + if (typeof window !== "undefined") { + return localStorage.getItem("projectsSort") || "createdAt-desc"; + } + return "createdAt-desc"; + }); - useEffect(() => { - localStorage.setItem("projectsSort", sortBy); - }, [sortBy]); + useEffect(() => { + localStorage.setItem("projectsSort", sortBy); + }, [sortBy]); - const filteredProjects = useMemo(() => { - if (!data) return []; + const filteredProjects = useMemo(() => { + if (!data) return []; - // First filter by search query - const filtered = data.filter( - (project) => - project.name.toLowerCase().includes(searchQuery.toLowerCase()) || - project.description?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + // First filter by search query + const filtered = data.filter( + (project) => + project.name.toLowerCase().includes(searchQuery.toLowerCase()) || + project.description?.toLowerCase().includes(searchQuery.toLowerCase()), + ); - // Then sort the filtered results - const [field, direction] = sortBy.split("-"); - return [...filtered].sort((a, b) => { - let comparison = 0; - switch (field) { - case "name": - comparison = a.name.localeCompare(b.name); - break; - case "createdAt": - comparison = - new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - break; - case "services": { - const aTotalServices = a.environments.reduce((total, env) => { - return ( - total + - (env.applications?.length || 0) + - (env.mariadb?.length || 0) + - (env.mongo?.length || 0) + - (env.mysql?.length || 0) + - (env.postgres?.length || 0) + - (env.redis?.length || 0) + - (env.compose?.length || 0) - ); - }, 0); - const bTotalServices = b.environments.reduce((total, env) => { - return ( - total + - (env.applications?.length || 0) + - (env.mariadb?.length || 0) + - (env.mongo?.length || 0) + - (env.mysql?.length || 0) + - (env.postgres?.length || 0) + - (env.redis?.length || 0) + - (env.compose?.length || 0) - ); - }, 0); - comparison = aTotalServices - bTotalServices; - break; - } - default: - comparison = 0; - } - return direction === "asc" ? comparison : -comparison; - }); - }, [data, searchQuery, sortBy]); + // Then sort the filtered results + const [field, direction] = sortBy.split("-"); + return [...filtered].sort((a, b) => { + let comparison = 0; + switch (field) { + case "name": + comparison = a.name.localeCompare(b.name); + break; + case "createdAt": + comparison = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case "services": { + const aTotalServices = a.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + const bTotalServices = b.environments.reduce((total, env) => { + return ( + total + + (env.applications?.length || 0) + + (env.mariadb?.length || 0) + + (env.mongo?.length || 0) + + (env.mysql?.length || 0) + + (env.postgres?.length || 0) + + (env.redis?.length || 0) + + (env.compose?.length || 0) + ); + }, 0); + comparison = aTotalServices - bTotalServices; + break; + } + default: + comparison = 0; + } + return direction === "asc" ? comparison : -comparison; + }); + }, [data, searchQuery, sortBy]); - return ( - <> - -
- -
-
- -
-
- - - - Projects - - - Create and manage your projects - - - {(auth?.role === "owner" || auth?.canCreateProjects) && ( -
- -
- )} -
+ return ( + <> + +
+ +
+
+ +
+
+ + + + Projects + + + Create and manage your projects + + + {(auth?.role === "owner" || auth?.canCreateProjects) && ( +
+ +
+ )} +
- - {isLoading ? ( -
- Loading... - -
- ) : ( - <> -
-
- setSearchQuery(e.target.value)} - className="pr-10" - /> + + {isLoading ? ( +
+ Loading... + +
+ ) : ( + <> +
+
+ setSearchQuery(e.target.value)} + className="pr-10" + /> - -
-
- - -
-
- {filteredProjects?.length === 0 && ( -
- - - No projects found - -
- )} -
- {filteredProjects?.map((project) => { - const emptyServices = project?.environments - .map( - (env) => - env.applications.length === 0 && - env.mariadb.length === 0 && - env.mongo.length === 0 && - env.mysql.length === 0 && - env.postgres.length === 0 && - env.redis.length === 0 && - env.applications.length === 0 && - env.compose.length === 0 - ) - .every(Boolean); + +
+
+ + +
+
+ {filteredProjects?.length === 0 && ( +
+ + + No projects found + +
+ )} +
+ {filteredProjects?.map((project) => { + const emptyServices = project?.environments + .map( + (env) => + env.applications.length === 0 && + env.mariadb.length === 0 && + env.mongo.length === 0 && + env.mysql.length === 0 && + env.postgres.length === 0 && + env.redis.length === 0 && + env.applications.length === 0 && + env.compose.length === 0, + ) + .every(Boolean); - const totalServices = project?.environments - .map( - (env) => - env.mariadb.length + - env.mongo.length + - env.mysql.length + - env.postgres.length + - env.redis.length + - env.applications.length + - env.compose.length - ) - .reduce((acc, curr) => acc + curr, 0); + const totalServices = project?.environments + .map( + (env) => + env.mariadb.length + + env.mongo.length + + env.mysql.length + + env.postgres.length + + env.redis.length + + env.applications.length + + env.compose.length, + ) + .reduce((acc, curr) => acc + curr, 0); - const haveServicesWithDomains = project?.environments - .map( - (env) => - env.applications.length > 0 || - env.compose.length > 0 - ) - .some(Boolean); + const haveServicesWithDomains = project?.environments + .map( + (env) => + env.applications.length > 0 || + env.compose.length > 0, + ) + .some(Boolean); - return ( -
- - - {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} - - - -
- - - {project.name} - -
+ return ( +
+ + + {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} + + + +
+ + + {project.name} + +
- - {project.description} - -
-
- - - - - e.stopPropagation()}> - - Actions - -
e.stopPropagation()}> - -
-
e.stopPropagation()}> - -
+ + {project.description} + + +
+ + + + + e.stopPropagation()} + > + + Actions + +
e.stopPropagation()} + > + +
+
e.stopPropagation()} + > + +
-
e.stopPropagation()}> - {(auth?.role === "owner" || - auth?.canDeleteProjects) && ( - - - - e.preventDefault() - }> - - Delete - - - - - - Are you sure to delete this - project? - - {!emptyServices ? ( -
- - - You have active - services, please delete - them first - -
- ) : ( - - This action cannot be - undone - - )} -
- - - Cancel - - { - await mutateAsync({ - projectId: - project.projectId, - }) - .then(() => { - toast.success( - "Project deleted successfully" - ); - }) - .catch(() => { - toast.error( - "Error deleting this project" - ); - }) - .finally(() => { - utils.project.all.invalidate(); - }); - }}> - Delete - - -
-
- )} -
-
-
-
- - - -
- - Created - - - {totalServices}{" "} - {totalServices === 1 - ? "service" - : "services"} - -
-
- - -
- ); - })} -
- - )} - -
- -
- - ); +
e.stopPropagation()} + > + {(auth?.role === "owner" || + auth?.canDeleteProjects) && ( + + + + e.preventDefault() + } + > + + Delete + + + + + + Are you sure to delete this + project? + + {!emptyServices ? ( +
+ + + You have active + services, please delete + them first + +
+ ) : ( + + This action cannot be + undone + + )} +
+ + + Cancel + + { + await mutateAsync({ + projectId: + project.projectId, + }) + .then(() => { + toast.success( + "Project deleted successfully", + ); + }) + .catch(() => { + toast.error( + "Error deleting this project", + ); + }) + .finally(() => { + utils.project.all.invalidate(); + }); + }} + > + Delete + + +
+
+ )} +
+ + +
+ + + +
+ + Created + + + {totalServices}{" "} + {totalServices === 1 + ? "service" + : "services"} + +
+
+ + +
+ ); + })} +
+ + )} + +
+
+
+ + ); }; diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 9833766f0..3dae77de6 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -1,78 +1,78 @@ "use client"; import type { inferRouterOutputs } from "@trpc/server"; import { - Activity, - BarChartHorizontalBigIcon, - Bell, - BlocksIcon, - BookIcon, - BotIcon, - Boxes, - ChevronRight, - ChevronsUpDown, - CircleHelp, - Clock, - CreditCard, - Database, - Folder, - Forward, - GalleryVerticalEnd, - GitBranch, - HeartIcon, - KeyRound, - Loader2, - type LucideIcon, - Package, - PieChart, - Server, - ShieldCheck, - Trash2, - User, - Users, + Activity, + BarChartHorizontalBigIcon, + Bell, + BlocksIcon, + BookIcon, + BotIcon, + Boxes, + ChevronRight, + ChevronsUpDown, + CircleHelp, + Clock, + CreditCard, + Database, + Folder, + Forward, + GalleryVerticalEnd, + GitBranch, + HeartIcon, + KeyRound, + Loader2, + type LucideIcon, + Package, + PieChart, + Server, + ShieldCheck, + Trash2, + User, + Users, } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, } from "@/components/ui/breadcrumb"; import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, } from "@/components/ui/collapsible"; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Separator } from "@/components/ui/separator"; import { - SIDEBAR_COOKIE_NAME, - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarInset, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - SidebarProvider, - SidebarRail, - SidebarTrigger, - useSidebar, + SIDEBAR_COOKIE_NAME, + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupLabel, + SidebarHeader, + SidebarInset, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarProvider, + SidebarRail, + SidebarTrigger, + useSidebar, } from "@/components/ui/sidebar"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; @@ -90,11 +90,11 @@ import { UserNav } from "./user-nav"; type AuthQueryOutput = inferRouterOutputs["user"]["get"]; type SingleNavItem = { - isSingle?: true; - title: string; - url: string; - icon?: LucideIcon; - isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; + isSingle?: true; + title: string; + url: string; + icon?: LucideIcon; + isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; }; // NavItem type @@ -102,33 +102,33 @@ type SingleNavItem = { // If `isSingle` is true or undefined, the item is a single item // If `isSingle` is false, the item is a group of items type NavItem = - | SingleNavItem - | { - isSingle: false; - title: string; - icon: LucideIcon; - items: SingleNavItem[]; - isEnabled?: (opts: { - auth?: AuthQueryOutput; - isCloud: boolean; - }) => boolean; - }; + | SingleNavItem + | { + isSingle: false; + title: string; + icon: LucideIcon; + items: SingleNavItem[]; + isEnabled?: (opts: { + auth?: AuthQueryOutput; + isCloud: boolean; + }) => boolean; + }; // ExternalLink type // Represents an external link item (used for the help section) type ExternalLink = { - name: string; - url: string; - icon: React.ComponentType<{ className?: string }>; - isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; + name: string; + url: string; + icon: React.ComponentType<{ className?: string }>; + isEnabled?: (opts: { auth?: AuthQueryOutput; isCloud: boolean }) => boolean; }; // Menu type // Consists of home, settings, and help items type Menu = { - home: NavItem[]; - settings: NavItem[]; - help: ExternalLink[]; + home: NavItem[]; + settings: NavItem[]; + help: ExternalLink[]; }; // Menu items @@ -136,257 +136,257 @@ type Menu = { // The items are filtered based on the user's role and permissions // The `isEnabled` function is called to determine if the item should be displayed const MENU: Menu = { - home: [ - { - isSingle: true, - title: "Projects", - url: "/dashboard/projects", - icon: Folder, - }, - { - isSingle: true, - title: "Monitoring", - url: "/dashboard/monitoring", - icon: BarChartHorizontalBigIcon, - // Only enabled in non-cloud environments - isEnabled: ({ isCloud }) => !isCloud, - }, - { - isSingle: true, - title: "Schedules", - url: "/dashboard/schedules", - icon: Clock, - // Only enabled in non-cloud environments - isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner", - }, - { - isSingle: true, - title: "Traefik File System", - url: "/dashboard/traefik", - icon: GalleryVerticalEnd, - // Only enabled for admins and users with access to Traefik files in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!( - (auth?.role === "owner" || auth?.canAccessToTraefikFiles) && - !isCloud - ), - }, - { - isSingle: true, - title: "Docker", - url: "/dashboard/docker", - icon: BlocksIcon, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), - }, - { - isSingle: true, - title: "Swarm", - url: "/dashboard/swarm", - icon: PieChart, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), - }, - { - isSingle: true, - title: "Requests", - url: "/dashboard/requests", - icon: Forward, - // Only enabled for admins and users with access to Docker in non-cloud environments - isEnabled: ({ auth, isCloud }) => - !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), - }, + home: [ + { + isSingle: true, + title: "Projects", + url: "/dashboard/projects", + icon: Folder, + }, + { + isSingle: true, + title: "Monitoring", + url: "/dashboard/monitoring", + icon: BarChartHorizontalBigIcon, + // Only enabled in non-cloud environments + isEnabled: ({ isCloud }) => !isCloud, + }, + { + isSingle: true, + title: "Schedules", + url: "/dashboard/schedules", + icon: Clock, + // Only enabled in non-cloud environments + isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner", + }, + { + isSingle: true, + title: "Traefik File System", + url: "/dashboard/traefik", + icon: GalleryVerticalEnd, + // Only enabled for admins and users with access to Traefik files in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!( + (auth?.role === "owner" || auth?.canAccessToTraefikFiles) && + !isCloud + ), + }, + { + isSingle: true, + title: "Docker", + url: "/dashboard/docker", + icon: BlocksIcon, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + }, + { + isSingle: true, + title: "Swarm", + url: "/dashboard/swarm", + icon: PieChart, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + }, + { + isSingle: true, + title: "Requests", + url: "/dashboard/requests", + icon: Forward, + // Only enabled for admins and users with access to Docker in non-cloud environments + isEnabled: ({ auth, isCloud }) => + !!((auth?.role === "owner" || auth?.canAccessToDocker) && !isCloud), + }, - // Legacy unused menu, adjusted to the new structure - // { - // isSingle: true, - // title: "Projects", - // url: "/dashboard/projects", - // icon: Folder, - // }, - // { - // isSingle: true, - // title: "Monitoring", - // icon: BarChartHorizontalBigIcon, - // url: "/dashboard/settings/monitoring", - // }, - // { - // isSingle: false, - // title: "Settings", - // icon: Settings2, - // items: [ - // { - // title: "Profile", - // url: "/dashboard/settings/profile", - // }, - // { - // title: "Users", - // url: "/dashboard/settings/users", - // }, - // { - // title: "SSH Key", - // url: "/dashboard/settings/ssh-keys", - // }, - // { - // title: "Git", - // url: "/dashboard/settings/git-providers", - // }, - // ], - // }, - // { - // isSingle: false, - // title: "Integrations", - // icon: BlocksIcon, - // items: [ - // { - // title: "S3 Destinations", - // url: "/dashboard/settings/destinations", - // }, - // { - // title: "Registry", - // url: "/dashboard/settings/registry", - // }, - // { - // title: "Notifications", - // url: "/dashboard/settings/notifications", - // }, - // ], - // }, - ], + // Legacy unused menu, adjusted to the new structure + // { + // isSingle: true, + // title: "Projects", + // url: "/dashboard/projects", + // icon: Folder, + // }, + // { + // isSingle: true, + // title: "Monitoring", + // icon: BarChartHorizontalBigIcon, + // url: "/dashboard/settings/monitoring", + // }, + // { + // isSingle: false, + // title: "Settings", + // icon: Settings2, + // items: [ + // { + // title: "Profile", + // url: "/dashboard/settings/profile", + // }, + // { + // title: "Users", + // url: "/dashboard/settings/users", + // }, + // { + // title: "SSH Key", + // url: "/dashboard/settings/ssh-keys", + // }, + // { + // title: "Git", + // url: "/dashboard/settings/git-providers", + // }, + // ], + // }, + // { + // isSingle: false, + // title: "Integrations", + // icon: BlocksIcon, + // items: [ + // { + // title: "S3 Destinations", + // url: "/dashboard/settings/destinations", + // }, + // { + // title: "Registry", + // url: "/dashboard/settings/registry", + // }, + // { + // title: "Notifications", + // url: "/dashboard/settings/notifications", + // }, + // ], + // }, + ], - settings: [ - { - isSingle: true, - title: "Web Server", - url: "/dashboard/settings/server", - icon: Activity, - // Only enabled for admins in non-cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), - }, - { - isSingle: true, - title: "Profile", - url: "/dashboard/settings/profile", - icon: User, - }, - { - isSingle: true, - title: "Remote Servers", - url: "/dashboard/settings/servers", - icon: Server, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Users", - icon: Users, - url: "/dashboard/settings/users", - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "SSH Keys", - icon: KeyRound, - url: "/dashboard/settings/ssh-keys", - // Only enabled for admins and users with access to SSH keys - isEnabled: ({ auth }) => - !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), - }, - { - title: "AI", - icon: BotIcon, - url: "/dashboard/settings/ai", - isSingle: true, - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Git", - url: "/dashboard/settings/git-providers", - icon: GitBranch, - // Only enabled for admins and users with access to Git providers - isEnabled: ({ auth }) => - !!(auth?.role === "owner" || auth?.canAccessToGitProviders), - }, - { - isSingle: true, - title: "Registry", - url: "/dashboard/settings/registry", - icon: Package, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "S3 Destinations", - url: "/dashboard/settings/destinations", - icon: Database, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, + settings: [ + { + isSingle: true, + title: "Web Server", + url: "/dashboard/settings/server", + icon: Activity, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + }, + { + isSingle: true, + title: "Profile", + url: "/dashboard/settings/profile", + icon: User, + }, + { + isSingle: true, + title: "Remote Servers", + url: "/dashboard/settings/servers", + icon: Server, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Users", + icon: Users, + url: "/dashboard/settings/users", + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "SSH Keys", + icon: KeyRound, + url: "/dashboard/settings/ssh-keys", + // Only enabled for admins and users with access to SSH keys + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.canAccessToSSHKeys), + }, + { + title: "AI", + icon: BotIcon, + url: "/dashboard/settings/ai", + isSingle: true, + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Git", + url: "/dashboard/settings/git-providers", + icon: GitBranch, + // Only enabled for admins and users with access to Git providers + isEnabled: ({ auth }) => + !!(auth?.role === "owner" || auth?.canAccessToGitProviders), + }, + { + isSingle: true, + title: "Registry", + url: "/dashboard/settings/registry", + icon: Package, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "S3 Destinations", + url: "/dashboard/settings/destinations", + icon: Database, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, - { - isSingle: true, - title: "Certificates", - url: "/dashboard/settings/certificates", - icon: ShieldCheck, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Cluster", - url: "/dashboard/settings/cluster", - icon: Boxes, - // Only enabled for admins in non-cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), - }, - { - isSingle: true, - title: "Notifications", - url: "/dashboard/settings/notifications", - icon: Bell, - // Only enabled for admins - isEnabled: ({ auth }) => !!(auth?.role === "owner"), - }, - { - isSingle: true, - title: "Billing", - url: "/dashboard/settings/billing", - icon: CreditCard, - // Only enabled for admins in cloud environments - isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), - }, - ], + { + isSingle: true, + title: "Certificates", + url: "/dashboard/settings/certificates", + icon: ShieldCheck, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Cluster", + url: "/dashboard/settings/cluster", + icon: Boxes, + // Only enabled for admins in non-cloud environments + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && !isCloud), + }, + { + isSingle: true, + title: "Notifications", + url: "/dashboard/settings/notifications", + icon: Bell, + // Only enabled for admins + isEnabled: ({ auth }) => !!(auth?.role === "owner"), + }, + { + isSingle: true, + title: "Billing", + url: "/dashboard/settings/billing", + icon: CreditCard, + // Only enabled for admins in cloud environments + isEnabled: ({ auth, isCloud }) => !!(auth?.role === "owner" && isCloud), + }, + ], - help: [ - { - name: "Documentation", - url: "https://docs.dokploy.com/docs/core", - icon: BookIcon, - }, - { - name: "Support", - url: "https://discord.gg/2tBnJ3jDJc", - icon: CircleHelp, - }, - { - name: "Sponsor", - url: "https://opencollective.com/dokploy", - icon: ({ className }) => ( - - ), - }, - ], + help: [ + { + name: "Documentation", + url: "https://docs.dokploy.com/docs/core", + icon: BookIcon, + }, + { + name: "Support", + url: "https://discord.gg/2tBnJ3jDJc", + icon: CircleHelp, + }, + { + name: "Sponsor", + url: "https://opencollective.com/dokploy", + icon: ({ className }) => ( + + ), + }, + ], } as const; /** @@ -394,41 +394,41 @@ const MENU: Menu = { * @returns a menu object with the home, settings, and help items */ function createMenuForAuthUser(opts: { - auth?: AuthQueryOutput; - isCloud: boolean; + auth?: AuthQueryOutput; + isCloud: boolean; }): Menu { - return { - // Filter the home items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - home: MENU.home.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }) - ), - // Filter the settings items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - settings: MENU.settings.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }) - ), - // Filter the help items based on the user's role and permissions - // Calls the `isEnabled` function if it exists to determine if the item should be displayed - help: MENU.help.filter((item) => - !item.isEnabled - ? true - : item.isEnabled({ - auth: opts.auth, - isCloud: opts.isCloud, - }) - ), - }; + return { + // Filter the home items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + home: MENU.home.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + isCloud: opts.isCloud, + }), + ), + // Filter the settings items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + settings: MENU.settings.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + isCloud: opts.isCloud, + }), + ), + // Filter the help items based on the user's role and permissions + // Calls the `isEnabled` function if it exists to determine if the item should be displayed + help: MENU.help.filter((item) => + !item.isEnabled + ? true + : item.isEnabled({ + auth: opts.auth, + isCloud: opts.isCloud, + }), + ), + }; } /** @@ -436,24 +436,24 @@ function createMenuForAuthUser(opts: { * @returns true if the item url is active, false otherwise */ function isActiveRoute(opts: { - /** The url of the item. Usually obtained from `item.url` */ - itemUrl: string; - /** The current pathname. Usually obtained from `usePathname()` */ - pathname: string; + /** The url of the item. Usually obtained from `item.url` */ + itemUrl: string; + /** The current pathname. Usually obtained from `usePathname()` */ + pathname: string; }): boolean { - const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project"); - const normalizedPathname = opts.pathname?.replace("/projects", "/project"); + const normalizedItemUrl = opts.itemUrl?.replace("/projects", "/project"); + const normalizedPathname = opts.pathname?.replace("/projects", "/project"); - if (!normalizedPathname) return false; + if (!normalizedPathname) return false; - if (normalizedPathname === normalizedItemUrl) return true; + if (normalizedPathname === normalizedItemUrl) return true; - if (normalizedPathname.startsWith(normalizedItemUrl)) { - const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); - return nextChar === "/"; - } + if (normalizedPathname.startsWith(normalizedItemUrl)) { + const nextChar = normalizedPathname.charAt(normalizedItemUrl.length); + return nextChar === "/"; + } - return false; + return false; } /** @@ -461,587 +461,615 @@ function isActiveRoute(opts: { * @returns the active nav item with `SingleNavItem` type or undefined if none is active */ function findActiveNavItem( - navItems: NavItem[], - pathname: string + navItems: NavItem[], + pathname: string, ): SingleNavItem | undefined { - const found = navItems.find((item) => - item.isSingle !== false - ? // The current item is single, so check if the item url is active - isActiveRoute({ itemUrl: item.url, pathname }) - : // The current item is not single, so check if any of the sub items are active - item.items.some((item) => - isActiveRoute({ itemUrl: item.url, pathname }) - ) - ); + const found = navItems.find((item) => + item.isSingle !== false + ? // The current item is single, so check if the item url is active + isActiveRoute({ itemUrl: item.url, pathname }) + : // The current item is not single, so check if any of the sub items are active + item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ), + ); - if (found?.isSingle !== false) { - // The found item is single, so return it - return found; - } + if (found?.isSingle !== false) { + // The found item is single, so return it + return found; + } - // The found item is not single, so find the active sub item - return found?.items.find((item) => - isActiveRoute({ itemUrl: item.url, pathname }) - ); + // The found item is not single, so find the active sub item + return found?.items.find((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); } interface Props { - children: React.ReactNode; + children: React.ReactNode; } function LogoWrapper() { - return ; + return ; } function SidebarLogo() { - const { state } = useSidebar(); - const { data: isCloud } = api.settings.isCloud.useQuery(); - const { data: user } = api.user.get.useQuery(); - const { data: session } = authClient.useSession(); + const { state } = useSidebar(); + const { data: isCloud } = api.settings.isCloud.useQuery(); + const { data: user } = api.user.get.useQuery(); + const { data: session } = authClient.useSession(); - const { - data: organizations, - refetch, - isLoading, - } = api.organization.all.useQuery(); - const { mutateAsync: deleteOrganization, isLoading: isRemoving } = - api.organization.delete.useMutation(); - const { isMobile } = useSidebar(); - const { data: activeOrganization } = authClient.useActiveOrganization(); - const _utils = api.useUtils(); + const { + data: organizations, + refetch, + isLoading, + } = api.organization.all.useQuery(); + const { mutateAsync: deleteOrganization, isLoading: isRemoving } = + api.organization.delete.useMutation(); + const { isMobile } = useSidebar(); + const { data: activeOrganization } = authClient.useActiveOrganization(); + const _utils = api.useUtils(); - const { data: invitations, refetch: refetchInvitations } = - api.user.getInvitations.useQuery(); + const { data: invitations, refetch: refetchInvitations } = + api.user.getInvitations.useQuery(); - const [_activeTeam, setActiveTeam] = useState< - typeof activeOrganization | null - >(null); + const [_activeTeam, setActiveTeam] = useState< + typeof activeOrganization | null + >(null); - useEffect(() => { - if (activeOrganization) { - setActiveTeam(activeOrganization); - } - }, [activeOrganization]); + useEffect(() => { + if (activeOrganization) { + setActiveTeam(activeOrganization); + } + }, [activeOrganization]); - return ( - <> - {isLoading ? ( -
- -
- ) : ( - - {/* Organization Logo and Selector */} - - - - -
-
- -
-
-

- {activeOrganization?.name ?? "Select Organization"} -

-
-
- -
-
- - - Organizations - - {organizations?.map((org) => ( -
- { - await authClient.organization.setActive({ - organizationId: org.id, - }); - window.location.reload(); - }} - className="w-full gap-2 p-2"> -
{org.name}
-
- -
-
- {org.ownerId === session?.user?.id && ( -
- - { - await deleteOrganization({ - organizationId: org.id, - }) - .then(() => { - refetch(); - toast.success( - "Organization deleted successfully" - ); - }) - .catch((error) => { - toast.error( - error?.message || - "Error deleting organization" - ); - }); - }}> - - -
- )} -
- ))} - {(user?.role === "owner" || isCloud) && ( - <> - - - - )} -
-
-
+ return ( + <> + {isLoading ? ( +
+ +
+ ) : ( + + {/* Organization Logo and Selector */} + + + + +
+
+ +
+
+

+ {activeOrganization?.name ?? "Select Organization"} +

+
+
+ +
+
+ + + Organizations + + {organizations?.map((org) => ( +
+ { + await authClient.organization.setActive({ + organizationId: org.id, + }); + window.location.reload(); + }} + className="w-full gap-2 p-2" + > +
{org.name}
+
+ +
+
+ {org.ownerId === session?.user?.id && ( +
+ + { + await deleteOrganization({ + organizationId: org.id, + }) + .then(() => { + refetch(); + toast.success( + "Organization deleted successfully", + ); + }) + .catch((error) => { + toast.error( + error?.message || + "Error deleting organization", + ); + }); + }} + > + + +
+ )} +
+ ))} + {(user?.role === "owner" || isCloud) && ( + <> + + + + )} +
+
+
- {/* Notification Bell */} - - - - - - - Pending Invitations -
- {invitations && invitations.length > 0 ? ( - invitations.map((invitation) => ( -
- e.preventDefault()}> -
- {invitation?.organization?.name} -
-
- Expires:{" "} - {new Date(invitation.expiresAt).toLocaleString()} -
-
- Role: {invitation.role} -
-
- { - const { error } = - await authClient.organization.acceptInvitation({ - invitationId: invitation.id, - }); + {/* Notification Bell */} + + + + + + + Pending Invitations +
+ {invitations && invitations.length > 0 ? ( + invitations.map((invitation) => ( +
+ e.preventDefault()} + > +
+ {invitation?.organization?.name} +
+
+ Expires:{" "} + {new Date(invitation.expiresAt).toLocaleString()} +
+
+ Role: {invitation.role} +
+
+ { + const { error } = + await authClient.organization.acceptInvitation({ + invitationId: invitation.id, + }); - if (error) { - toast.error( - error.message || "Error accepting invitation" - ); - } else { - toast.success("Invitation accepted successfully"); - await refetchInvitations(); - await refetch(); - } - }}> - - -
- )) - ) : ( - - No pending invitations - - )} -
-
-
-
- - )} - - ); + if (error) { + toast.error( + error.message || "Error accepting invitation", + ); + } else { + toast.success("Invitation accepted successfully"); + await refetchInvitations(); + await refetch(); + } + }} + > + +
+
+ )) + ) : ( + + No pending invitations + + )} +
+
+
+
+
+ )} + + ); } export default function Page({ children }: Props) { - const [defaultOpen, setDefaultOpen] = useState( - undefined - ); - const [isLoaded, setIsLoaded] = useState(false); + const [defaultOpen, setDefaultOpen] = useState( + undefined, + ); + const [isLoaded, setIsLoaded] = useState(false); - useEffect(() => { - const cookieValue = document.cookie - .split("; ") - .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`)) - ?.split("=")[1]; + useEffect(() => { + const cookieValue = document.cookie + .split("; ") + .find((row) => row.startsWith(`${SIDEBAR_COOKIE_NAME}=`)) + ?.split("=")[1]; - setDefaultOpen(cookieValue === undefined ? true : cookieValue === "true"); - setIsLoaded(true); - }, []); + setDefaultOpen(cookieValue === undefined ? true : cookieValue === "true"); + setIsLoaded(true); + }, []); - const pathname = usePathname(); - const { data: auth } = api.user.get.useQuery(); - const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); + const pathname = usePathname(); + const { data: auth } = api.user.get.useQuery(); + const { data: dokployVersion } = api.settings.getDokployVersion.useQuery(); - const includesProjects = pathname?.includes("/dashboard/project"); - const { data: isCloud } = api.settings.isCloud.useQuery(); + const includesProjects = pathname?.includes("/dashboard/project"); + const { data: isCloud } = api.settings.isCloud.useQuery(); - const { - home: filteredHome, - settings: filteredSettings, - help, - } = createMenuForAuthUser({ auth, isCloud: !!isCloud }); + const { + home: filteredHome, + settings: filteredSettings, + help, + } = createMenuForAuthUser({ auth, isCloud: !!isCloud }); - const activeItem = findActiveNavItem( - [...filteredHome, ...filteredSettings], - pathname - ); + const activeItem = findActiveNavItem( + [...filteredHome, ...filteredSettings], + pathname, + ); - if (!isLoaded) { - return
; // Placeholder mientras se carga - } + if (!isLoaded) { + return
; // Placeholder mientras se carga + } - return ( - { - setDefaultOpen(open); + return ( + { + setDefaultOpen(open); - document.cookie = `${SIDEBAR_COOKIE_NAME}=${open}`; - }} - style={ - { - "--sidebar-width": "19.5rem", - "--sidebar-width-mobile": "19.5rem", - } as React.CSSProperties - }> - - - {/* + + + {/* */} - - {/* */} - - - - Home - - {filteredHome.map((item) => { - const isSingle = item.isSingle !== false; - const isActive = isSingle - ? isActiveRoute({ itemUrl: item.url, pathname }) - : item.items.some((item) => - isActiveRoute({ itemUrl: item.url, pathname }) - ); + + {/* */} + + + + Home + + {filteredHome.map((item) => { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); - return ( - - - {isSingle ? ( - - - {item.icon && ( - - )} - {item.title} - - - ) : ( - <> - - - {item.icon && } + return ( + + + {isSingle ? ( + + + {item.icon && ( + + )} + {item.title} + + + ) : ( + <> + + + {item.icon && } - {item.title} - {item.items?.length && ( - - )} - - - - - {item.items?.map((subItem) => ( - - - - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ); - })} - - - - Settings - - {filteredSettings.map((item) => { - const isSingle = item.isSingle !== false; - const isActive = isSingle - ? isActiveRoute({ itemUrl: item.url, pathname }) - : item.items.some((item) => - isActiveRoute({ itemUrl: item.url, pathname }) - ); + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} + + + + Settings + + {filteredSettings.map((item) => { + const isSingle = item.isSingle !== false; + const isActive = isSingle + ? isActiveRoute({ itemUrl: item.url, pathname }) + : item.items.some((item) => + isActiveRoute({ itemUrl: item.url, pathname }), + ); - return ( - - - {isSingle ? ( - - - {item.icon && ( - - )} - {item.title} - - - ) : ( - <> - - - {item.icon && } + return ( + + + {isSingle ? ( + + + {item.icon && ( + + )} + {item.title} + + + ) : ( + <> + + + {item.icon && } - {item.title} - {item.items?.length && ( - - )} - - - - - {item.items?.map((subItem) => ( - - - - {subItem.icon && ( - - - - )} - {subItem.title} - - - - ))} - - - - )} - - - ); - })} - - - - Extra - - {help.map((item: ExternalLink) => ( - - - - - - - {item.name} - - - - ))} - - - - - - {!isCloud && auth?.role === "owner" && ( - - - - )} - - - - {dokployVersion && ( - <> -
- Version {dokployVersion} -
-
- {dokployVersion} -
- - )} -
-
- -
- - {!includesProjects && ( -
-
-
- - - - - - - - {activeItem?.title} - - - - - -
- -
-
- )} + {item.title} + {item.items?.length && ( + + )} + + + + + {item.items?.map((subItem) => ( + + + + {subItem.icon && ( + + + + )} + {subItem.title} + + + + ))} + + + + )} + + + ); + })} + + + + Extra + + {help.map((item: ExternalLink) => ( + + + + + + + {item.name} + + + + ))} + + + + + + {!isCloud && auth?.role === "owner" && ( + + + + )} + + + + {dokployVersion && ( + <> +
+ Version {dokployVersion} +
+
+ {dokployVersion} +
+ + )} +
+
+ + + + {!includesProjects && ( +
+
+
+ + + + + + + + {activeItem?.title} + + + + + +
+ +
+
+ )} -
{children}
-
-
- ); +
{children}
+ +
+ ); } diff --git a/apps/dokploy/components/ui/time-badge.tsx b/apps/dokploy/components/ui/time-badge.tsx index 8d47297ea..b409bf703 100644 --- a/apps/dokploy/components/ui/time-badge.tsx +++ b/apps/dokploy/components/ui/time-badge.tsx @@ -4,57 +4,57 @@ import { useEffect, useState } from "react"; import { api } from "@/utils/api"; export function TimeBadge() { - const { data: serverTime } = api.server.getServerTime.useQuery(undefined, { - refetchInterval: 60000, // Refetch every 60 seconds - }); - const [time, setTime] = useState(null); + const { data: serverTime } = api.server.getServerTime.useQuery(undefined, { + refetchInterval: 60000, // Refetch every 60 seconds + }); + const [time, setTime] = useState(null); - useEffect(() => { - if (serverTime?.time) { - setTime(new Date(serverTime.time)); - } - }, [serverTime]); + useEffect(() => { + if (serverTime?.time) { + setTime(new Date(serverTime.time)); + } + }, [serverTime]); - useEffect(() => { - const timer = setInterval(() => { - setTime((prevTime) => { - if (!prevTime) return null; - const newTime = new Date(prevTime.getTime() + 1000); - return newTime; - }); - }, 1000); + useEffect(() => { + const timer = setInterval(() => { + setTime((prevTime) => { + if (!prevTime) return null; + const newTime = new Date(prevTime.getTime() + 1000); + return newTime; + }); + }, 1000); - return () => { - clearInterval(timer); - }; - }, []); + return () => { + clearInterval(timer); + }; + }, []); - if (!time || !serverTime?.timezone) { - return null; - } + if (!time || !serverTime?.timezone) { + return null; + } - const getUtcOffset = (timeZone: string) => { - const date = new Date(); - const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" })); - const tzDate = new Date(date.toLocaleString("en-US", { timeZone })); - const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60); - const sign = offset >= 0 ? "+" : "-"; - const hours = Math.floor(Math.abs(offset)); - const minutes = (Math.abs(offset) * 60) % 60; - return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes - .toString() - .padStart(2, "0")}`; - }; + const getUtcOffset = (timeZone: string) => { + const date = new Date(); + const utcDate = new Date(date.toLocaleString("en-US", { timeZone: "UTC" })); + const tzDate = new Date(date.toLocaleString("en-US", { timeZone })); + const offset = (tzDate.getTime() - utcDate.getTime()) / (1000 * 60 * 60); + const sign = offset >= 0 ? "+" : "-"; + const hours = Math.floor(Math.abs(offset)); + const minutes = (Math.abs(offset) * 60) % 60; + return `UTC${sign}${hours.toString().padStart(2, "0")}:${minutes + .toString() + .padStart(2, "0")}`; + }; - return ( -
- Server Time: - - {time.toLocaleTimeString()} - - - ({serverTime.timezone} | {getUtcOffset(serverTime.timezone)}) - -
- ); + return ( +
+ Server Time: + + {time.toLocaleTimeString()} + + + ({serverTime.timezone} | {getUtcOffset(serverTime.timezone)}) + +
+ ); } From c56ddf3ec1dae72cca60408ff9ad50db2fb27f55 Mon Sep 17 00:00:00 2001 From: ChillerDragon Date: Mon, 10 Nov 2025 11:12:00 +0100 Subject: [PATCH 03/11] fix: pr template checkboxes without a space they do not render as checkboxes on github --- .github/pull_request_template.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0b849afc0..d45c3dac0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -6,9 +6,9 @@ Please describe in a short paragraph what this PR is about. Before submitting this PR, please make sure that: -- [] You created a dedicated branch based on the `canary` branch. -- [] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request -- [] You have tested this PR in your local instance. +- [ ] You created a dedicated branch based on the `canary` branch. +- [ ] You have read the suggestions in the CONTRIBUTING.md file https://github.com/Dokploy/dokploy/blob/canary/CONTRIBUTING.md#pull-request +- [ ] You have tested this PR in your local instance. ## Issues related (if applicable) From 843313ddb9cbe6fee109eeab7f737a001400fe37 Mon Sep 17 00:00:00 2001 From: HarikrishnanD Date: Tue, 11 Nov 2025 13:10:47 +0530 Subject: [PATCH 04/11] feat: add expandable commit messages for deployment logs --- .../deployments/show-deployments.tsx | 106 ++++++++++++++++-- 1 file changed, 97 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 1045856c2..5848a046f 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,4 +1,4 @@ -import { Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; +import { ChevronDown, ChevronUp, Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -80,6 +80,52 @@ export const ShowDeployments = ({ } = api.compose.cancelDeployment.useMutation(); const [url, setUrl] = React.useState(""); + const [expandedDescriptions, setExpandedDescriptions] = useState>( + new Set(), + ); + + // Maximum character length before truncating commit messages + const MAX_DESCRIPTION_LENGTH = 150; + + // Helper function to truncate description intelligently + const truncateDescription = (description: string, maxLength: number): string => { + if (maxLength <= 0) { + return description; // Don't truncate if maxLength is 0 or negative + } + if (description.length <= maxLength) { + return description; + } + // Try to truncate at a word boundary if possible + const truncated = description.slice(0, maxLength); + const lastSpace = truncated.lastIndexOf(" "); + // If we find a space near the end (within last 20 chars), use it for cleaner truncation + if (lastSpace > maxLength - 20 && lastSpace > 0) { + return `${truncated.slice(0, lastSpace)}...`; + } + return `${truncated}...`; + }; + + // Check if description should be truncated + const shouldTruncate = (description: string): boolean => { + // Only truncate if MAX_DESCRIPTION_LENGTH is greater than 0 + if (MAX_DESCRIPTION_LENGTH <= 0) { + return false; + } + return description.length > MAX_DESCRIPTION_LENGTH; + }; + + // Toggle expand/collapse state for a specific deployment + const toggleDescription = (deploymentId: string) => { + setExpandedDescriptions((prev) => { + const next = new Set(prev); + if (next.has(deploymentId)) { + next.delete(deploymentId); + } else { + next.add(deploymentId); + } + return next; + }); + }; // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment const stuckDeployment = useMemo(() => { @@ -230,14 +276,56 @@ export const ShowDeployments = ({ className="size-2.5" /> - - {deployment.title} - - {deployment.description && ( - - {deployment.description} - - )} + {(() => { + // The commit message is in the title field, so we truncate that + const titleText = deployment.title.trim(); + const needsTruncation = shouldTruncate(titleText); + const isExpanded = expandedDescriptions.has(deployment.deploymentId); + + return ( +
+ {/* Commit message (from title) - truncated */} + + {isExpanded || !needsTruncation + ? titleText + : truncateDescription( + titleText, + MAX_DESCRIPTION_LENGTH, + )} + + {needsTruncation && ( + + )} + {/* Hash (from description) - shown in compact form */} + {deployment.description && deployment.description.trim() && ( + + {deployment.description} + + )} +
+ ); + })()}
From 70bb32c59073f0756fce52b17c7eda1c6d2fb5e0 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 07:42:12 +0000 Subject: [PATCH 05/11] [autofix.ci] apply automated fixes --- .../deployments/show-deployments.tsx | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 5848a046f..a5a8c5fd6 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -1,4 +1,12 @@ -import { ChevronDown, ChevronUp, Clock, Loader2, RefreshCcw, RocketIcon, Settings } from "lucide-react"; +import { + ChevronDown, + ChevronUp, + Clock, + Loader2, + RefreshCcw, + RocketIcon, + Settings, +} from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { AlertBlock } from "@/components/shared/alert-block"; @@ -88,7 +96,10 @@ export const ShowDeployments = ({ const MAX_DESCRIPTION_LENGTH = 150; // Helper function to truncate description intelligently - const truncateDescription = (description: string, maxLength: number): string => { + const truncateDescription = ( + description: string, + maxLength: number, + ): string => { if (maxLength <= 0) { return description; // Don't truncate if maxLength is 0 or negative } @@ -280,8 +291,10 @@ export const ShowDeployments = ({ // The commit message is in the title field, so we truncate that const titleText = deployment.title.trim(); const needsTruncation = shouldTruncate(titleText); - const isExpanded = expandedDescriptions.has(deployment.deploymentId); - + const isExpanded = expandedDescriptions.has( + deployment.deploymentId, + ); + return (
{/* Commit message (from title) - truncated */} @@ -296,7 +309,9 @@ export const ShowDeployments = ({ {needsTruncation && ( )} {/* Hash (from description) - shown in compact form */} - {deployment.description && deployment.description.trim() && ( - - {deployment.description} - - )} + {deployment.description && + deployment.description.trim() && ( + + {deployment.description} + + )}
); })()} From 05e3d241f17692925d1fc537c75c4957af64ac12 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 15 Nov 2025 17:43:51 -0600 Subject: [PATCH 06/11] feat: increase commit message truncation length and simplify truncation logic - Updated the maximum character length for commit message truncation from 150 to 200 characters. - Simplified the truncation logic by removing unnecessary checks and consolidating the function to focus solely on the new maximum length. - Enhanced the display logic for deployment titles to ensure better readability and user experience. --- .../deployments/show-deployments.tsx | 297 ++++++++---------- 1 file changed, 136 insertions(+), 161 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index a5a8c5fd6..8e7eb66ca 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -92,39 +92,21 @@ export const ShowDeployments = ({ new Set(), ); - // Maximum character length before truncating commit messages - const MAX_DESCRIPTION_LENGTH = 150; + const MAX_DESCRIPTION_LENGTH = 200; - // Helper function to truncate description intelligently - const truncateDescription = ( - description: string, - maxLength: number, - ): string => { - if (maxLength <= 0) { - return description; // Don't truncate if maxLength is 0 or negative - } - if (description.length <= maxLength) { + const truncateDescription = (description: string): string => { + if (description.length <= MAX_DESCRIPTION_LENGTH) { return description; } - // Try to truncate at a word boundary if possible - const truncated = description.slice(0, maxLength); + const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); const lastSpace = truncated.lastIndexOf(" "); - // If we find a space near the end (within last 20 chars), use it for cleaner truncation - if (lastSpace > maxLength - 20 && lastSpace > 0) { + // Truncate at word boundary if found near the end + if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { return `${truncated.slice(0, lastSpace)}...`; } return `${truncated}...`; }; - // Check if description should be truncated - const shouldTruncate = (description: string): boolean => { - // Only truncate if MAX_DESCRIPTION_LENGTH is greater than 0 - if (MAX_DESCRIPTION_LENGTH <= 0) { - return false; - } - return description.length > MAX_DESCRIPTION_LENGTH; - }; - // Toggle expand/collapse state for a specific deployment const toggleDescription = (deploymentId: string) => { setExpandedDescriptions((prev) => { @@ -274,165 +256,158 @@ export const ShowDeployments = ({
) : (
- {deployments?.map((deployment, index) => ( -
-
- - {index + 1}. {deployment.status} - - - {(() => { - // The commit message is in the title field, so we truncate that - const titleText = deployment.title.trim(); - const needsTruncation = shouldTruncate(titleText); - const isExpanded = expandedDescriptions.has( - deployment.deploymentId, - ); + {deployments?.map((deployment, index) => { + const titleText = deployment?.title?.trim() || ""; + const needsTruncation = titleText.length > MAX_DESCRIPTION_LENGTH; + const isExpanded = expandedDescriptions.has( + deployment.deploymentId, + ); - return ( -
- {/* Commit message (from title) - truncated */} - - {isExpanded || !needsTruncation - ? titleText - : truncateDescription( - titleText, - MAX_DESCRIPTION_LENGTH, - )} - - {needsTruncation && ( - - )} - {/* Hash (from description) - shown in compact form */} - {deployment.description && - deployment.description.trim() && ( - - {deployment.description} - - )} -
- ); - })()} -
-
-
- - {deployment.startedAt && deployment.finishedAt && ( - - - {formatDuration( - Math.floor( - (new Date(deployment.finishedAt).getTime() - - new Date(deployment.startedAt).getTime()) / - 1000, - ), - )} - - )} -
+ return ( +
+
+ + {index + 1}. {deployment.status} + + -
- {deployment.pid && deployment.status === "running" && ( - { - await killProcess({ - deploymentId: deployment.deploymentId, - }) - .then(() => { - toast.success("Process killed successfully"); - }) - .catch(() => { - toast.error("Error killing process"); - }); - }} - > - - - )} - + {isExpanded ? ( + <> + + Show less + + ) : ( + <> + + Show more + + )} + + )} + {/* Hash (from description) - shown in compact form */} + {deployment.description?.trim() && ( + + {deployment.description} + + )} +
+
+
+
+ + {deployment.startedAt && deployment.finishedAt && ( + + + {formatDuration( + Math.floor( + (new Date(deployment.finishedAt).getTime() - + new Date(deployment.startedAt).getTime()) / + 1000, + ), + )} + + )} +
- {deployment?.rollback && - deployment.status === "done" && - type === "application" && ( +
+ {deployment.pid && deployment.status === "running" && ( { - await rollback({ - rollbackId: deployment.rollback.rollbackId, + await killProcess({ + deploymentId: deployment.deploymentId, }) .then(() => { - toast.success( - "Rollback initiated successfully", - ); + toast.success("Process killed successfully"); }) .catch(() => { - toast.error("Error initiating rollback"); + toast.error("Error killing process"); }); }} > )} + + + {deployment?.rollback && + deployment.status === "done" && + type === "application" && ( + { + await rollback({ + rollbackId: deployment.rollback.rollbackId, + }) + .then(() => { + toast.success( + "Rollback initiated successfully", + ); + }) + .catch(() => { + toast.error("Error initiating rollback"); + }); + }} + > + + + )} +
-
- ))} + ); + })}
)} Date: Sat, 15 Nov 2025 17:46:14 -0600 Subject: [PATCH 07/11] refactor: simplify deployment description toggle logic - Removed the separate toggleDescription function and integrated its logic directly into the button's onClick handler for better readability. - Maintained functionality for expanding and collapsing deployment descriptions while streamlining the code structure. --- .../deployments/show-deployments.tsx | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx index 8e7eb66ca..1885ffc3a 100644 --- a/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx +++ b/apps/dokploy/components/dashboard/application/deployments/show-deployments.tsx @@ -100,26 +100,12 @@ export const ShowDeployments = ({ } const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH); const lastSpace = truncated.lastIndexOf(" "); - // Truncate at word boundary if found near the end if (lastSpace > MAX_DESCRIPTION_LENGTH - 20 && lastSpace > 0) { return `${truncated.slice(0, lastSpace)}...`; } return `${truncated}...`; }; - // Toggle expand/collapse state for a specific deployment - const toggleDescription = (deploymentId: string) => { - setExpandedDescriptions((prev) => { - const next = new Set(prev); - if (next.has(deploymentId)) { - next.delete(deploymentId); - } else { - next.add(deploymentId); - } - return next; - }); - }; - // Check for stuck deployment (more than 9 minutes) - only for the most recent deployment const stuckDeployment = useMemo(() => { if (!isCloud || !deployments || deployments.length === 0) return null; @@ -286,9 +272,15 @@ export const ShowDeployments = ({ {needsTruncation && (
@@ -1400,11 +1466,6 @@ const EnvironmentPage = ( }} className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border" > - {service.serverId && ( -
- -
- )}
@@ -1471,7 +1532,15 @@ const EnvironmentPage = ( -
+
+ {service.serverName && ( +
+ + + {service.serverName} + +
+ )} Created diff --git a/packages/server/src/services/environment.ts b/packages/server/src/services/environment.ts index c35862714..fb1952818 100644 --- a/packages/server/src/services/environment.ts +++ b/packages/server/src/services/environment.ts @@ -37,16 +37,38 @@ export const findEnvironmentById = async (environmentId: string) => { applications: { with: { deployments: true, + server: true, + }, + }, + mariadb: { + with: { + server: true, + }, + }, + mongo: { + with: { + server: true, + }, + }, + mysql: { + with: { + server: true, + }, + }, + postgres: { + with: { + server: true, + }, + }, + redis: { + with: { + server: true, }, }, - mariadb: true, - mongo: true, - mysql: true, - postgres: true, - redis: true, compose: { with: { deployments: true, + server: true, }, }, project: true, From 3618be65fc5c83e1bb1b216659f4ee4f2e43cf13 Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sat, 15 Nov 2025 23:54:53 -0600 Subject: [PATCH 09/11] feat: add server icon display in environment service dashboard - Introduced a server icon next to services in the environment dashboard for better visual identification of server associations. - Enhanced user experience by providing immediate visual cues regarding the server linked to each service. --- .../project/[projectId]/environment/[environmentId].tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index 17fe64ac9..a2e54ad51 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -1466,6 +1466,11 @@ const EnvironmentPage = ( }} className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border" > + {service.serverId && ( +
+ +
+ )}
From c4c193019574aab3ec0fccb31c43aa01ad799cbe Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 16 Nov 2025 15:43:46 -0600 Subject: [PATCH 10/11] fix: update database restore commands to properly quote user credentials - Modified the restore command functions for PostgreSQL, MariaDB, MySQL, and MongoDB to ensure that database user credentials are enclosed in single quotes. This change enhances command execution reliability and prevents potential issues with special characters in usernames and passwords. --- packages/server/src/utils/restore/utils.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index c46077238..23052e642 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -7,7 +7,7 @@ export const getPostgresRestoreCommand = ( database: string, databaseUser: string, ) => { - return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U ${databaseUser} -d ${database} -O --clean --if-exists"`; + return `docker exec -i $CONTAINER_ID sh -c "pg_restore -U '${databaseUser}' -d ${database} -O --clean --if-exists"`; }; export const getMariadbRestoreCommand = ( @@ -15,14 +15,14 @@ export const getMariadbRestoreCommand = ( databaseUser: string, databasePassword: string, ) => { - return `docker exec -i $CONTAINER_ID sh -c "mariadb -u ${databaseUser} -p${databasePassword} ${database}"`; + return `docker exec -i $CONTAINER_ID sh -c "mariadb -u '${databaseUser}' -p'${databasePassword}' ${database}"`; }; export const getMysqlRestoreCommand = ( database: string, databasePassword: string, ) => { - return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p${databasePassword} ${database}"`; + return `docker exec -i $CONTAINER_ID sh -c "mysql -u root -p'${databasePassword}' ${database}"`; }; export const getMongoRestoreCommand = ( @@ -30,7 +30,7 @@ export const getMongoRestoreCommand = ( databaseUser: string, databasePassword: string, ) => { - return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username ${databaseUser} --password ${databasePassword} --authenticationDatabase admin --db ${database} --archive"`; + return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`; }; export const getComposeSearchCommand = ( From 1581defc3993641e549180f06ccad121f3b3204a Mon Sep 17 00:00:00 2001 From: Mauricio Siu Date: Sun, 16 Nov 2025 21:32:23 -0600 Subject: [PATCH 11/11] feat: conditionally render TimeBadge based on cloud status - Updated the ShowProjects and side layout components to only display the TimeBadge when not in cloud mode. - Modified the TimeBadge component to remove the refetch interval for server time when in cloud mode, returning null instead. - Enhanced the server API to return null for server time in cloud environments, improving performance and avoiding unnecessary queries. --- apps/dokploy/components/dashboard/projects/show.tsx | 13 ++++++++----- apps/dokploy/components/layouts/side.tsx | 2 +- apps/dokploy/components/ui/time-badge.tsx | 4 +--- apps/dokploy/server/api/routers/server.ts | 3 +++ 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index 92f7ed5cb..5369a544e 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -14,6 +14,7 @@ import { useEffect, useMemo, useState } from "react"; 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, @@ -44,7 +45,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; import { Select, SelectContent, @@ -52,13 +52,14 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { TimeBadge } from "@/components/ui/time-badge"; import { api } from "@/utils/api"; import { HandleProject } from "./handle-project"; import { ProjectEnvironment } from "./project-environment"; -import { TimeBadge } from "@/components/ui/time-badge"; export const ShowProjects = () => { const utils = api.useUtils(); + const { data: isCloud } = api.settings.isCloud.useQuery(); const { data, isLoading } = api.project.all.useQuery(); const { data: auth } = api.user.get.useQuery(); const { mutateAsync } = api.project.remove.useMutation(); @@ -136,9 +137,11 @@ export const ShowProjects = () => { -
- -
+ {!isCloud && ( +
+ +
+ )}
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 01df80f18..7473fe586 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -1126,7 +1126,7 @@ export default function Page({ children }: Props) {
- + {!isCloud && }
)} diff --git a/apps/dokploy/components/ui/time-badge.tsx b/apps/dokploy/components/ui/time-badge.tsx index b409bf703..ea7f1f84e 100644 --- a/apps/dokploy/components/ui/time-badge.tsx +++ b/apps/dokploy/components/ui/time-badge.tsx @@ -4,9 +4,7 @@ import { useEffect, useState } from "react"; import { api } from "@/utils/api"; export function TimeBadge() { - const { data: serverTime } = api.server.getServerTime.useQuery(undefined, { - refetchInterval: 60000, // Refetch every 60 seconds - }); + const { data: serverTime } = api.server.getServerTime.useQuery(undefined); const [time, setTime] = useState(null); useEffect(() => { diff --git a/apps/dokploy/server/api/routers/server.ts b/apps/dokploy/server/api/routers/server.ts index 4eb75bdf0..8a01228f8 100644 --- a/apps/dokploy/server/api/routers/server.ts +++ b/apps/dokploy/server/api/routers/server.ts @@ -384,6 +384,9 @@ export const serverRouter = createTRPCRouter({ return ip; }), getServerTime: protectedProcedure.query(() => { + if (IS_CLOUD) { + return null; + } return { time: new Date(), timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,