From fccf3413d0db50c78bf966448d59149346f6229e Mon Sep 17 00:00:00 2001 From: NBaB44 Date: Tue, 28 Jul 2026 10:15:11 +0200 Subject: [PATCH] feat(dashboard): add global Services list page Add a Services page after Home that lists all services across projects and environments with status and last deployment relative time, sorted by project, environment, then name. --- .../components/dashboard/search-command.tsx | 16 + .../services/show-services-table.tsx | 627 ++++++++++++++++++ apps/dokploy/components/layouts/side.tsx | 7 + apps/dokploy/pages/dashboard/services.tsx | 61 ++ 4 files changed, 711 insertions(+) create mode 100644 apps/dokploy/components/dashboard/services/show-services-table.tsx create mode 100644 apps/dokploy/pages/dashboard/services.tsx diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index d20fcd698c..45417e2f09 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -170,6 +170,22 @@ export const SearchCommand = () => { router.push("/dashboard/home"); setOpen(false); }} + > + Home + + { + router.push("/dashboard/services"); + setOpen(false); + }} + > + Services + + { + router.push("/dashboard/projects"); + setOpen(false); + }} > Projects diff --git a/apps/dokploy/components/dashboard/services/show-services-table.tsx b/apps/dokploy/components/dashboard/services/show-services-table.tsx new file mode 100644 index 0000000000..3661d7c76e --- /dev/null +++ b/apps/dokploy/components/dashboard/services/show-services-table.tsx @@ -0,0 +1,627 @@ +"use client"; + +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { formatDistanceToNow } from "date-fns"; +import { + ArrowUpDown, + Boxes, + ChevronLeft, + ChevronRight, + CircuitBoard, + ExternalLink, + GlobeIcon, + Layers, + Loader2, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { + extractServices, + type Services, +} from "@/components/dashboard/settings/users/add-permissions"; +import { + MariadbIcon, + MongodbIcon, + MysqlIcon, + PostgresqlIcon, + RedisIcon, +} from "@/components/icons/data-tools-icons"; +import { StatusTooltip } from "@/components/shared/status-tooltip"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type ProjectRow = inferRouterOutputs["project"]["all"][number]; +type DeploymentRow = + inferRouterOutputs["deployment"]["allCentralized"][number]; + +type ServiceRow = Services & { + projectId: string; + projectName: string; + environmentId: string; + environmentName: string; + href: string; + lastDeployment: { + createdAt: string; + status: DeploymentRow["status"]; + } | null; +}; + +const statusVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + running: "yellow", + done: "green", + error: "red", + cancelled: "outline", + idle: "secondary", +}; + +const typeLabels: Record = { + application: "Application", + compose: "Compose", + postgres: "Postgres", + mysql: "MySQL", + mariadb: "MariaDB", + mongo: "MongoDB", + redis: "Redis", + libsql: "LibSQL", +}; + +function ServiceTypeIcon({ type }: { type: ServiceRow["type"] }) { + const className = "size-4 text-muted-foreground shrink-0"; + switch (type) { + case "application": + return ; + case "compose": + return ; + case "postgres": + return ; + case "redis": + return ; + case "mariadb": + return ; + case "mongo": + return ; + case "mysql": + return ; + case "libsql": + return ; + default: + return ; + } +} + +function buildLatestDeploymentMap(deployments: DeploymentRow[] | undefined) { + const map = new Map< + string, + { createdAt: string; status: DeploymentRow["status"] } + >(); + if (!deployments) return map; + + for (const deployment of deployments) { + const serviceId = deployment.applicationId ?? deployment.composeId; + if (!serviceId || !deployment.createdAt) continue; + + const existing = map.get(serviceId); + if ( + !existing || + new Date(deployment.createdAt).getTime() > + new Date(existing.createdAt).getTime() + ) { + map.set(serviceId, { + createdAt: deployment.createdAt, + status: deployment.status, + }); + } + } + + return map; +} + +function flattenServices( + projects: ProjectRow[] | undefined, + latestByServiceId: Map< + string, + { createdAt: string; status: DeploymentRow["status"] } + >, +): ServiceRow[] { + if (!projects) return []; + + const rows: ServiceRow[] = []; + + for (const project of projects) { + for (const environment of project.environments ?? []) { + const services = extractServices(environment as never).filter((service) => + Boolean(service.id && service.name), + ); + + for (const service of services) { + rows.push({ + ...service, + projectId: project.projectId, + projectName: project.name, + environmentId: environment.environmentId, + environmentName: environment.name, + href: `/dashboard/project/${project.projectId}/environment/${environment.environmentId}/services/${service.type}/${service.id}`, + lastDeployment: latestByServiceId.get(service.id) ?? null, + }); + } + } + } + + rows.sort((a, b) => { + const byProject = a.projectName.localeCompare(b.projectName); + if (byProject !== 0) return byProject; + const byEnv = a.environmentName.localeCompare(b.environmentName); + if (byEnv !== 0) return byEnv; + return a.name.localeCompare(b.name); + }); + + return rows; +} + +export function ShowServicesTable() { + const [sorting, setSorting] = useState([ + { id: "projectName", desc: false }, + { id: "environmentName", desc: false }, + { id: "name", desc: false }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + + const { data: permissions } = api.user.getPermissions.useQuery(); + const canReadDeployments = !!permissions?.deployment.read; + + const { data: projects, isLoading: isLoadingProjects } = + api.project.all.useQuery(); + const { data: deployments, isLoading: isLoadingDeployments } = + api.deployment.allCentralized.useQuery(undefined, { + enabled: canReadDeployments, + refetchInterval: 10000, + }); + + const isLoading = + isLoadingProjects || (canReadDeployments && isLoadingDeployments); + + const latestByServiceId = useMemo( + () => + buildLatestDeploymentMap(canReadDeployments ? deployments : undefined), + [canReadDeployments, deployments], + ); + + const serviceRows = useMemo( + () => flattenServices(projects, latestByServiceId), + [projects, latestByServiceId], + ); + + const filteredData = useMemo(() => { + let list = serviceRows; + if (statusFilter !== "all") { + list = list.filter((row) => row.status === statusFilter); + } + if (typeFilter !== "all") { + list = list.filter((row) => row.type === typeFilter); + } + if (globalFilter.trim()) { + const q = globalFilter.toLowerCase(); + list = list.filter( + (row) => + row.name.toLowerCase().includes(q) || + row.projectName.toLowerCase().includes(q) || + row.environmentName.toLowerCase().includes(q) || + row.type.toLowerCase().includes(q), + ); + } + return list; + }, [serviceRows, statusFilter, typeFilter, globalFilter]); + + const columns = useMemo( + () => [ + { + id: "name", + accessorKey: "name", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: ServiceRow } }) => { + const service = row.original; + return ( +
+ +
+ + {service.name} + + + {typeLabels[service.type]} + +
+
+ ); + }, + }, + { + id: "projectName", + accessorKey: "projectName", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: ServiceRow } }) => ( + + {row.original.projectName} + + ), + }, + { + id: "environmentName", + accessorKey: "environmentName", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: ServiceRow } }) => ( + + {row.original.environmentName} + + ), + }, + { + id: "status", + accessorKey: "status", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: ServiceRow } }) => ( +
+ + + {row.original.status ?? "—"} + +
+ ), + }, + { + id: "lastDeployment", + accessorFn: (row: ServiceRow) => + row.lastDeployment?.createdAt + ? new Date(row.lastDeployment.createdAt).getTime() + : 0, + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: ServiceRow } }) => { + const last = row.original.lastDeployment; + if (!canReadDeployments || !last) { + return ; + } + const status = last.status ?? "idle"; + return ( +
+ + {status} + + + {formatDistanceToNow(new Date(last.createdAt), { + addSuffix: true, + })} + +
+ ); + }, + }, + { + header: "", + id: "actions", + enableSorting: false, + cell: ({ row }: { row: { original: ServiceRow } }) => ( + + ), + }, + ], + [canReadDeployments], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + columnFilters, + globalFilter, + pagination, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + enableMultiSort: true, + }); + + return ( +
+
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+
+ {isLoading ? ( +
+ + Loading services... +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + +
+ +

No services found

+

+ Services from your projects will appear here. +

+
+
+
+ )} +
+
+
+
+
+ + Rows per page + + + + Showing{" "} + {filteredData.length === 0 + ? 0 + : pagination.pageIndex * pagination.pageSize + 1}{" "} + to{" "} + {Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredData.length, + )}{" "} + of {filteredData.length} entries + +
+
+ + +
+
+ + )} +
+
+ ); +} diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index e58256c63f..98fc5a689a 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -22,6 +22,7 @@ import { House, Key, KeyRound, + Layers, Loader2, LogIn, type LucideIcon, @@ -157,6 +158,12 @@ const MENU: Menu = { url: "/dashboard/home", icon: House, }, + { + isSingle: true, + title: "Services", + url: "/dashboard/services", + icon: Layers, + }, { isSingle: true, title: "Projects", diff --git a/apps/dokploy/pages/dashboard/services.tsx b/apps/dokploy/pages/dashboard/services.tsx new file mode 100644 index 0000000000..76442b92d0 --- /dev/null +++ b/apps/dokploy/pages/dashboard/services.tsx @@ -0,0 +1,61 @@ +import { validateRequest } from "@dokploy/server/lib/auth"; +import { Layers } from "lucide-react"; +import type { GetServerSidePropsContext } from "next"; +import type { ReactElement } from "react"; +import { ShowServicesTable } from "@/components/dashboard/services/show-services-table"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +function ServicesPage() { + return ( +
+ +
+ +
+
+ + + Services + + + All services across projects and environments in one place. + +
+
+
+ +
+
+
+
+
+ ); +} + +export default ServicesPage; + +ServicesPage.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const { user } = await validateRequest(ctx.req); + if (!user) { + return { + redirect: { + permanent: false, + destination: "/", + }, + }; + } + + return { + props: {}, + }; +}