From 046a608f8c02534eb1fd00b7d47bbb288ac8e628 Mon Sep 17 00:00:00 2001 From: Narciso Date: Wed, 12 Aug 2026 01:01:55 -0400 Subject: [PATCH] feat: add Overview dashboard (Services, Backups & Domains tabs) --- .../services/overview-backups-icon.test.ts | 113 +++ .../services/overview-domains-sort.test.ts | 79 ++ .../services/overview-services-sort.test.ts | 106 +++ .../volume-backups/restore-volume-backups.tsx | 18 +- .../database/backups/restore-backup.tsx | 12 +- .../overview/show-overview-backups.tsx | 317 +++++++ .../overview/show-overview-domains.tsx | 377 ++++++++ .../overview/show-overview-services.tsx | 547 ++++++++++++ .../components/icons/data-tools-icons.tsx | 9 + apps/dokploy/components/layouts/side.tsx | 9 + apps/dokploy/hooks/use-sort-preference.ts | 24 + apps/dokploy/pages/dashboard/overview.tsx | 118 +++ apps/dokploy/server/api/root.ts | 2 + apps/dokploy/server/api/routers/overview.ts | 54 ++ packages/server/src/index.ts | 1 + .../server/src/services/overview-shared.ts | 183 ++++ packages/server/src/services/overview.ts | 810 ++++++++++++++++++ 17 files changed, 2770 insertions(+), 9 deletions(-) create mode 100644 apps/dokploy/__test__/services/overview-backups-icon.test.ts create mode 100644 apps/dokploy/__test__/services/overview-domains-sort.test.ts create mode 100644 apps/dokploy/__test__/services/overview-services-sort.test.ts create mode 100644 apps/dokploy/components/dashboard/overview/show-overview-backups.tsx create mode 100644 apps/dokploy/components/dashboard/overview/show-overview-domains.tsx create mode 100644 apps/dokploy/components/dashboard/overview/show-overview-services.tsx create mode 100644 apps/dokploy/hooks/use-sort-preference.ts create mode 100644 apps/dokploy/pages/dashboard/overview.tsx create mode 100644 apps/dokploy/server/api/routers/overview.ts create mode 100644 packages/server/src/services/overview-shared.ts create mode 100644 packages/server/src/services/overview.ts diff --git a/apps/dokploy/__test__/services/overview-backups-icon.test.ts b/apps/dokploy/__test__/services/overview-backups-icon.test.ts new file mode 100644 index 000000000..ff0d06049 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-backups-icon.test.ts @@ -0,0 +1,113 @@ +import { + getBackupOverviewIcon, + getServiceOverviewIcon, +} from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +describe("getServiceOverviewIcon", () => { + test("returns a db icon for every known DB engine type", () => { + for (const type of [ + "postgres", + "mariadb", + "mysql", + "mongo", + "redis", + "libsql", + ] as const) { + expect(getServiceOverviewIcon({ type, icon: null })).toEqual({ + kind: "db", + engine: type, + }); + } + }); + + test("returns a custom icon for application/compose with a service icon set", () => { + expect( + getServiceOverviewIcon({ + type: "application", + icon: "data:image/png;base64,x", + }), + ).toEqual({ kind: "custom", url: "data:image/png;base64,x" }); + expect( + getServiceOverviewIcon({ + type: "compose", + icon: "data:image/png;base64,y", + }), + ).toEqual({ kind: "custom", url: "data:image/png;base64,y" }); + }); + + test("falls back to a generic icon for application/compose without a service icon", () => { + expect(getServiceOverviewIcon({ type: "application", icon: null })).toEqual( + { + kind: "generic", + type: "application", + }, + ); + expect(getServiceOverviewIcon({ type: "compose", icon: null })).toEqual({ + kind: "generic", + type: "compose", + }); + }); +}); + +describe("getBackupOverviewIcon", () => { + test("returns a db icon for a database backup with a known databaseType", () => { + expect( + getBackupOverviewIcon({ + databaseType: "postgres", + serviceType: null, + serviceOwnerType: "postgres", + }), + ).toEqual({ kind: "db", engine: "postgres" }); + }); + + test("returns a webServer icon for a web-server database backup", () => { + expect( + getBackupOverviewIcon({ + databaseType: "web-server", + serviceType: null, + serviceOwnerType: "web-server", + }), + ).toEqual({ kind: "webServer" }); + }); + + test("returns a db icon for a compose-type backup dumping a known DB engine", () => { + expect( + getBackupOverviewIcon({ + databaseType: "mysql", + serviceType: null, + serviceOwnerType: "compose", + }), + ).toEqual({ kind: "db", engine: "mysql" }); + }); + + test("returns a db icon for a volume backup of a known DB engine service", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "mongo", + serviceOwnerType: "mongo", + }), + ).toEqual({ kind: "db", engine: "mongo" }); + }); + + test("returns a generic application icon for a volume backup of an application", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "application", + serviceOwnerType: "application", + }), + ).toEqual({ kind: "generic", type: "application" }); + }); + + test("returns a generic compose icon for a volume backup of a compose service", () => { + expect( + getBackupOverviewIcon({ + databaseType: null, + serviceType: "compose", + serviceOwnerType: "compose", + }), + ).toEqual({ kind: "generic", type: "compose" }); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-domains-sort.test.ts b/apps/dokploy/__test__/services/overview-domains-sort.test.ts new file mode 100644 index 000000000..1e43a22d3 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-domains-sort.test.ts @@ -0,0 +1,79 @@ +import type { OverviewDomain } from "@dokploy/server/services/overview"; +import { sortOverviewDomains } from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +const makeDomain = (overrides: Partial): OverviewDomain => ({ + domainId: "id", + host: "example.com", + path: "/", + port: 3000, + customEntrypoint: null, + https: true, + certificateType: "letsencrypt", + createdAt: "2024-01-01T00:00:00.000Z", + enabled: true, + domainType: "application", + serviceOwnerId: "app", + serviceOwnerType: "application", + serviceName: "App", + projectId: "project", + projectName: "Project", + environmentId: "environment", + environmentName: "Environment", + ...overrides, +}); + +describe("sortOverviewDomains", () => { + test("sorts by createdAt asc/desc", () => { + const domains = [ + makeDomain({ domainId: "old", createdAt: "2023-01-01T00:00:00.000Z" }), + makeDomain({ domainId: "new", createdAt: "2024-06-01T00:00:00.000Z" }), + makeDomain({ domainId: "mid", createdAt: "2023-12-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewDomains(domains, "createdAt-asc").map((d) => d.domainId), + ).toEqual(["old", "mid", "new"]); + expect( + sortOverviewDomains(domains, "createdAt-desc").map((d) => d.domainId), + ).toEqual(["new", "mid", "old"]); + }); + + test("sorts by port asc/desc, with portless domains always last", () => { + const domains = [ + makeDomain({ domainId: "none", port: null }), + makeDomain({ domainId: "low", port: 80 }), + makeDomain({ domainId: "high", port: 8080 }), + ]; + + expect( + sortOverviewDomains(domains, "port-asc").map((d) => d.domainId), + ).toEqual(["low", "high", "none"]); + expect( + sortOverviewDomains(domains, "port-desc").map((d) => d.domainId), + ).toEqual(["high", "low", "none"]); + }); + + test("port sort with all domains portless is a no-op", () => { + const domains = [ + makeDomain({ domainId: "a", port: null }), + makeDomain({ domainId: "b", port: null }), + ]; + + expect( + sortOverviewDomains(domains, "port-desc").map((d) => d.domainId), + ).toEqual(["a", "b"]); + }); + + test("does not mutate the input array", () => { + const domains = [ + makeDomain({ domainId: "b", port: 8080 }), + makeDomain({ domainId: "a", port: 80 }), + ]; + const original = [...domains]; + + sortOverviewDomains(domains, "port-asc"); + + expect(domains).toEqual(original); + }); +}); diff --git a/apps/dokploy/__test__/services/overview-services-sort.test.ts b/apps/dokploy/__test__/services/overview-services-sort.test.ts new file mode 100644 index 000000000..b90bb05a8 --- /dev/null +++ b/apps/dokploy/__test__/services/overview-services-sort.test.ts @@ -0,0 +1,106 @@ +import type { OverviewService } from "@dokploy/server/services/overview"; +import { sortOverviewServices } from "@dokploy/server/services/overview"; +import { describe, expect, test } from "vitest"; + +const makeService = (overrides: Partial): OverviewService => ({ + id: "id", + type: "application", + name: "name", + appName: "app-name", + status: "running", + createdAt: "2024-01-01T00:00:00.000Z", + serverId: null, + serverName: null, + icon: null, + projectId: "project", + projectName: "Project", + environmentId: "environment", + environmentName: "Environment", + lastDeployAt: null, + ...overrides, +}); + +describe("sortOverviewServices", () => { + test("sorts by name asc/desc", () => { + const services = [ + makeService({ id: "b", name: "Bravo" }), + makeService({ id: "a", name: "Alpha" }), + makeService({ id: "c", name: "Charlie" }), + ]; + + expect(sortOverviewServices(services, "name-asc").map((s) => s.id)).toEqual( + ["a", "b", "c"], + ); + expect( + sortOverviewServices(services, "name-desc").map((s) => s.id), + ).toEqual(["c", "b", "a"]); + }); + + test("sorts by type asc/desc", () => { + const services = [ + makeService({ id: "app", type: "application" }), + makeService({ id: "pg", type: "postgres" }), + makeService({ id: "comp", type: "compose" }), + ]; + + expect(sortOverviewServices(services, "type-asc").map((s) => s.id)).toEqual( + ["app", "comp", "pg"], + ); + expect( + sortOverviewServices(services, "type-desc").map((s) => s.id), + ).toEqual(["pg", "comp", "app"]); + }); + + test("sorts by createdAt asc/desc", () => { + const services = [ + makeService({ id: "old", createdAt: "2023-01-01T00:00:00.000Z" }), + makeService({ id: "new", createdAt: "2024-06-01T00:00:00.000Z" }), + makeService({ id: "mid", createdAt: "2023-12-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewServices(services, "createdAt-asc").map((s) => s.id), + ).toEqual(["old", "mid", "new"]); + expect( + sortOverviewServices(services, "createdAt-desc").map((s) => s.id), + ).toEqual(["new", "mid", "old"]); + }); + + test("sorts by lastDeploy asc/desc, with never-deployed services always last", () => { + const services = [ + makeService({ id: "never", lastDeployAt: null }), + makeService({ id: "old", lastDeployAt: "2023-01-01T00:00:00.000Z" }), + makeService({ id: "new", lastDeployAt: "2024-06-01T00:00:00.000Z" }), + ]; + + expect( + sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id), + ).toEqual(["new", "old", "never"]); + expect( + sortOverviewServices(services, "lastDeploy-asc").map((s) => s.id), + ).toEqual(["old", "new", "never"]); + }); + + test("lastDeploy sort with all services never deployed is a no-op", () => { + const services = [ + makeService({ id: "a", lastDeployAt: null }), + makeService({ id: "b", lastDeployAt: null }), + ]; + + expect( + sortOverviewServices(services, "lastDeploy-desc").map((s) => s.id), + ).toEqual(["a", "b"]); + }); + + test("does not mutate the input array", () => { + const services = [ + makeService({ id: "b", name: "Bravo" }), + makeService({ id: "a", name: "Alpha" }), + ]; + const original = [...services]; + + sortOverviewServices(services, "name-asc"); + + expect(services).toEqual(original); + }); +}); diff --git a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx index 6732ca0d7..173d1420a 100644 --- a/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx +++ b/apps/dokploy/components/dashboard/application/volume-backups/restore-volume-backups.tsx @@ -50,6 +50,7 @@ interface Props { id: string; type: "application" | "compose"; serverId?: string; + trigger?: React.ReactNode; } const RestoreBackupSchema = z.object({ @@ -64,7 +65,12 @@ const RestoreBackupSchema = z.object({ }), }); -export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { +export const RestoreVolumeBackups = ({ + id, + type, + serverId, + trigger, +}: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(""); @@ -144,10 +150,12 @@ export const RestoreVolumeBackups = ({ id, type, serverId }: Props) => { return ( - + {trigger ?? ( + + )} diff --git a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx index 0c13bddb7..d90ef92aa 100644 --- a/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx +++ b/apps/dokploy/components/dashboard/database/backups/restore-backup.tsx @@ -74,6 +74,7 @@ interface Props { databaseType?: DatabaseType; serverId?: string | null; backupType?: "database" | "compose"; + trigger?: React.ReactNode; } const RestoreBackupSchema = z @@ -200,6 +201,7 @@ export const RestoreBackup = ({ databaseType, serverId, backupType = "database", + trigger, }: Props) => { const [isOpen, setIsOpen] = useState(false); const [search, setSearch] = useState(""); @@ -311,10 +313,12 @@ export const RestoreBackup = ({ return ( - + {trigger ?? ( + + )} diff --git a/apps/dokploy/components/dashboard/overview/show-overview-backups.tsx b/apps/dokploy/components/dashboard/overview/show-overview-backups.tsx new file mode 100644 index 000000000..c7c7cbac5 --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-backups.tsx @@ -0,0 +1,317 @@ +import type { OverviewBackup } from "@dokploy/server/services/overview-shared"; +import { getBackupOverviewIcon } from "@dokploy/server/services/overview-shared"; +import { + ArrowUpDown, + CircuitBoard, + GlobeIcon, + Loader2, + MoreHorizontal, + RefreshCw, + ServerIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { RestoreVolumeBackups } from "@/components/dashboard/application/volume-backups/restore-volume-backups"; +import { RestoreBackup } from "@/components/dashboard/database/backups/restore-backup"; +import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { api } from "@/utils/api"; + +const STATUS_VARIANT: Record = + { + done: "default", + running: "secondary", + error: "destructive", + cancelled: "destructive", + }; + +const renderRowIcon = (row: OverviewBackup) => { + const icon = getBackupOverviewIcon(row); + if (icon.kind === "db") { + const Icon = DB_ENGINE_ICONS[icon.engine as keyof typeof DB_ENGINE_ICONS]; + return Icon ? : null; + } + if (icon.kind === "webServer") { + return ; + } + return icon.type === "compose" ? ( + + ) : ( + + ); +}; + +const detailHref = (row: OverviewBackup) => { + if (!row.projectId || !row.environmentId || !row.serviceOwnerId) return null; + return `/dashboard/project/${row.projectId}/environment/${row.environmentId}/services/${row.serviceOwnerType}/${row.serviceOwnerId}`; +}; + +export const ShowOverviewBackups = () => { + const { + data: backups, + isFetching, + isFetched, + isError, + refetch, + } = api.overview.backups.useQuery(undefined, { + staleTime: Number.POSITIVE_INFINITY, + refetchOnWindowFocus: false, + }); + + const [destinationId, setDestinationId] = useState("all"); + const [serviceOwnerId, setServiceOwnerId] = useState("all"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + + const destinations = useMemo(() => { + if (!backups) return []; + const map = new Map(); + for (const row of backups) map.set(row.destinationId, row.destinationName); + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [backups]); + + const services = useMemo(() => { + if (!backups) return []; + const map = new Map(); + for (const row of backups) { + if (row.serviceOwnerId) map.set(row.serviceOwnerId, row.serviceName); + } + return Array.from(map.entries()).map(([id, name]) => ({ id, name })); + }, [backups]); + + const rows = useMemo(() => { + if (!backups) return []; + const filtered = backups.filter( + (row) => + (destinationId === "all" || row.destinationId === destinationId) && + (serviceOwnerId === "all" || row.serviceOwnerId === serviceOwnerId), + ); + const sorted = [...filtered].sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ); + return sortDirection === "asc" ? sorted : sorted.reverse(); + }, [backups, destinationId, serviceOwnerId, sortDirection]); + + return ( + +
+
+

+ Backups{" "} + {isFetched && ( + + ({rows.length}) + + )} +

+
+ {isFetched && ( + <> + + + + + )} + +
+
+ + {isFetching && !isFetched && ( +
+ + Loading backups... +
+ )} + + {isFetched && isError && ( +
+ Failed to load backups. + +
+ )} + + {isFetched && !isError && rows.length === 0 && ( +
+ No backups match the current filters. +
+ )} + + {isFetched && !isError && rows.length > 0 && ( + + + + Date + Service + Destination + Kind + Status + Actions + + + + {rows.map((row) => { + const href = detailHref(row); + const name = href ? ( + + {row.serviceName} + + ) : ( + row.serviceName + ); + const canRestoreBackup = + row.kind === "backup" && !!row.serviceOwnerId; + const canRestoreVolumeBackup = + row.kind === "volumeBackup" && + !!row.serviceOwnerId && + (row.serviceOwnerType === "application" || + row.serviceOwnerType === "compose"); + return ( + + + {new Date(row.createdAt).toLocaleString()} + + +
+ {renderRowIcon(row)} + {name} +
+
+ {row.destinationName} + + + {row.kind === "backup" ? "Database" : "Volume"} + + + + {row.status && ( + + {row.status} + + )} + + + {(canRestoreBackup || canRestoreVolumeBackup) && ( + + + + + + {canRestoreBackup && row.serviceOwnerId && ( + e.preventDefault()} + > + Restore + + } + /> + )} + {canRestoreVolumeBackup && + row.serviceOwnerId && + (row.serviceOwnerType === "application" || + row.serviceOwnerType === "compose") && ( + e.preventDefault()} + > + Restore + + } + /> + )} + + + )} + +
+ ); + })} +
+
+ )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/overview/show-overview-domains.tsx b/apps/dokploy/components/dashboard/overview/show-overview-domains.tsx new file mode 100644 index 000000000..23348e0ff --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-domains.tsx @@ -0,0 +1,377 @@ +import type { OverviewDomainSortBy } from "@dokploy/server/services/overview-shared"; +import { sortOverviewDomains } from "@dokploy/server/services/overview-shared"; +import { ExternalLink, Loader2 } from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useSortPreference } from "@/hooks/use-sort-preference"; +import { api } from "@/utils/api"; + +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; + +const SORT_OPTIONS: { value: OverviewDomainSortBy; label: string }[] = [ + { value: "createdAt-desc", label: "Newest first" }, + { value: "createdAt-asc", label: "Oldest first" }, + { value: "port-asc", label: "Port (low-high)" }, + { value: "port-desc", label: "Port (high-low)" }, +]; + +const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value); + +export const ShowOverviewDomains = () => { + const utils = api.useUtils(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canToggleDomain = permissions?.domain.create ?? false; + + const { data: domains, isLoading } = api.overview.domains.useQuery(); + const { data: allProjects } = api.project.all.useQuery(); + + const { mutateAsync: toggleEnable, isPending: isToggling } = + api.domain.toggleEnable.useMutation(); + + const [selectedProjectId, setSelectedProjectId] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [selectedSsl, setSelectedSsl] = useState("all"); + const [selectedPort, setSelectedPort] = useState("all"); + const [sortBy, setSort] = useSortPreference( + "overviewDomainsSort", + "createdAt-desc", + SORT_VALUES, + ); + + const handleToggleEnable = async ( + domain: NonNullable[number], + ) => { + try { + const result = await toggleEnable({ domainId: domain.domainId }); + utils.overview.domains.invalidate(); + toast.success(result.enabled ? "Domain enabled" : "Domain disabled"); + } catch { + toast.error(`Error updating "${domain.host}"`); + } + }; + + const availablePorts = useMemo(() => { + if (!domains) return []; + const ports = new Set(); + for (const domain of domains) { + if (domain.port !== null) ports.add(domain.port); + } + return Array.from(ports).sort((a, b) => a - b); + }, [domains]); + + const filteredDomains = useMemo(() => { + if (!domains) return []; + const filtered = domains.filter( + (domain) => + (selectedProjectId === "all" || + domain.projectId === selectedProjectId) && + (selectedStatus === "all" || + (selectedStatus === "enabled" ? domain.enabled : !domain.enabled)) && + (selectedSsl === "all" || + (selectedSsl === "https" ? domain.https : !domain.https)) && + (selectedPort === "all" || domain.port === Number(selectedPort)), + ); + return sortOverviewDomains(filtered, sortBy); + }, [ + domains, + selectedProjectId, + selectedStatus, + selectedSsl, + selectedPort, + sortBy, + ]); + + const [pageSize, setPageSize] = useState(50); + const [pageIndex, setPageIndex] = useState(0); + const pageCount = Math.max(1, Math.ceil(filteredDomains.length / pageSize)); + const currentPageIndex = Math.min(pageIndex, pageCount - 1); + const pagedDomains = filteredDomains.slice( + currentPageIndex * pageSize, + currentPageIndex * pageSize + pageSize, + ); + + return ( + +
+
+

+ Domains{" "} + + ({filteredDomains.length}) + +

+
+ + + + {availablePorts.length > 0 && ( + + )} + +
+
+ + {isLoading && ( +
+ + Loading domains... +
+ )} + + {!isLoading && filteredDomains.length === 0 && ( +
+ No domains match the current filters. +
+ )} + + {!isLoading && filteredDomains.length > 0 && ( + <> + + + + Service + Host + Path + Port + Entrypoint + Protocol + Certificate + Created + Status + + + + {pagedDomains.map((domain) => { + const href = `/dashboard/project/${domain.projectId}/environment/${domain.environmentId}/services/${domain.serviceOwnerType}/${domain.serviceOwnerId}`; + return ( + + + + + {domain.serviceName} + + + {domain.projectName} / {domain.environmentName} + + + + + + {domain.host} + + + + + + {domain.path || "/"} + + + + {domain.port} + + + {domain.customEntrypoint ? ( + + {domain.customEntrypoint} + + ) : ( + — + )} + + + + {domain.https ? "HTTPS" : "HTTP"} + + + + + {domain.certificateType} + + + + + + + {canToggleDomain ? ( + + + +
+ + handleToggleEnable(domain) + } + disabled={isToggling} + /> +
+
+ +

+ {domain.enabled + ? "Domain is active. Toggle to disable routing without deleting it." + : "Domain is disabled and not routed. Toggle to enable it again."} +

+
+
+
+ ) : ( + + {domain.enabled ? "Enabled" : "Disabled"} + + )} +
+
+ ); + })} +
+
+ +
+ + {filteredDomains.length}{" "} + {filteredDomains.length === 1 ? "domain" : "domains"} total + +
+
+ Rows per page + +
+ + Page {currentPageIndex + 1} of {pageCount} + +
+ + +
+
+
+ + )} +
+
+ ); +}; diff --git a/apps/dokploy/components/dashboard/overview/show-overview-services.tsx b/apps/dokploy/components/dashboard/overview/show-overview-services.tsx new file mode 100644 index 000000000..30f913319 --- /dev/null +++ b/apps/dokploy/components/dashboard/overview/show-overview-services.tsx @@ -0,0 +1,547 @@ +import type { + OverviewServiceType, + OverviewSortBy, +} from "@dokploy/server/services/overview-shared"; +import { sortOverviewServices } from "@dokploy/server/services/overview-shared"; +import { + Ban, + CircuitBoard, + GlobeIcon, + Loader2, + MoreHorizontal, + RefreshCw, + Search, + ServerIcon, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { toast } from "sonner"; +import { DB_ENGINE_ICONS } from "@/components/icons/data-tools-icons"; +import { DateTooltip } from "@/components/shared/date-tooltip"; +import { DialogAction } from "@/components/shared/dialog-action"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { useSortPreference } from "@/hooks/use-sort-preference"; +import { api } from "@/utils/api"; + +const PAGE_SIZE_OPTIONS = [25, 50, 100, 200]; + +const TYPE_LABELS = { + application: "Application", + postgres: "PostgreSQL", + mariadb: "MariaDB", + mongo: "MongoDB", + mysql: "MySQL", + redis: "Redis", + compose: "Compose", + libsql: "Libsql", +} satisfies Record; + +const STATUS_OPTIONS = ["running", "idle", "done", "error"]; + +// "done" is the steady live state (green); "running" only holds mid-deploy (yellow) — relabeled to match what users expect. +const STATUS_LABELS: Record = { + running: "Deploying", + idle: "Idle", + done: "Running", + error: "Error", +}; + +const SORT_OPTIONS: { value: OverviewSortBy; label: string }[] = [ + { value: "lastDeploy-desc", label: "Recently deployed" }, + { value: "lastDeploy-asc", label: "Oldest deployed" }, + { value: "createdAt-desc", label: "Newest first" }, + { value: "createdAt-asc", label: "Oldest first" }, + { value: "name-asc", label: "Name (A-Z)" }, + { value: "name-desc", label: "Name (Z-A)" }, + { value: "type-asc", label: "Type (A-Z)" }, + { value: "type-desc", label: "Type (Z-A)" }, +]; + +const SORT_VALUES = SORT_OPTIONS.map((opt) => opt.value); + +// libsql has no deploy/stop actions, same as environment/[environmentId].tsx. +const idKeyByType: Record = { + application: "applicationId", + compose: "composeId", + postgres: "postgresId", + mysql: "mysqlId", + mariadb: "mariadbId", + redis: "redisId", + mongo: "mongoId", +}; + +export const ShowOverviewServices = () => { + const utils = api.useUtils(); + const { + data: services, + isLoading, + refetch, + } = api.overview.services.useQuery(); + const { data: allProjects } = api.project.all.useQuery(); + + const [searchQuery, setSearchQuery] = useState(""); + const [selectedProjectId, setSelectedProjectId] = useState("all"); + const [selectedType, setSelectedType] = useState("all"); + const [selectedStatus, setSelectedStatus] = useState("all"); + const [selectedServerId, setSelectedServerId] = useState("all"); + const [sortBy, setSort] = useSortPreference( + "overviewServicesSort", + "lastDeploy-desc", + SORT_VALUES, + ); + + const applicationActions = { + deploy: api.application.deploy.useMutation(), + stop: api.application.stop.useMutation(), + }; + const composeActions = { + deploy: api.compose.deploy.useMutation(), + stop: api.compose.stop.useMutation(), + }; + const postgresActions = { + deploy: api.postgres.deploy.useMutation(), + stop: api.postgres.stop.useMutation(), + }; + const mysqlActions = { + deploy: api.mysql.deploy.useMutation(), + stop: api.mysql.stop.useMutation(), + }; + const mariadbActions = { + deploy: api.mariadb.deploy.useMutation(), + stop: api.mariadb.stop.useMutation(), + }; + const redisActions = { + deploy: api.redis.deploy.useMutation(), + stop: api.redis.stop.useMutation(), + }; + const mongoActions = { + deploy: api.mongo.deploy.useMutation(), + stop: api.mongo.stop.useMutation(), + }; + + const actionsByType: Record< + string, + { + deploy: { mutateAsync: (input: any) => Promise }; + stop: { mutateAsync: (input: any) => Promise }; + } + > = { + application: applicationActions, + compose: composeActions, + postgres: postgresActions, + mysql: mysqlActions, + mariadb: mariadbActions, + redis: redisActions, + mongo: mongoActions, + }; + + const handleAction = async ( + service: NonNullable[number], + action: "deploy" | "stop", + ) => { + const actions = actionsByType[service.type]; + const idKey = idKeyByType[service.type]; + if (!actions || !idKey) return; + + const labels = + action === "deploy" + ? { + loading: "Deploying", + success: "queued for deployment", + error: "deploying", + } + : { loading: "Stopping", success: "stopped", error: "stopping" }; + + toast.promise( + (async () => { + await actions[action].mutateAsync({ [idKey]: service.id }); + })(), + { + loading: `${labels.loading} ${service.name}...`, + success: () => { + utils.overview.services.invalidate(); + return `${service.name} ${labels.success} successfully`; + }, + error: (error) => + `Error ${labels.error} ${service.name}: ${error instanceof Error ? error.message : "Unknown error"}`, + }, + ); + }; + + const availableServers = useMemo(() => { + if (!services) return []; + const map = new Map(); + for (const service of services) { + if (service.serverId && service.serverName) { + map.set(service.serverId, service.serverName); + } + } + return Array.from(map.entries()).map(([serverId, serverName]) => ({ + serverId, + serverName, + })); + }, [services]); + + const hasServicesWithoutServer = useMemo( + () => !!services?.some((service) => !service.serverId), + [services], + ); + + const filteredServices = useMemo(() => { + if (!services) return []; + const filtered = services.filter( + (service) => + service.name.toLowerCase().includes(searchQuery.toLowerCase()) && + (selectedProjectId === "all" || + service.projectId === selectedProjectId) && + (selectedType === "all" || service.type === selectedType) && + (selectedStatus === "all" || service.status === selectedStatus) && + (selectedServerId === "all" || + (selectedServerId === "dokploy-server" && !service.serverId) || + service.serverId === selectedServerId), + ); + return sortOverviewServices(filtered, sortBy); + }, [ + services, + searchQuery, + selectedProjectId, + selectedType, + selectedStatus, + selectedServerId, + sortBy, + ]); + + // pageIndex is clamped against the filtered count each render instead of reset via an effect. + const [pageSize, setPageSize] = useState(50); + const [pageIndex, setPageIndex] = useState(0); + const pageCount = Math.max(1, Math.ceil(filteredServices.length / pageSize)); + const currentPageIndex = Math.min(pageIndex, pageCount - 1); + const pagedServices = filteredServices.slice( + currentPageIndex * pageSize, + currentPageIndex * pageSize + pageSize, + ); + + const renderIcon = (service: NonNullable[number]) => { + if (service.type in DB_ENGINE_ICONS) { + const Icon = + DB_ENGINE_ICONS[service.type as keyof typeof DB_ENGINE_ICONS]; + return ; + } + if (service.icon) { + return ( + {service.name} + ); + } + return service.type === "compose" ? ( + + ) : ( + + ); + }; + + return ( + +
+
+

+ Services{" "} + + ({filteredServices.length}) + +

+
+
+ setSearchQuery(e.target.value)} + className="pr-9 w-[200px]" + /> + +
+ + + + {(availableServers.length > 0 || hasServicesWithoutServer) && ( + + )} + +
+
+ + {isLoading && ( +
+ + Loading services... +
+ )} + + {!isLoading && filteredServices.length === 0 && ( +
+ No services match the current filters. +
+ )} + + {!isLoading && filteredServices.length > 0 && ( + <> + + + + Service + Type + Status + Server + Created + Last Deploy + Actions + + + + {pagedServices.map((service) => { + const href = `/dashboard/project/${service.projectId}/environment/${service.environmentId}/services/${service.type}/${service.id}`; + const hasActions = service.type in actionsByType; + return ( + + + + {renderIcon(service)} +
+ + {service.name} + + + {service.projectName} / {service.environmentName} + +
+ +
+ {TYPE_LABELS[service.type]} + + + + +
+ + + {service.serverName ?? "Dokploy server"} + +
+
+ + + + + {service.lastDeployAt ? ( + + ) : ( + — + )} + + + {hasActions && ( + + + + + + + {service.name} + + handleAction(service, "deploy")} + > + e.preventDefault()} + > + + Deploy + + + handleAction(service, "stop")} + > + e.preventDefault()} + > + + Stop + + + + + )} + +
+ ); + })} +
+
+ +
+ + {filteredServices.length}{" "} + {filteredServices.length === 1 ? "service" : "services"} total + +
+
+ Rows per page + +
+ + Page {currentPageIndex + 1} of {pageCount} + +
+ + +
+
+
+ + )} +
+
+ ); +}; diff --git a/apps/dokploy/components/icons/data-tools-icons.tsx b/apps/dokploy/components/icons/data-tools-icons.tsx index 7a0d680aa..dc5dc5ee0 100644 --- a/apps/dokploy/components/icons/data-tools-icons.tsx +++ b/apps/dokploy/components/icons/data-tools-icons.tsx @@ -211,6 +211,15 @@ export const LibsqlIcon = ({ className }: Props) => { ); }; +export const DB_ENGINE_ICONS = { + postgres: PostgresqlIcon, + mariadb: MariadbIcon, + mysql: MysqlIcon, + mongo: MongodbIcon, + redis: RedisIcon, + libsql: LibsqlIcon, +} as const; + export const GitlabIcon = ({ className }: Props) => { return ( !!permissions?.docker.read, }, + { + isSingle: true, + title: "Overview", + url: "/dashboard/overview", + icon: LayoutGrid, + // Only enabled for users with access to services + isEnabled: ({ permissions }) => !!permissions?.service.read, + }, { isSingle: true, title: "Requests", diff --git a/apps/dokploy/hooks/use-sort-preference.ts b/apps/dokploy/hooks/use-sort-preference.ts new file mode 100644 index 000000000..12d45b785 --- /dev/null +++ b/apps/dokploy/hooks/use-sort-preference.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; + +// Initializes from defaultValue (not localStorage) to avoid an SSR/client hydration mismatch, then syncs from localStorage after mount. +export const useSortPreference = ( + key: string, + defaultValue: T, + allowedValues: readonly T[], +) => { + const [value, setValue] = useState(defaultValue); + + useEffect(() => { + const stored = localStorage.getItem(key); + if (stored && (allowedValues as readonly string[]).includes(stored)) { + setValue(stored as T); + } + }, [key]); + + const setPreference = (next: T) => { + setValue(next); + localStorage.setItem(key, next); + }; + + return [value, setPreference] as const; +}; diff --git a/apps/dokploy/pages/dashboard/overview.tsx b/apps/dokploy/pages/dashboard/overview.tsx new file mode 100644 index 000000000..153c73a56 --- /dev/null +++ b/apps/dokploy/pages/dashboard/overview.tsx @@ -0,0 +1,118 @@ +import { validateRequest } from "@dokploy/server/lib/auth"; +import { createServerSideHelpers } from "@trpc/react-query/server"; +import type { GetServerSidePropsContext } from "next"; +import { useRouter } from "next/router"; +import type { ReactElement } from "react"; +import superjson from "superjson"; +import { ShowOverviewBackups } from "@/components/dashboard/overview/show-overview-backups"; +import { ShowOverviewDomains } from "@/components/dashboard/overview/show-overview-domains"; +import { ShowOverviewServices } from "@/components/dashboard/overview/show-overview-services"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { appRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +const DEFAULT_TAB = "services"; + +const Overview = () => { + const router = useRouter(); + const { data: permissions } = api.user.getPermissions.useQuery(); + const canSeeBackups = + !!permissions?.backup.read && !!permissions?.volumeBackup.read; + const canSeeDomains = !!permissions?.domain.read; + + const queryTab = + typeof router.query.tab === "string" ? router.query.tab : DEFAULT_TAB; + const activeTab = + (queryTab === "backups" && !canSeeBackups) || + (queryTab === "domains" && !canSeeDomains) + ? DEFAULT_TAB + : queryTab; + + const setTab = (value: string) => { + const { tab: _current, ...query } = router.query; + router.replace( + { + pathname: router.pathname, + query: value === DEFAULT_TAB ? query : { ...query, tab: value }, + }, + undefined, + { shallow: true }, + ); + }; + + return ( + + + Services + {canSeeBackups && Backups} + {canSeeDomains && Domains} + + + + + {canSeeBackups && ( + + + + )} + {canSeeDomains && ( + + + + )} + + ); +}; + +export default Overview; + +Overview.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const { user, session } = await validateRequest(ctx.req); + if (!user) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + const { req, res } = ctx; + + const helpers = createServerSideHelpers({ + router: appRouter, + ctx: { + req: req as any, + res: res as any, + db: null as any, + session: session as any, + user: user as any, + }, + transformer: superjson, + }); + try { + const userPermissions = await helpers.user.getPermissions.fetch(); + + if (!userPermissions?.service.read) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + return { + props: { + trpcState: helpers.dehydrate(), + }, + }; + } catch { + return { + props: {}, + }; + } +} diff --git a/apps/dokploy/server/api/root.ts b/apps/dokploy/server/api/root.ts index ea9aed210..ecd67439f 100644 --- a/apps/dokploy/server/api/root.ts +++ b/apps/dokploy/server/api/root.ts @@ -28,6 +28,7 @@ import { mysqlRouter } from "./routers/mysql"; import { networkRouter } from "./routers/network"; import { notificationRouter } from "./routers/notification"; import { organizationRouter } from "./routers/organization"; +import { overviewRouter } from "./routers/overview"; import { patchRouter } from "./routers/patch"; import { portRouter } from "./routers/port"; import { postgresRouter } from "./routers/postgres"; @@ -118,6 +119,7 @@ export const appRouter = createTRPCRouter({ environment: environmentRouter, tag: tagRouter, patch: patchRouter, + overview: overviewRouter, }); // export type definition of API diff --git a/apps/dokploy/server/api/routers/overview.ts b/apps/dokploy/server/api/routers/overview.ts new file mode 100644 index 000000000..88e6e79df --- /dev/null +++ b/apps/dokploy/server/api/routers/overview.ts @@ -0,0 +1,54 @@ +import { + getAllBackupsForOrganization, + getAllDomainsForOrganization, + getAllServicesForOrganization, +} from "@dokploy/server"; +import { + findMemberByUserId, + hasPermission, +} from "@dokploy/server/services/permission"; +import { TRPCError } from "@trpc/server"; +import { createTRPCRouter, protectedProcedure, withPermission } from "../trpc"; + +export const overviewRouter = createTRPCRouter({ + services: withPermission("service", "read").query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + const accessedServices = + ctx.user.role !== "owner" && ctx.user.role !== "admin" + ? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices + : null; + return getAllServicesForOrganization(orgId, accessedServices); + }), + + // Reads backup and/or volumeBackup run history depending on which the user can see. + backups: protectedProcedure.query(async ({ ctx }) => { + const [canReadBackups, canReadVolumeBackups] = await Promise.all([ + hasPermission(ctx, { backup: ["read"] }), + hasPermission(ctx, { volumeBackup: ["read"] }), + ]); + if (!canReadBackups && !canReadVolumeBackups) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "You don't have access to backups or volume backups", + }); + } + const orgId = ctx.session.activeOrganizationId; + const accessedServices = + ctx.user.role !== "owner" && ctx.user.role !== "admin" + ? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices + : null; + return getAllBackupsForOrganization(orgId, accessedServices, { + backup: canReadBackups, + volumeBackup: canReadVolumeBackups, + }); + }), + + domains: withPermission("domain", "read").query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + const accessedServices = + ctx.user.role !== "owner" && ctx.user.role !== "admin" + ? (await findMemberByUserId(ctx.user.id, orgId)).accessedServices + : null; + return getAllDomainsForOrganization(orgId, accessedServices); + }), +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index b2a0c7f12..c3ce7c040 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -33,6 +33,7 @@ export * from "./services/mount"; export * from "./services/mysql"; export * from "./services/network"; export * from "./services/notification"; +export * from "./services/overview"; export * from "./services/patch"; export * from "./services/patch-repo"; export * from "./services/port"; diff --git a/packages/server/src/services/overview-shared.ts b/packages/server/src/services/overview-shared.ts new file mode 100644 index 000000000..1250c82e1 --- /dev/null +++ b/packages/server/src/services/overview-shared.ts @@ -0,0 +1,183 @@ +// No db/schema imports here — client components import this file directly, and dockerode/ssh2 (child_process) would break the browser bundle. + +export type OverviewServiceType = + | "application" + | "postgres" + | "mysql" + | "mariadb" + | "mongo" + | "redis" + | "compose" + | "libsql"; + +export interface OverviewService { + id: string; + type: OverviewServiceType; + name: string; + appName: string; + status: string | null; + createdAt: string; + serverId: string | null; + serverName: string | null; + icon: string | null; + projectId: string; + projectName: string; + environmentId: string; + environmentName: string; + lastDeployAt: string | null; +} + +export type OverviewSortBy = + | "name-asc" + | "name-desc" + | "type-asc" + | "type-desc" + | "createdAt-asc" + | "createdAt-desc" + | "lastDeploy-asc" + | "lastDeploy-desc"; + +export const sortOverviewServices = ( + services: OverviewService[], + sortBy: OverviewSortBy, +): OverviewService[] => { + const [field, direction] = sortBy.split("-") as [string, "asc" | "desc"]; + return [...services].sort((a, b) => { + if (field === "name") { + const cmp = a.name.localeCompare(b.name); + return direction === "asc" ? cmp : -cmp; + } + if (field === "type") { + const cmp = a.type.localeCompare(b.type); + return direction === "asc" ? cmp : -cmp; + } + if (field === "createdAt") { + const cmp = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + return direction === "asc" ? cmp : -cmp; + } + // lastDeploy: services without a deploy always sort to the end. + const aDate = a.lastDeployAt ? new Date(a.lastDeployAt).getTime() : null; + const bDate = b.lastDeployAt ? new Date(b.lastDeployAt).getTime() : null; + if (aDate === null && bDate === null) return 0; + if (aDate === null) return 1; + if (bDate === null) return -1; + return direction === "desc" ? bDate - aDate : aDate - bDate; + }); +}; + +export const DB_ENGINE_ICON_TYPES = new Set([ + "postgres", + "mariadb", + "mysql", + "mongo", + "redis", + "libsql", +]); + +export const getServiceOverviewIcon = (service: { + type: OverviewServiceType; + icon: string | null; +}): + | { kind: "db"; engine: string } + | { kind: "custom"; url: string } + | { kind: "generic"; type: "application" | "compose" } => { + if (DB_ENGINE_ICON_TYPES.has(service.type)) { + return { kind: "db", engine: service.type }; + } + if (service.icon) { + return { kind: "custom", url: service.icon }; + } + return { kind: "generic", type: service.type as "application" | "compose" }; +}; + +export type OverviewBackupKind = "backup" | "volumeBackup"; + +export interface OverviewBackup { + deploymentId: string; + kind: OverviewBackupKind; + status: string | null; + createdAt: string; + destinationId: string; + destinationName: string; + databaseType: string | null; + serviceType: string | null; + backupType: "database" | "compose" | null; + serviceName: string; + serviceOwnerId: string | null; + serviceOwnerType: OverviewServiceType | "web-server"; + serverId: string | null; + projectId: string | null; + environmentId: string | null; +} + +export type OverviewDomainOwnerType = "application" | "compose"; + +export interface OverviewDomain { + domainId: string; + host: string; + path: string | null; + port: number | null; + customEntrypoint: string | null; + https: boolean; + certificateType: "letsencrypt" | "none" | "custom"; + createdAt: string; + enabled: boolean; + domainType: "application" | "compose" | "preview" | null; + serviceOwnerId: string; + serviceOwnerType: OverviewDomainOwnerType; + serviceName: string; + projectId: string; + projectName: string; + environmentId: string; + environmentName: string; +} + +export type OverviewDomainSortBy = + | "createdAt-asc" + | "createdAt-desc" + | "port-asc" + | "port-desc"; + +export const sortOverviewDomains = ( + domains: OverviewDomain[], + sortBy: OverviewDomainSortBy, +): OverviewDomain[] => { + const [field, direction] = sortBy.split("-") as [string, "asc" | "desc"]; + return [...domains].sort((a, b) => { + if (field === "port") { + // Domains without a port sort to the end regardless of direction. + const aPort = a.port; + const bPort = b.port; + if (aPort === null && bPort === null) return 0; + if (aPort === null) return 1; + if (bPort === null) return -1; + return direction === "asc" ? aPort - bPort : bPort - aPort; + } + const cmp = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + return direction === "asc" ? cmp : -cmp; + }); +}; + +export const getBackupOverviewIcon = ( + row: Pick< + OverviewBackup, + "databaseType" | "serviceType" | "serviceOwnerType" + >, +): + | { kind: "db"; engine: string } + | { kind: "generic"; type: "application" | "compose" } + | { kind: "webServer" } => { + const engine = row.databaseType ?? row.serviceType; + if (engine === "web-server") { + return { kind: "webServer" }; + } + if (engine && DB_ENGINE_ICON_TYPES.has(engine)) { + return { kind: "db", engine }; + } + if (row.serviceOwnerType === "compose") { + return { kind: "generic", type: "compose" }; + } + return { kind: "generic", type: "application" }; +}; diff --git a/packages/server/src/services/overview.ts b/packages/server/src/services/overview.ts new file mode 100644 index 000000000..c1800c399 --- /dev/null +++ b/packages/server/src/services/overview.ts @@ -0,0 +1,810 @@ +import { db } from "@dokploy/server/db"; +import { + applications, + backups, + compose, + deployments, + destinations, + domains, + environments, + libsql, + mariadb, + mongo, + mysql, + postgres, + previewDeployments, + projects, + redis, + server as serverTable, + volumeBackups, +} from "@dokploy/server/db/schema"; +import { and, desc, eq, inArray, isNull, max, or } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import type { + OverviewBackup, + OverviewDomain, + OverviewDomainOwnerType, + OverviewService, + OverviewServiceType, +} from "./overview-shared"; + +// Re-exported for existing "./overview" consumers; client components should import overview-shared directly. +export * from "./overview-shared"; + +type TypeQueryConfig = { + type: OverviewServiceType; + table: + | typeof applications + | typeof postgres + | typeof mysql + | typeof mariadb + | typeof mongo + | typeof redis + | typeof compose + | typeof libsql; + idColumn: { name: string }; + statusColumn: { name: string }; + hasIcon: boolean; +}; + +async function getServicesOfType( + config: TypeQueryConfig, + orgId: string, + accessedServices: string[] | null, +): Promise { + const table = config.table as typeof applications; + const idCol = (table as unknown as Record)[ + config.idColumn.name + ] as typeof applications.applicationId; + const statusCol = (table as unknown as Record)[ + config.statusColumn.name + ] as typeof applications.applicationStatus; + const iconCol = config.hasIcon + ? ((table as unknown as Record) + .icon as typeof applications.icon) + : null; + + const conditions = [ + eq(projects.organizationId, orgId), + ...(accessedServices !== null ? [inArray(idCol, accessedServices)] : []), + ]; + + const baseSelect = { + id: idCol, + name: table.name, + appName: table.appName, + status: statusCol, + createdAt: table.createdAt, + serverId: table.serverId, + serverName: serverTable.name, + projectId: projects.projectId, + projectName: projects.name, + environmentId: environments.environmentId, + environmentName: environments.name, + }; + + // Only select icon when the table has that column — other service tables don't. + const rows = await db + .select(iconCol ? { ...baseSelect, icon: iconCol } : baseSelect) + .from(table) + .innerJoin( + environments, + eq(table.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .leftJoin(serverTable, eq(table.serverId, serverTable.serverId)) + .where(and(...conditions)); + + return rows.map((row) => ({ + ...row, + icon: "icon" in row ? (row.icon as string | null) : null, + type: config.type, + lastDeployAt: null, + })); +} + +const SERVICE_TYPE_CONFIGS: TypeQueryConfig[] = [ + { + type: "application", + table: applications, + idColumn: { name: "applicationId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: true, + }, + { + type: "postgres", + table: postgres, + idColumn: { name: "postgresId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: false, + }, + { + type: "mysql", + table: mysql, + idColumn: { name: "mysqlId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: false, + }, + { + type: "mariadb", + table: mariadb, + idColumn: { name: "mariadbId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: false, + }, + { + type: "mongo", + table: mongo, + idColumn: { name: "mongoId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: false, + }, + { + type: "redis", + table: redis, + idColumn: { name: "redisId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: false, + }, + { + type: "compose", + table: compose, + idColumn: { name: "composeId" }, + statusColumn: { name: "composeStatus" }, + hasIcon: true, + }, + { + type: "libsql", + table: libsql, + idColumn: { name: "libsqlId" }, + statusColumn: { name: "applicationStatus" }, + hasIcon: false, + }, +]; + +// Only application/compose have a deployments FK, so lastDeployAt only applies to those. +async function attachLastDeployAt( + services: OverviewService[], +): Promise { + const applicationIds = services + .filter((s) => s.type === "application") + .map((s) => s.id); + const composeIds = services + .filter((s) => s.type === "compose") + .map((s) => s.id); + + const [appDeploys, composeDeploys] = await Promise.all([ + applicationIds.length > 0 + ? db + .select({ + applicationId: deployments.applicationId, + lastDeployAt: max(deployments.createdAt), + }) + .from(deployments) + .where(inArray(deployments.applicationId, applicationIds)) + .groupBy(deployments.applicationId) + : Promise.resolve([]), + composeIds.length > 0 + ? db + .select({ + composeId: deployments.composeId, + lastDeployAt: max(deployments.createdAt), + }) + .from(deployments) + .where(inArray(deployments.composeId, composeIds)) + .groupBy(deployments.composeId) + : Promise.resolve([]), + ]); + + const lastAppDeploy = new Map(); + for (const row of appDeploys) { + if (row.applicationId && row.lastDeployAt) { + lastAppDeploy.set(row.applicationId, row.lastDeployAt); + } + } + const lastComposeDeploy = new Map(); + for (const row of composeDeploys) { + if (row.composeId && row.lastDeployAt) { + lastComposeDeploy.set(row.composeId, row.lastDeployAt); + } + } + + return services.map((service) => { + if (service.type === "application") { + return { + ...service, + lastDeployAt: lastAppDeploy.get(service.id) ?? null, + }; + } + if (service.type === "compose") { + return { + ...service, + lastDeployAt: lastComposeDeploy.get(service.id) ?? null, + }; + } + return service; + }); +} + +export const getAllServicesForOrganization = async ( + orgId: string, + accessedServices: string[] | null, +): Promise => { + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + + const results = await Promise.all( + SERVICE_TYPE_CONFIGS.map((config) => + getServicesOfType(config, orgId, accessedServices), + ), + ); + + return attachLastDeployAt(results.flat()); +}; + +type Owner = { + id: string; + name: string; + serverId: string | null; + type: OverviewServiceType; + projectId: string | null; + environmentId: string | null; +}; + +type OwnerRow = { + name: string; + serverId: string | null; + environment?: { + environmentId: string; + project?: { projectId: string } | null; + } | null; +}; + +function ownerFrom( + row: OwnerRow, + id: string, + type: OverviewServiceType, +): Owner { + return { + id, + name: row.name, + serverId: row.serverId, + type, + projectId: row.environment?.project?.projectId ?? null, + environmentId: row.environment?.environmentId ?? null, + }; +} + +function pickBackupOwner(backup: { + postgres?: (OwnerRow & { postgresId: string }) | null; + mariadb?: (OwnerRow & { mariadbId: string }) | null; + mysql?: (OwnerRow & { mysqlId: string }) | null; + mongo?: (OwnerRow & { mongoId: string }) | null; + libsql?: (OwnerRow & { libsqlId: string }) | null; + compose?: (OwnerRow & { composeId: string }) | null; +}): Owner | null { + if (backup.postgres) + return ownerFrom(backup.postgres, backup.postgres.postgresId, "postgres"); + if (backup.mariadb) + return ownerFrom(backup.mariadb, backup.mariadb.mariadbId, "mariadb"); + if (backup.mysql) + return ownerFrom(backup.mysql, backup.mysql.mysqlId, "mysql"); + if (backup.mongo) + return ownerFrom(backup.mongo, backup.mongo.mongoId, "mongo"); + if (backup.libsql) + return ownerFrom(backup.libsql, backup.libsql.libsqlId, "libsql"); + if (backup.compose) + return ownerFrom(backup.compose, backup.compose.composeId, "compose"); + return null; +} + +function pickVolumeBackupOwner(volumeBackup: { + application?: (OwnerRow & { applicationId: string }) | null; + postgres?: (OwnerRow & { postgresId: string }) | null; + mariadb?: (OwnerRow & { mariadbId: string }) | null; + mysql?: (OwnerRow & { mysqlId: string }) | null; + mongo?: (OwnerRow & { mongoId: string }) | null; + redis?: (OwnerRow & { redisId: string }) | null; + libsql?: (OwnerRow & { libsqlId: string }) | null; + compose?: (OwnerRow & { composeId: string }) | null; +}): Owner | null { + if (volumeBackup.application) + return ownerFrom( + volumeBackup.application, + volumeBackup.application.applicationId, + "application", + ); + if (volumeBackup.postgres) + return ownerFrom( + volumeBackup.postgres, + volumeBackup.postgres.postgresId, + "postgres", + ); + if (volumeBackup.mariadb) + return ownerFrom( + volumeBackup.mariadb, + volumeBackup.mariadb.mariadbId, + "mariadb", + ); + if (volumeBackup.mysql) + return ownerFrom(volumeBackup.mysql, volumeBackup.mysql.mysqlId, "mysql"); + if (volumeBackup.mongo) + return ownerFrom(volumeBackup.mongo, volumeBackup.mongo.mongoId, "mongo"); + if (volumeBackup.redis) + return ownerFrom(volumeBackup.redis, volumeBackup.redis.redisId, "redis"); + if (volumeBackup.libsql) + return ownerFrom( + volumeBackup.libsql, + volumeBackup.libsql.libsqlId, + "libsql", + ); + if (volumeBackup.compose) + return ownerFrom( + volumeBackup.compose, + volumeBackup.compose.composeId, + "compose", + ); + return null; +} + +// Ownerless backups (e.g. the Dokploy host's own database) are visible to everyone. +function ownerAccessCondition( + ownerColumns: AnyPgColumn[], + accessedServices: string[] | null, +) { + if (accessedServices === null) return undefined; + return or( + and(...ownerColumns.map((col) => isNull(col))), + ...ownerColumns.map((col) => inArray(col, accessedServices)), + ); +} + +async function getBackupIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise { + const accessCondition = ownerAccessCondition( + [ + backups.postgresId, + backups.mariadbId, + backups.mysqlId, + backups.mongoId, + backups.libsqlId, + backups.composeId, + ], + accessedServices, + ); + + const rows = await db + .select({ backupId: backups.backupId }) + .from(backups) + .innerJoin( + destinations, + eq(backups.destinationId, destinations.destinationId), + ) + .where( + and( + eq(destinations.organizationId, orgId), + ...(accessCondition ? [accessCondition] : []), + ), + ); + return rows.map((r) => r.backupId); +} + +async function getVolumeBackupIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise { + const accessCondition = ownerAccessCondition( + [ + volumeBackups.applicationId, + volumeBackups.postgresId, + volumeBackups.mariadbId, + volumeBackups.mysqlId, + volumeBackups.mongoId, + volumeBackups.redisId, + volumeBackups.libsqlId, + volumeBackups.composeId, + ], + accessedServices, + ); + + const rows = await db + .select({ volumeBackupId: volumeBackups.volumeBackupId }) + .from(volumeBackups) + .innerJoin( + destinations, + eq(volumeBackups.destinationId, destinations.destinationId), + ) + .where( + and( + eq(destinations.organizationId, orgId), + ...(accessCondition ? [accessCondition] : []), + ), + ); + return rows.map((r) => r.volumeBackupId); +} + +const ownerServiceColumns = { + columns: { name: true, serverId: true }, + with: { + environment: { + columns: { environmentId: true }, + with: { project: { columns: { projectId: true } } }, + }, + }, +} as const; + +// "Backups" here = run history (deployments with backupId/volumeBackupId), not the schedule configs. +export const getAllBackupsForOrganization = async ( + orgId: string, + accessedServices: string[] | null, + permissions: { backup: boolean; volumeBackup: boolean }, +): Promise => { + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + + const [backupIds, volumeBackupIds] = await Promise.all([ + permissions.backup + ? getBackupIdsInOrg(orgId, accessedServices) + : Promise.resolve([]), + permissions.volumeBackup + ? getVolumeBackupIdsInOrg(orgId, accessedServices) + : Promise.resolve([]), + ]); + + if (backupIds.length === 0 && volumeBackupIds.length === 0) { + return []; + } + + const conditions = [ + ...(backupIds.length > 0 ? [inArray(deployments.backupId, backupIds)] : []), + ...(volumeBackupIds.length > 0 + ? [inArray(deployments.volumeBackupId, volumeBackupIds)] + : []), + ]; + const whereClause = + conditions.length === 1 ? conditions[0] : or(...conditions); + + const rows = await db.query.deployments.findMany({ + where: whereClause, + orderBy: desc(deployments.createdAt), + limit: 500, + with: { + backup: { + with: { + destination: { columns: { destinationId: true, name: true } }, + postgres: { + columns: { postgresId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + mariadb: { + columns: { mariadbId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + mysql: { + columns: { mysqlId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + mongo: { + columns: { mongoId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + libsql: { + columns: { libsqlId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + compose: { + columns: { composeId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + }, + }, + volumeBackup: { + with: { + destination: { columns: { destinationId: true, name: true } }, + application: { + columns: { applicationId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + postgres: { + columns: { postgresId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + mariadb: { + columns: { mariadbId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + mysql: { + columns: { mysqlId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + mongo: { + columns: { mongoId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + redis: { + columns: { redisId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + libsql: { + columns: { libsqlId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + compose: { + columns: { composeId: true, ...ownerServiceColumns.columns }, + with: ownerServiceColumns.with, + }, + }, + }, + }, + }); + + const result: OverviewBackup[] = []; + for (const deployment of rows) { + if (deployment.backup) { + const owner = pickBackupOwner(deployment.backup); + result.push({ + deploymentId: deployment.deploymentId, + kind: "backup", + status: deployment.status, + createdAt: deployment.createdAt, + destinationId: deployment.backup.destination?.destinationId ?? "", + destinationName: deployment.backup.destination?.name ?? "Unknown", + databaseType: deployment.backup.databaseType, + serviceType: null, + backupType: deployment.backup.backupType, + serviceName: owner?.name ?? "Dokploy Server", + serviceOwnerId: owner?.id ?? null, + serviceOwnerType: owner?.type ?? "web-server", + serverId: owner?.serverId ?? null, + projectId: owner?.projectId ?? null, + environmentId: owner?.environmentId ?? null, + }); + } else if (deployment.volumeBackup) { + const owner = pickVolumeBackupOwner(deployment.volumeBackup); + result.push({ + deploymentId: deployment.deploymentId, + kind: "volumeBackup", + status: deployment.status, + createdAt: deployment.createdAt, + destinationId: deployment.volumeBackup.destination?.destinationId ?? "", + destinationName: deployment.volumeBackup.destination?.name ?? "Unknown", + databaseType: null, + serviceType: deployment.volumeBackup.serviceType, + backupType: null, + serviceName: + owner?.name ?? deployment.volumeBackup.serviceName ?? "Unknown", + serviceOwnerId: owner?.id ?? null, + serviceOwnerType: + owner?.type ?? + (deployment.volumeBackup.serviceType as OverviewServiceType), + serverId: owner?.serverId ?? null, + projectId: owner?.projectId ?? null, + environmentId: owner?.environmentId ?? null, + }); + } + } + return result; +}; + +type DomainOwnerRow = { + name: string; + environment?: { + environmentId: string; + name: string; + project?: { + projectId: string; + name: string; + organizationId: string; + } | null; + } | null; +}; + +type DomainOwner = { + id: string; + name: string; + type: OverviewDomainOwnerType; + organizationId: string | null; + projectId: string | null; + projectName: string | null; + environmentId: string | null; + environmentName: string | null; +}; + +const domainOwnerColumns = { + columns: { name: true }, + with: { + environment: { + columns: { environmentId: true, name: true }, + with: { + project: { + columns: { projectId: true, name: true, organizationId: true }, + }, + }, + }, + }, +} as const; + +function domainOwnerFrom( + row: DomainOwnerRow, + id: string, + type: OverviewDomainOwnerType, +): DomainOwner { + return { + id, + name: row.name, + type, + organizationId: row.environment?.project?.organizationId ?? null, + projectId: row.environment?.project?.projectId ?? null, + projectName: row.environment?.project?.name ?? null, + environmentId: row.environment?.environmentId ?? null, + environmentName: row.environment?.name ?? null, + }; +} + +// Preview domains inherit access from their parent application, so they're scoped by the same accessedServices check as applications. +async function getDomainScopeIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise<{ appIds: string[]; composeIds: string[]; previewIds: string[] }> { + const [appRows, composeRows, previewRows] = await Promise.all([ + db + .select({ id: applications.applicationId }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(applications.applicationId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ), + db + .select({ id: compose.composeId }) + .from(compose) + .innerJoin( + environments, + eq(compose.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(compose.composeId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ), + db + .select({ id: previewDeployments.previewDeploymentId }) + .from(previewDeployments) + .innerJoin( + applications, + eq(previewDeployments.applicationId, applications.applicationId), + ) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(applications.applicationId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ), + ]); + + return { + appIds: appRows.map((r) => r.id), + composeIds: composeRows.map((r) => r.id), + previewIds: previewRows.map((r) => r.id), + }; +} + +export const getAllDomainsForOrganization = async ( + orgId: string, + accessedServices: string[] | null, +): Promise => { + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + + const { appIds, composeIds, previewIds } = await getDomainScopeIdsInOrg( + orgId, + accessedServices, + ); + if ( + appIds.length === 0 && + composeIds.length === 0 && + previewIds.length === 0 + ) { + return []; + } + + const scopeConditions = [ + ...(appIds.length > 0 ? [inArray(domains.applicationId, appIds)] : []), + ...(composeIds.length > 0 ? [inArray(domains.composeId, composeIds)] : []), + ...(previewIds.length > 0 + ? [inArray(domains.previewDeploymentId, previewIds)] + : []), + ]; + + const rows = await db.query.domains.findMany({ + where: or(...scopeConditions), + with: { + application: { + columns: { applicationId: true, ...domainOwnerColumns.columns }, + with: domainOwnerColumns.with, + }, + compose: { + columns: { composeId: true, ...domainOwnerColumns.columns }, + with: domainOwnerColumns.with, + }, + previewDeployment: { + columns: {}, + with: { + application: { + columns: { applicationId: true, ...domainOwnerColumns.columns }, + with: domainOwnerColumns.with, + }, + }, + }, + }, + }); + + const result: OverviewDomain[] = []; + for (const domain of rows) { + const owner = domain.application + ? domainOwnerFrom( + domain.application, + domain.application.applicationId, + "application", + ) + : domain.compose + ? domainOwnerFrom(domain.compose, domain.compose.composeId, "compose") + : domain.previewDeployment?.application + ? domainOwnerFrom( + domain.previewDeployment.application, + domain.previewDeployment.application.applicationId, + "application", + ) + : null; + + if (!owner || owner.organizationId !== orgId) continue; + if (accessedServices !== null && !accessedServices.includes(owner.id)) { + continue; + } + + result.push({ + domainId: domain.domainId, + host: domain.host, + path: domain.path, + port: domain.port, + customEntrypoint: domain.customEntrypoint, + https: domain.https, + certificateType: domain.certificateType, + createdAt: domain.createdAt, + enabled: domain.enabled, + domainType: domain.domainType, + serviceOwnerId: owner.id, + serviceOwnerType: owner.type, + serviceName: owner.name, + projectId: owner.projectId ?? "", + projectName: owner.projectName ?? "", + environmentId: owner.environmentId ?? "", + environmentName: owner.environmentName ?? "", + }); + } + return result; +};