diff --git a/apps/api/.env.example b/apps/api/.env.example index 647e2a077c..01edbec0c6 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,2 +1,11 @@ LEMON_SQUEEZY_API_KEY="" -LEMON_SQUEEZY_STORE_ID="" \ No newline at end of file +LEMON_SQUEEZY_STORE_ID="" + +# Inngest (for GET /jobs - list deployment queue). Self-hosted example: +# INNGEST_BASE_URL="http://localhost:8288" +# Production: INNGEST_BASE_URL="https://dev-inngest.dokploy.com" +# INNGEST_SIGNING_KEY="your-signing-key" +# Optional: only events after this RFC3339 timestamp. If unset, no date filter is applied. +# INNGEST_EVENTS_RECEIVED_AFTER="2024-01-01T00:00:00Z" +# Max events to fetch when listing jobs (paginates with cursor). Default 100, max 10000. +# INNGEST_JOBS_MAX_EVENTS=100 \ No newline at end of file diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8ddb56dec0..0bb6e1401e 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -10,6 +10,7 @@ import { type DeployJob, deployJobSchema, } from "./schema.js"; +import { fetchDeploymentJobs } from "./service.js"; import { deploy } from "./utils.js"; const app = new Hono(); @@ -118,7 +119,6 @@ app.post("/deploy", zValidator("json", deployJobSchema), async (c) => { 200, ); } catch (error) { - console.log("error", error); logger.error("Failed to send deployment event", error); return c.json( { @@ -176,6 +176,29 @@ app.get("/health", async (c) => { return c.json({ status: "ok" }); }); +// List deployment jobs (Inngest runs) for a server - same shape as BullMQ queue for the UI +app.get("/jobs", async (c) => { + const serverId = c.req.query("serverId"); + if (!serverId) { + return c.json({ message: "serverId is required" }, 400); + } + + try { + const rows = await fetchDeploymentJobs(serverId); + return c.json(rows); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("INNGEST_BASE_URL")) { + return c.json( + { message: "INNGEST_BASE_URL is required to list deployment jobs" }, + 503, + ); + } + logger.error("Failed to fetch jobs from Inngest", { serverId, error }); + return c.json([], 200); + } +}); + // Serve Inngest functions endpoint app.on( ["GET", "POST", "PUT"], diff --git a/apps/api/src/service.ts b/apps/api/src/service.ts new file mode 100644 index 0000000000..495e30ca34 --- /dev/null +++ b/apps/api/src/service.ts @@ -0,0 +1,239 @@ +import { logger } from "./logger"; + +const baseUrl = process.env.INNGEST_BASE_URL ?? ""; +const signingKey = process.env.INNGEST_SIGNING_KEY ?? ""; + +const DEFAULT_MAX_EVENTS = 500; +const MAX_EVENTS = DEFAULT_MAX_EVENTS; + +/** Event shape from GET /v1/events (https://api.inngest.com/v1/events) */ +type InngestEventRow = { + internal_id?: string; + accountID?: string; + environmentID?: string; + source?: string; + sourceID?: string | null; + /** RFC3339 timestamp – API uses receivedAt, dev server may use received_at */ + receivedAt?: string; + received_at?: string; + id: string; + name: string; + data: Record; + user?: unknown; + ts: number; + v?: string | null; + metadata?: { + fetchedAt: string; + cachedUntil: string | null; + }; +}; + +/** Run shape from GET /v1/events/{eventId}/runs – the actual job execution */ +type InngestRun = { + run_id: string; + event_id: string; + status: string; // "Running" | "Completed" | "Failed" | "Cancelled" | "Queued"? + run_started_at?: string; + ended_at?: string | null; + output?: unknown; + // dev server / API may use different casing + run_started_at_ms?: number; +}; + +function getEventReceivedAt(ev: InngestEventRow): string | undefined { + return ev.receivedAt ?? ev.received_at; +} + +/** Map Inngest run status to BullMQ-style state for the UI */ +function runStatusToState( + status: string, +): "pending" | "active" | "completed" | "failed" | "cancelled" { + const s = status.toLowerCase(); + if (s === "running") return "active"; + if (s === "completed") return "completed"; + if (s === "failed") return "failed"; + if (s === "cancelled") return "cancelled"; + if (s === "queued") return "pending"; + return "pending"; +} + +export const fetchInngestEvents = async () => { + const maxEvents = MAX_EVENTS; + const all: InngestEventRow[] = []; + let cursor: string | undefined; + + do { + const params = new URLSearchParams({ limit: "100" }); + if (cursor) { + params.set("cursor", cursor); + } + + const res = await fetch(`${baseUrl}/v1/events?${params}`, { + headers: { + Authorization: `Bearer ${signingKey}`, + "Content-Type": "application/json", + }, + }); + + if (!res.ok) { + logger.warn("Inngest API error", { + status: res.status, + body: await res.text(), + }); + break; + } + + const body = (await res.json()) as { + data?: InngestEventRow[]; + cursor?: string; + nextCursor?: string; + }; + const data = Array.isArray(body.data) ? body.data : []; + all.push(...data); + + // Next page: API may return cursor/nextCursor, or use last event's internal_id (per API docs) + const nextCursor = + body.cursor ?? body.nextCursor ?? data[data.length - 1]?.internal_id; + const hasMore = data.length === 100 && nextCursor && all.length < maxEvents; + cursor = hasMore ? nextCursor : undefined; + } while (cursor); + + return all.slice(0, maxEvents); +}; + +/** Fetch runs for a single event (GET /v1/events/{eventId}/runs) – runs are the actual jobs */ +export const fetchInngestRunsForEvent = async ( + eventId: string, +): Promise => { + const res = await fetch( + `${baseUrl}/v1/events/${encodeURIComponent(eventId)}/runs`, + { + headers: { + Authorization: `Bearer ${signingKey}`, + "Content-Type": "application/json", + }, + }, + ); + if (!res.ok) { + logger.warn("Inngest runs API error", { + eventId, + status: res.status, + body: await res.text(), + }); + return []; + } + const body = (await res.json()) as { data?: InngestRun[] }; + return Array.isArray(body.data) ? body.data : []; +}; + +/** One row for the queue UI (BullMQ-compatible shape) */ +export type DeploymentJobRow = { + id: string; + name: string; + data: Record; + timestamp: number; + processedOn?: number; + finishedOn?: number; + failedReason?: string; + state: string; +}; + +/** Build queue rows from events + their runs (one row per run, or pending if no run yet) */ +function buildDeploymentRowsFromRuns( + events: InngestEventRow[], + runsByEventId: Map, + serverId: string, +): DeploymentJobRow[] { + const requested = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + const rows: DeploymentJobRow[] = []; + + for (const ev of requested) { + const data = (ev.data ?? {}) as Record; + const runs = runsByEventId.get(ev.id) ?? []; + + if (runs.length === 0) { + // Queued: event received but no run yet + rows.push({ + id: ev.id, + name: ev.name, + data, + timestamp: ev.ts, + processedOn: ev.ts, + finishedOn: undefined, + failedReason: undefined, + state: "pending", + }); + continue; + } + + for (const run of runs) { + const state = runStatusToState(run.status); + const runStartedMs = + run.run_started_at_ms ?? + (run.run_started_at ? new Date(run.run_started_at).getTime() : ev.ts); + const endedMs = run.ended_at + ? new Date(run.ended_at).getTime() + : undefined; + const failedReason = + state === "failed" && + run.output && + typeof run.output === "object" && + "error" in run.output + ? String((run.output as { error?: unknown }).error) + : undefined; + + rows.push({ + id: run.run_id, + name: ev.name, + data, + timestamp: runStartedMs, + processedOn: runStartedMs, + finishedOn: + state === "completed" || state === "failed" || state === "cancelled" + ? endedMs + : undefined, + failedReason, + state, + }); + } + } + + return rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); +} + +/** Fetch deployment jobs for a server: events → runs → rows (correct model: runs = jobs) */ +export const fetchDeploymentJobs = async ( + serverId: string, +): Promise => { + if (!signingKey) { + logger.warn("INNGEST_SIGNING_KEY not set, returning empty jobs list"); + return []; + } + if (!baseUrl) { + throw new Error("INNGEST_BASE_URL is required to list deployment jobs"); + } + + const events = await fetchInngestEvents(); + + const requestedForServer = events.filter( + (e) => + e.name === "deployment/requested" && + (e.data as Record)?.serverId === serverId, + ); + // Limit to avoid too many run fetches + const toFetch = requestedForServer.slice(0, 50); + const runsByEventId = new Map(); + + await Promise.all( + toFetch.map(async (ev) => { + const runs = await fetchInngestRunsForEvent(ev.id); + runsByEventId.set(ev.id, runs); + }), + ); + + return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId); +}; diff --git a/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx new file mode 100644 index 0000000000..770d4efd05 --- /dev/null +++ b/apps/dokploy/components/dashboard/deployments/show-deployments-table.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { + type ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + type PaginationState, + type SortingState, + useReactTable, +} from "@tanstack/react-table"; +import type { inferRouterOutputs } from "@trpc/server"; +import { + ArrowUpDown, + Boxes, + ChevronLeft, + ChevronRight, + ExternalLink, + Loader2, + Rocket, + Server, +} from "lucide-react"; +import Link from "next/link"; +import { useMemo, useState } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { AppRouter } from "@/server/api/root"; +import { api } from "@/utils/api"; + +type DeploymentRow = + inferRouterOutputs["deployment"]["allCentralized"][number]; + +const statusVariants: Record< + string, + | "default" + | "secondary" + | "destructive" + | "outline" + | "yellow" + | "green" + | "red" +> = { + running: "yellow", + done: "green", + error: "red", + cancelled: "outline", +}; + +function getServiceInfo(d: DeploymentRow) { + const app = d.application; + const comp = d.compose; + if (app?.environment?.project && app.environment) { + return { + type: "Application" as const, + name: app.name, + projectId: app.environment.project.projectId, + environmentId: app.environment.environmentId, + projectName: app.environment.project.name, + environmentName: app.environment.name, + serviceId: app.applicationId, + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + }; + } + if (comp?.environment?.project && comp.environment) { + return { + type: "Compose" as const, + name: comp.name, + projectId: comp.environment.project.projectId, + environmentId: comp.environment.environmentId, + projectName: comp.environment.project.name, + environmentName: comp.environment.name, + serviceId: comp.composeId, + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + }; + } + return null; +} + +export function ShowDeploymentsTable() { + const [sorting, setSorting] = useState([ + { id: "createdAt", desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("all"); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 50, + }); + + const { data: deploymentsList, isLoading } = + api.deployment.allCentralized.useQuery(undefined, { + refetchInterval: 5000, + }); + + const filteredData = useMemo(() => { + if (!deploymentsList) return []; + let list = deploymentsList; + if (statusFilter !== "all") { + list = list.filter((d) => d.status === statusFilter); + } + if (typeFilter === "application") { + list = list.filter((d) => d.applicationId != null); + } else if (typeFilter === "compose") { + list = list.filter((d) => d.composeId != null); + } + if (globalFilter.trim()) { + const q = globalFilter.toLowerCase(); + list = list.filter((d) => { + const info = getServiceInfo(d); + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + ""; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? ""; + if (!info) return false; + return ( + info.name.toLowerCase().includes(q) || + info.projectName.toLowerCase().includes(q) || + info.environmentName.toLowerCase().includes(q) || + (d.title?.toLowerCase().includes(q) ?? false) || + serverName.toLowerCase().includes(q) || + buildServerName.toLowerCase().includes(q) + ); + }); + } + return list; + }, [deploymentsList, statusFilter, typeFilter, globalFilter]); + + const columns = useMemo( + () => [ + { + id: "serviceName", + accessorFn: (row: DeploymentRow) => getServiceInfo(row)?.name ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return ; + return ( +
+ {info.type === "Application" ? ( + + ) : ( + + )} +
+ {info.name} + + {info.type} + +
+
+ ); + }, + }, + { + id: "projectName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.projectName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.projectName ?? "—"} + + ); + }, + }, + { + id: "environmentName", + accessorFn: (row: DeploymentRow) => + getServiceInfo(row)?.environmentName ?? "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + return ( + + {info?.environmentName ?? "—"} + + ); + }, + }, + { + id: "serverName", + accessorFn: (row: DeploymentRow) => + row.server?.name ?? + row.application?.server?.name ?? + row.compose?.server?.name ?? + "", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const d = row.original; + const serverName = + d.server?.name ?? + d.application?.server?.name ?? + d.compose?.server?.name ?? + null; + const serverType = + d.server?.serverType ?? + d.application?.server?.serverType ?? + d.compose?.server?.serverType ?? + null; + const buildServerName = + d.buildServer?.name ?? d.application?.buildServer?.name ?? null; + const buildServerType = + d.buildServer?.serverType ?? + d.application?.buildServer?.serverType ?? + null; + const showBuild = + buildServerName != null && buildServerName !== serverName; + if (!serverName && !showBuild) { + return ; + } + return ( +
+ {serverName && ( +
+ + {serverName} + {serverType && ( + + {serverType} + + )} +
+ )} + {showBuild && buildServerName && ( +
+ Build: + {buildServerName} + {buildServerType && ( + + {buildServerType} + + )} +
+ )} +
+ ); + }, + }, + { + accessorKey: "title", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.title || "—"} + + ), + }, + { + accessorKey: "status", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const status = row.original.status ?? "running"; + return ( + + {status} + + ); + }, + }, + { + accessorKey: "createdAt", + header: ({ + column, + }: { + column: { + getIsSorted: () => false | "asc" | "desc"; + toggleSorting: (asc: boolean) => void; + }; + }) => ( + + ), + cell: ({ row }: { row: { original: DeploymentRow } }) => ( + + {row.original.createdAt + ? new Date(row.original.createdAt).toLocaleString() + : "—"} + + ), + }, + { + header: "", + id: "actions", + enableSorting: false, + cell: ({ row }: { row: { original: DeploymentRow } }) => { + const info = getServiceInfo(row.original); + if (!info) return null; + return ( + + ); + }, + }, + ], + [], + ); + + const table = useReactTable({ + data: filteredData, + columns, + state: { + sorting, + columnFilters, + globalFilter, + pagination, + }, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onGlobalFilterChange: setGlobalFilter, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + }); + + return ( +
+
+ setGlobalFilter(e.target.value)} + className="max-w-xs" + /> + + +
+
+ {isLoading ? ( +
+ + Loading deployments... +
+ ) : ( + <> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + +
+ +

No deployments found

+

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

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

Queue is empty

+

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

+
+
+
+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/dokploy/components/dashboard/project/duplicate-project.tsx b/apps/dokploy/components/dashboard/project/duplicate-project.tsx index f84cf35dda..e754b1d8b5 100644 --- a/apps/dokploy/components/dashboard/project/duplicate-project.tsx +++ b/apps/dokploy/components/dashboard/project/duplicate-project.tsx @@ -25,7 +25,6 @@ import { import { api } from "@/utils/api"; export type Services = { - appName: string; serverId?: string | null; name: string; type: diff --git a/apps/dokploy/components/dashboard/projects/show.tsx b/apps/dokploy/components/dashboard/projects/show.tsx index c3d4d498be..f25fb6d478 100644 --- a/apps/dokploy/components/dashboard/projects/show.tsx +++ b/apps/dokploy/components/dashboard/projects/show.tsx @@ -2,7 +2,6 @@ import { AlertTriangle, ArrowUpDown, BookIcon, - ExternalLinkIcon, FolderInput, Loader2, MoreHorizontalIcon, @@ -16,7 +15,6 @@ import { toast } from "sonner"; import { BreadcrumbSidebar } from "@/components/shared/breadcrumb-sidebar"; import { DateTooltip } from "@/components/shared/date-tooltip"; import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input"; -import { StatusTooltip } from "@/components/shared/status-tooltip"; import { AlertDialog, AlertDialogAction, @@ -40,10 +38,8 @@ import { import { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { @@ -280,14 +276,6 @@ export const ShowProjects = () => { ) .reduce((acc, curr) => acc + curr, 0); - const haveServicesWithDomains = project?.environments - .map( - (env) => - env.applications.length > 0 || - env.compose.length > 0, - ) - .some(Boolean); - // Find default environment from accessible environments, or fall back to first accessible environment const accessibleEnvironment = project?.environments.find((env) => env.isDefault) || @@ -313,122 +301,6 @@ export const ShowProjects = () => { }} > - {haveServicesWithDomains ? ( - - - - - e.stopPropagation()} - > - {project.environments.some( - (env) => env.applications.length > 0, - ) && ( - - - Applications - - {project.environments.map((env) => - env.applications.map((app) => ( -
- - - - {app.name} - - - - {app.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} - {project.environments.some( - (env) => env.compose.length > 0, - ) && ( - - - Compose - - {project.environments.map((env) => - env.compose.map((comp) => ( -
- - - - {comp.name} - - - - {comp.domains.map((domain) => ( - - - - {domain.host} - - - - - ))} - -
- )), - )} -
- )} -
-
- ) : null} diff --git a/apps/dokploy/components/dashboard/requests/columns.tsx b/apps/dokploy/components/dashboard/requests/columns.tsx index 3648261fb9..997074fdee 100644 --- a/apps/dokploy/components/dashboard/requests/columns.tsx +++ b/apps/dokploy/components/dashboard/requests/columns.tsx @@ -6,6 +6,9 @@ import { Button } from "@/components/ui/button"; import type { LogEntry } from "./show-requests"; export const getStatusColor = (status: number) => { + if (status === 0) { + return "secondary"; + } if (status >= 100 && status < 200) { return "outline"; } @@ -21,6 +24,24 @@ export const getStatusColor = (status: number) => { return "destructive"; }; +const formatStatusLabel = (status: number) => { + if (status === 0) { + return "N/A"; + } + return status; +}; + +const formatDuration = (nanos: number) => { + const ms = nanos / 1000000; + if (ms < 1) { + return `${(nanos / 1000).toFixed(2)} µs`; + } + if (ms < 1000) { + return `${ms.toFixed(2)} ms`; + } + return `${(ms / 1000).toFixed(2)} s`; +}; + export const columns: ColumnDef[] = [ { accessorKey: "level", @@ -59,10 +80,10 @@ export const columns: ColumnDef[] = [
- Status: {log.OriginStatus} + Status: {formatStatusLabel(log.OriginStatus)} - Exec Time: {`${log.Duration / 1000000000}s`} + Exec Time: {formatDuration(log.Duration)} IP: {log.ClientAddr}
diff --git a/apps/dokploy/components/dashboard/requests/requests-table.tsx b/apps/dokploy/components/dashboard/requests/requests-table.tsx index 45a531324f..e804b065bd 100644 --- a/apps/dokploy/components/dashboard/requests/requests-table.tsx +++ b/apps/dokploy/components/dashboard/requests/requests-table.tsx @@ -152,7 +152,15 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { return JSON.stringify(value, null, 2); } if (key === "Duration" || key === "OriginDuration" || key === "Overhead") { - return `${value / 1000000000} s`; + const nanos = Number(value); + const ms = nanos / 1000000; + if (ms < 1) { + return `${(nanos / 1000).toFixed(2)} µs`; + } + if (ms < 1000) { + return `${ms.toFixed(2)} ms`; + } + return `${(ms / 1000).toFixed(2)} s`; } if (key === "level") { return {value}; @@ -161,7 +169,11 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => { return {value}; } if (key === "DownstreamStatus" || key === "OriginStatus") { - return {value}; + const num = Number(value); + if (num === 0) { + return N/A; + } + return {value}; } return value; }; diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 6fd7989553..bbd612d92f 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -174,6 +174,14 @@ export const SearchCommand = () => { > Projects + { + router.push("/dashboard/deployments"); + setOpen(false); + }} + > + Deployments + {!isCloud && ( <> { const url = useUrl(); - const { data: projects } = api.project.all.useQuery(); + const { data: projects } = api.project.allForPermissions.useQuery(); const extractServicesFromProjects = () => { if (!projects) return []; diff --git a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx index d3f8af31c7..d0a2a26fa8 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-permissions.tsx @@ -28,8 +28,12 @@ import { import { Switch } from "@/components/ui/switch"; import { api, type RouterOutputs } from "@/utils/api"; -type Project = RouterOutputs["project"]["all"][number]; -type Environment = Project["environments"][number]; +/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */ +type ProjectForPermissions = + RouterOutputs["project"]["allForPermissions"][number]; +type EnvironmentForPermissions = ProjectForPermissions["environments"][number]; + +type Environment = EnvironmentForPermissions; export type Services = { appName: string; @@ -173,7 +177,9 @@ interface Props { export const AddUserPermissions = ({ userId }: Props) => { const [isOpen, setIsOpen] = useState(false); - const { data: projects } = api.project.all.useQuery(); + const { data: projects } = api.project.allForPermissions.useQuery(undefined, { + enabled: isOpen, + }); const { data, refetch } = api.user.one.useQuery( { diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 66ac772a7b..50a16b4769 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -25,6 +25,7 @@ import { type LucideIcon, Package, PieChart, + Rocket, Server, ShieldCheck, Star, @@ -145,6 +146,12 @@ const MENU: Menu = { url: "/dashboard/projects", icon: Folder, }, + { + isSingle: true, + title: "Deployments", + url: "/dashboard/deployments", + icon: Rocket, + }, { isSingle: true, title: "Monitoring", diff --git a/apps/dokploy/components/ui/command.tsx b/apps/dokploy/components/ui/command.tsx index ffaff86857..409823c560 100644 --- a/apps/dokploy/components/ui/command.tsx +++ b/apps/dokploy/components/ui/command.tsx @@ -13,7 +13,7 @@ const Command = React.forwardRef< { + if (!isValidTab(value)) return; + router.replace( + { pathname: "/dashboard/deployments", query: { tab: value } }, + undefined, + { shallow: true }, + ); + }; + + return ( +
+ +
+ +
+
+ + + Deployments + + + All application and compose deployments in one place. + +
+
+ + + Deployments + Queue + + + + + + + + +
+
+
+
+ ); +} + +export default DeploymentsPage; + +DeploymentsPage.getLayout = (page: ReactElement) => { + return {page}; +}; + +export async function getServerSideProps(ctx: GetServerSidePropsContext) { + const { user } = await validateRequest(ctx.req); + if (!user) { + return { + redirect: { + permanent: true, + destination: "/", + }, + }; + } + return { + props: {}, + }; +} diff --git a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx index af901311e2..07a7396e2b 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -23,7 +23,7 @@ import type { InferGetServerSidePropsType, } from "next"; import Head from "next/head"; -import { useRouter } from "next/router"; +import Link from "next/link"; import { type ReactElement, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import superjson from "superjson"; @@ -100,7 +100,6 @@ import { appRouter } from "@/server/api/root"; import { api } from "@/utils/api"; export type Services = { - appName: string; serverId?: string | null; serverName?: string | null; name: string; @@ -146,7 +145,6 @@ export const extractServicesFromEnvironment = ( } } return { - appName: item.appName, name: item.name, type: "application", id: item.applicationId, @@ -161,7 +159,6 @@ export const extractServicesFromEnvironment = ( const mariadb: Services[] = environment.mariadb?.map((item) => ({ - appName: item.appName, name: item.name, type: "mariadb", id: item.mariadbId, @@ -174,7 +171,6 @@ export const extractServicesFromEnvironment = ( const postgres: Services[] = environment.postgres?.map((item) => ({ - appName: item.appName, name: item.name, type: "postgres", id: item.postgresId, @@ -187,7 +183,6 @@ export const extractServicesFromEnvironment = ( const mongo: Services[] = environment.mongo?.map((item) => ({ - appName: item.appName, name: item.name, type: "mongo", id: item.mongoId, @@ -200,7 +195,6 @@ export const extractServicesFromEnvironment = ( const redis: Services[] = environment.redis?.map((item) => ({ - appName: item.appName, name: item.name, type: "redis", id: item.redisId, @@ -213,7 +207,6 @@ export const extractServicesFromEnvironment = ( const mysql: Services[] = environment.mysql?.map((item) => ({ - appName: item.appName, name: item.name, type: "mysql", id: item.mysqlId, @@ -242,7 +235,6 @@ export const extractServicesFromEnvironment = ( } } return { - appName: item.appName, name: item.name, type: "compose", id: item.composeId, @@ -366,7 +358,6 @@ const EnvironmentPage = ( environmentId, }); const { data: allProjects } = api.project.all.useQuery(); - const router = useRouter(); const [isMoveDialogOpen, setIsMoveDialogOpen] = useState(false); const [selectedTargetProject, setSelectedTargetProject] = @@ -420,6 +411,7 @@ const EnvironmentPage = ( }; const handleServiceSelect = (serviceId: string, event: React.MouseEvent) => { + event.preventDefault(); event.stopPropagation(); setSelectedServices((prev) => prev.includes(serviceId) @@ -1471,101 +1463,99 @@ const EnvironmentPage = (
{filteredServices?.map((service) => ( - { - router.push( - `/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`, - ); - }} - className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border" + href={`/dashboard/project/${projectId}/environment/${environmentId}/services/${service.type}/${service.id}`} + className="block" > - {service.serverId && ( -
- + + {service.serverId && ( +
+ +
+ )} +
+
- )} -
- -
-
- handleServiceSelect(service.id, e) - } - > -
- +
+ handleServiceSelect(service.id, e) + } + > +
+ +
-
- - -
-
- - {service.name} - - {service.description && ( - - {service.description} + + +
+
+ + {service.name} - )} -
+ {service.description && ( + + {service.description} + + )} +
- - {service.type === "postgres" && ( - - )} - {service.type === "redis" && ( - - )} - {service.type === "mariadb" && ( - - )} - {service.type === "mongo" && ( - - )} - {service.type === "mysql" && ( - - )} - {service.type === "application" && ( - - )} - {service.type === "compose" && ( - - )} - -
- - - -
- {service.serverName && ( -
- - - {service.serverName} + + {service.type === "postgres" && ( + + )} + {service.type === "redis" && ( + + )} + {service.type === "mariadb" && ( + + )} + {service.type === "mongo" && ( + + )} + {service.type === "mysql" && ( + + )} + {service.type === "application" && ( + + )} + {service.type === "compose" && ( + + )}
- )} - - Created - -
-
- + + + +
+ {service.serverName && ( +
+ + + {service.serverName} + +
+ )} + + Created + +
+
+ + ))}
diff --git a/apps/dokploy/server/api/routers/application.ts b/apps/dokploy/server/api/routers/application.ts index 97dba570e3..df3e81c82b 100644 --- a/apps/dokploy/server/api/routers/application.ts +++ b/apps/dokploy/server/api/routers/application.ts @@ -7,6 +7,7 @@ import { findApplicationById, findEnvironmentById, findGitProviderById, + findMemberById, findProjectById, getApplicationStats, IS_CLOUD, @@ -32,7 +33,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { nanoid } from "nanoid"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; @@ -53,6 +54,8 @@ import { apiSaveGitProvider, apiUpdateApplication, applications, + environments, + projects, } from "@/server/db/schema"; import { deploymentWorker } from "@/server/queues/deployments-queue"; import type { DeploymentJob } from "@/server/queues/queue-types"; @@ -1002,4 +1005,138 @@ export const applicationRouter = createTRPCRouter({ message: "Deployment cancellation only available in cloud version", }); }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + repository: z.string().optional(), + owner: z.string().optional(), + dockerImage: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(applications.environmentId, input.environmentId), + ); + } + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(applications.name, term), + ilike(applications.appName, term), + ilike(applications.description ?? "", term), + ilike(applications.repository ?? "", term), + ilike(applications.owner ?? "", term), + ilike(applications.dockerImage ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push(ilike(applications.name, `%${input.name.trim()}%`)); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(applications.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike( + applications.description ?? "", + `%${input.description.trim()}%`, + ), + ); + } + if (input.repository?.trim()) { + baseConditions.push( + ilike(applications.repository ?? "", `%${input.repository.trim()}%`), + ); + } + if (input.owner?.trim()) { + baseConditions.push( + ilike(applications.owner ?? "", `%${input.owner.trim()}%`), + ); + } + if (input.dockerImage?.trim()) { + baseConditions.push( + ilike( + applications.dockerImage ?? "", + `%${input.dockerImage.trim()}%`, + ), + ); + } + + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${applications.applicationId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db + .select({ + applicationId: applications.applicationId, + name: applications.name, + appName: applications.appName, + description: applications.description, + environmentId: applications.environmentId, + applicationStatus: applications.applicationStatus, + sourceType: applications.sourceType, + createdAt: applications.createdAt, + }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(applications.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), }); diff --git a/apps/dokploy/server/api/routers/compose.ts b/apps/dokploy/server/api/routers/compose.ts index f868e2ae1f..e3c803cd4f 100644 --- a/apps/dokploy/server/api/routers/compose.ts +++ b/apps/dokploy/server/api/routers/compose.ts @@ -16,6 +16,7 @@ import { findDomainsByComposeId, findEnvironmentById, findGitProviderById, + findMemberById, findProjectById, findServerById, getComposeContainer, @@ -41,7 +42,7 @@ import { } from "@dokploy/server/templates/github"; import { processTemplate } from "@dokploy/server/templates/processors"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import _ from "lodash"; import { nanoid } from "nanoid"; import { parse } from "toml"; @@ -58,6 +59,8 @@ import { apiRedeployCompose, apiUpdateCompose, compose as composeTable, + environments, + projects, } from "@/server/db/schema"; import { deploymentWorker } from "@/server/queues/deployments-queue"; import type { DeploymentJob } from "@/server/queues/queue-types"; @@ -1054,4 +1057,114 @@ export const composeRouter = createTRPCRouter({ message: "Deployment cancellation only available in cloud version", }); }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(composeTable.environmentId, input.environmentId), + ); + } + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(composeTable.name, term), + ilike(composeTable.appName, term), + ilike(composeTable.description ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push(ilike(composeTable.name, `%${input.name.trim()}%`)); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(composeTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike( + composeTable.description ?? "", + `%${input.description.trim()}%`, + ), + ); + } + + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${composeTable.composeId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db + .select({ + composeId: composeTable.composeId, + name: composeTable.name, + appName: composeTable.appName, + description: composeTable.description, + environmentId: composeTable.environmentId, + composeStatus: composeTable.composeStatus, + sourceType: composeTable.sourceType, + createdAt: composeTable.createdAt, + }) + .from(composeTable) + .innerJoin( + environments, + eq(composeTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(composeTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(composeTable) + .innerJoin( + environments, + eq(composeTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), }); diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 5aac2e8a77..7b2b414c00 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -4,11 +4,15 @@ import { findAllDeploymentsByApplicationId, findAllDeploymentsByComposeId, findAllDeploymentsByServerId, + findAllDeploymentsCentralized, findApplicationById, findComposeById, findDeploymentById, + findMemberById, findServerById, + IS_CLOUD, removeDeployment, + resolveServicePath, updateDeploymentStatus, } from "@dokploy/server"; import { db } from "@dokploy/server/db"; @@ -21,7 +25,10 @@ import { apiFindAllByServer, apiFindAllByType, deployments, + server, } from "@/server/db/schema"; +import { myQueue } from "@/server/queues/queueSetup"; +import { fetchDeployApiJobs, type QueueJobRow } from "@/server/utils/deploy"; import { createTRPCRouter, protectedProcedure } from "../trpc"; export const deploymentRouter = createTRPCRouter({ @@ -68,6 +75,63 @@ export const deploymentRouter = createTRPCRouter({ } return await findAllDeploymentsByServerId(input.serverId); }), + allCentralized: protectedProcedure.query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + const accessedServices = + ctx.user.role === "member" + ? (await findMemberById(ctx.user.id, orgId)).accessedServices + : null; + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + return findAllDeploymentsCentralized(orgId, accessedServices); + }), + + queueList: protectedProcedure.query(async ({ ctx }) => { + const orgId = ctx.session.activeOrganizationId; + let rows: QueueJobRow[]; + + if (IS_CLOUD) { + const servers = await db.query.server.findMany({ + where: eq(server.organizationId, orgId), + columns: { serverId: true }, + }); + const serverRowsArrays = await Promise.all( + servers.map(({ serverId }) => fetchDeployApiJobs(serverId)), + ); + rows = serverRowsArrays.flat(); + rows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + } else { + const jobs = await myQueue.getJobs(); + const jobRows = await Promise.all( + jobs.map(async (job) => { + const state = await job.getState(); + return { + id: String(job.id), + name: job.name ?? undefined, + data: job.data as Record, + timestamp: job.timestamp, + processedOn: job.processedOn, + finishedOn: job.finishedOn, + failedReason: job.failedReason ?? undefined, + state, + }; + }), + ); + jobRows.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); + rows = jobRows; + } + + return Promise.all( + rows.map(async (row) => ({ + ...row, + servicePath: await resolveServicePath( + orgId, + (row.data ?? {}) as Record, + ), + })), + ); + }), allByType: protectedProcedure .input(apiFindAllByType) @@ -79,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({ rollback: true, }, }); - return deploymentsList; }), - killProcess: protectedProcedure .input( z.object({ diff --git a/apps/dokploy/server/api/routers/environment.ts b/apps/dokploy/server/api/routers/environment.ts index 9f5eb45c2d..16376e9e01 100644 --- a/apps/dokploy/server/api/routers/environment.ts +++ b/apps/dokploy/server/api/routers/environment.ts @@ -11,7 +11,9 @@ import { findMemberById, updateEnvironmentById, } from "@dokploy/server"; +import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -21,6 +23,7 @@ import { apiRemoveEnvironment, apiUpdateEnvironment, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; // Helper function to filter services within an environment based on user permissions const filterEnvironmentServices = ( @@ -358,4 +361,92 @@ export const environmentRouter = createTRPCRouter({ }); } }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(environments.name, term), + ilike(environments.description ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push(ilike(environments.name, `%${input.name.trim()}%`)); + } + if (input.description?.trim()) { + baseConditions.push( + ilike( + environments.description ?? "", + `%${input.description.trim()}%`, + ), + ); + } + + if (ctx.user.role === "member") { + const { accessedEnvironments } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedEnvironments.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${environments.environmentId} IN (${sql.join( + accessedEnvironments.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db + .select({ + environmentId: environments.environmentId, + name: environments.name, + description: environments.description, + createdAt: environments.createdAt, + env: environments.env, + projectId: environments.projectId, + isDefault: environments.isDefault, + }) + .from(environments) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(environments.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(environments) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), }); diff --git a/apps/dokploy/server/api/routers/mariadb.ts b/apps/dokploy/server/api/routers/mariadb.ts index bddc71b096..567cd4ad86 100644 --- a/apps/dokploy/server/api/routers/mariadb.ts +++ b/apps/dokploy/server/api/routers/mariadb.ts @@ -8,6 +8,7 @@ import { findBackupsByDbId, findEnvironmentById, findMariadbById, + findMemberById, findProjectById, IS_CLOUD, rebuildDatabase, @@ -22,7 +23,7 @@ import { import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; import { observable } from "@trpc/server/observable"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -37,6 +38,7 @@ import { apiUpdateMariaDB, mariadb as mariadbTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; export const mariadbRouter = createTRPCRouter({ create: protectedProcedure @@ -446,4 +448,102 @@ export const mariadbRouter = createTRPCRouter({ await rebuildDatabase(mariadb.mariadbId, "mariadb"); return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(mariadbTable.environmentId, input.environmentId), + ); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(mariadbTable.name, term), + ilike(mariadbTable.appName, term), + ilike(mariadbTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push(ilike(mariadbTable.name, `%${input.name.trim()}%`)); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(mariadbTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike( + mariadbTable.description ?? "", + `%${input.description.trim()}%`, + ), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${mariadbTable.mariadbId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + mariadbId: mariadbTable.mariadbId, + name: mariadbTable.name, + appName: mariadbTable.appName, + description: mariadbTable.description, + environmentId: mariadbTable.environmentId, + applicationStatus: mariadbTable.applicationStatus, + createdAt: mariadbTable.createdAt, + }) + .from(mariadbTable) + .innerJoin( + environments, + eq(mariadbTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(mariadbTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(mariadbTable) + .innerJoin( + environments, + eq(mariadbTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/mongo.ts b/apps/dokploy/server/api/routers/mongo.ts index e8454c8a41..ec0a4041c7 100644 --- a/apps/dokploy/server/api/routers/mongo.ts +++ b/apps/dokploy/server/api/routers/mongo.ts @@ -7,6 +7,7 @@ import { deployMongo, findBackupsByDbId, findEnvironmentById, + findMemberById, findMongoById, findProjectById, IS_CLOUD, @@ -21,7 +22,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -36,6 +37,7 @@ import { apiUpdateMongo, mongo as mongoTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; export const mongoRouter = createTRPCRouter({ create: protectedProcedure @@ -476,4 +478,97 @@ export const mongoRouter = createTRPCRouter({ return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push(eq(mongoTable.environmentId, input.environmentId)); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(mongoTable.name, term), + ilike(mongoTable.appName, term), + ilike(mongoTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push(ilike(mongoTable.name, `%${input.name.trim()}%`)); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(mongoTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(mongoTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${mongoTable.mongoId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + mongoId: mongoTable.mongoId, + name: mongoTable.name, + appName: mongoTable.appName, + description: mongoTable.description, + environmentId: mongoTable.environmentId, + applicationStatus: mongoTable.applicationStatus, + createdAt: mongoTable.createdAt, + }) + .from(mongoTable) + .innerJoin( + environments, + eq(mongoTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(mongoTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(mongoTable) + .innerJoin( + environments, + eq(mongoTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/mysql.ts b/apps/dokploy/server/api/routers/mysql.ts index b1bc10f32e..5a00ef0d01 100644 --- a/apps/dokploy/server/api/routers/mysql.ts +++ b/apps/dokploy/server/api/routers/mysql.ts @@ -7,6 +7,7 @@ import { deployMySql, findBackupsByDbId, findEnvironmentById, + findMemberById, findMySqlById, findProjectById, IS_CLOUD, @@ -21,7 +22,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -34,7 +35,9 @@ import { apiSaveEnvironmentVariablesMySql, apiSaveExternalPortMySql, apiUpdateMySql, + environments, mysql as mysqlTable, + projects, } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; @@ -471,4 +474,97 @@ export const mysqlRouter = createTRPCRouter({ return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push(eq(mysqlTable.environmentId, input.environmentId)); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(mysqlTable.name, term), + ilike(mysqlTable.appName, term), + ilike(mysqlTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push(ilike(mysqlTable.name, `%${input.name.trim()}%`)); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(mysqlTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${mysqlTable.mysqlId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + mysqlId: mysqlTable.mysqlId, + name: mysqlTable.name, + appName: mysqlTable.appName, + description: mysqlTable.description, + environmentId: mysqlTable.environmentId, + applicationStatus: mysqlTable.applicationStatus, + createdAt: mysqlTable.createdAt, + }) + .from(mysqlTable) + .innerJoin( + environments, + eq(mysqlTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(mysqlTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(mysqlTable) + .innerJoin( + environments, + eq(mysqlTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/postgres.ts b/apps/dokploy/server/api/routers/postgres.ts index d9f69330c3..48de9d5a2a 100644 --- a/apps/dokploy/server/api/routers/postgres.ts +++ b/apps/dokploy/server/api/routers/postgres.ts @@ -7,6 +7,7 @@ import { deployPostgres, findBackupsByDbId, findEnvironmentById, + findMemberById, findPostgresById, findProjectById, getMountPath, @@ -22,7 +23,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -37,6 +38,7 @@ import { apiUpdatePostgres, postgres as postgresTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; import { cancelJobs } from "@/server/utils/backup"; export const postgresRouter = createTRPCRouter({ @@ -483,4 +485,104 @@ export const postgresRouter = createTRPCRouter({ return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push( + eq(postgresTable.environmentId, input.environmentId), + ); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(postgresTable.name, term), + ilike(postgresTable.appName, term), + ilike(postgresTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push( + ilike(postgresTable.name, `%${input.name.trim()}%`), + ); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(postgresTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike( + postgresTable.description ?? "", + `%${input.description.trim()}%`, + ), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${postgresTable.postgresId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + postgresId: postgresTable.postgresId, + name: postgresTable.name, + appName: postgresTable.appName, + description: postgresTable.description, + environmentId: postgresTable.environmentId, + applicationStatus: postgresTable.applicationStatus, + createdAt: postgresTable.createdAt, + }) + .from(postgresTable) + .innerJoin( + environments, + eq(postgresTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(postgresTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(postgresTable) + .innerJoin( + environments, + eq(postgresTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/api/routers/project.ts b/apps/dokploy/server/api/routers/project.ts index b1df689519..e270ee4b40 100644 --- a/apps/dokploy/server/api/routers/project.ts +++ b/apps/dokploy/server/api/routers/project.ts @@ -34,10 +34,14 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { and, desc, eq, sql } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { AnyPgColumn } from "drizzle-orm/pg-core"; import { z } from "zod"; -import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { + adminProcedure, + createTRPCRouter, + protectedProcedure, +} from "@/server/api/trpc"; import { apiCreateProject, apiFindOneProject, @@ -219,31 +223,69 @@ export const projectRouter = createTRPCRouter({ applications.applicationId, accessedServices, ), - with: { domains: true }, + columns: { + applicationId: true, + name: true, + applicationStatus: true, + }, }, mariadb: { where: buildServiceFilter(mariadb.mariadbId, accessedServices), + columns: { + mariadbId: true, + name: true, + applicationStatus: true, + }, }, mongo: { where: buildServiceFilter(mongo.mongoId, accessedServices), + columns: { + mongoId: true, + name: true, + applicationStatus: true, + }, }, mysql: { where: buildServiceFilter(mysql.mysqlId, accessedServices), + columns: { + mysqlId: true, + name: true, + applicationStatus: true, + }, }, postgres: { where: buildServiceFilter( postgres.postgresId, accessedServices, ), + columns: { + postgresId: true, + name: true, + applicationStatus: true, + }, }, redis: { where: buildServiceFilter(redis.redisId, accessedServices), + columns: { + redisId: true, + name: true, + applicationStatus: true, + }, }, compose: { where: buildServiceFilter(compose.composeId, accessedServices), - with: { domains: true }, + columns: { + composeId: true, + name: true, + composeStatus: true, + }, }, }, + columns: { + environmentId: true, + isDefault: true, + name: true, + }, }, }, orderBy: desc(projects.createdAt), @@ -255,21 +297,50 @@ export const projectRouter = createTRPCRouter({ environments: { with: { applications: { - with: { - domains: true, + columns: { + applicationId: true, + name: true, + applicationStatus: true, + }, + }, + mariadb: { + columns: { + mariadbId: true, + }, + }, + mongo: { + columns: { + mongoId: true, + }, + }, + mysql: { + columns: { + mysqlId: true, + }, + }, + postgres: { + columns: { + postgresId: true, + }, + }, + redis: { + columns: { + redisId: true, }, }, - mariadb: true, - mongo: true, - mysql: true, - postgres: true, - redis: true, compose: { - with: { - domains: true, + columns: { + composeId: true, + name: true, + composeStatus: true, }, }, }, + columns: { + name: true, + environmentId: true, + isDefault: true, + }, }, }, where: eq(projects.organizationId, ctx.session.activeOrganizationId), @@ -277,6 +348,183 @@ export const projectRouter = createTRPCRouter({ }); }), + /** All projects with full environments and services for the admin permissions UI. Admin only. */ + allForPermissions: adminProcedure.query(async ({ ctx }) => { + return await db.query.projects.findMany({ + where: eq(projects.organizationId, ctx.session.activeOrganizationId), + orderBy: desc(projects.createdAt), + columns: { + projectId: true, + name: true, + }, + with: { + environments: { + columns: { + environmentId: true, + name: true, + isDefault: true, + }, + with: { + applications: { + columns: { + applicationId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + mariadb: { + columns: { + mariadbId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + postgres: { + columns: { + postgresId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + mysql: { + columns: { + mysqlId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + mongo: { + columns: { + mongoId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + redis: { + columns: { + redisId: true, + appName: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, + }, + }, + compose: { + columns: { + composeId: true, + appName: true, + name: true, + createdAt: true, + composeStatus: true, + description: true, + serverId: true, + }, + }, + }, + }, + }, + }); + }), + + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + description: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(projects.name, term), + ilike(projects.description ?? "", term), + )!, + ); + } + + if (input.name?.trim()) { + baseConditions.push(ilike(projects.name, `%${input.name.trim()}%`)); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(projects.description ?? "", `%${input.description.trim()}%`), + ); + } + + if (ctx.user.role === "member") { + const { accessedProjects } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedProjects.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${projects.projectId} IN (${sql.join( + accessedProjects.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + + const where = and(...baseConditions); + + const [items, countResult] = await Promise.all([ + db.query.projects.findMany({ + where, + limit: input.limit, + offset: input.offset, + orderBy: desc(projects.createdAt), + columns: { + projectId: true, + name: true, + description: true, + createdAt: true, + organizationId: true, + env: true, + }, + }), + db + .select({ count: sql`count(*)::int` }) + .from(projects) + .where(where), + ]); + + return { + items, + total: countResult[0]?.count ?? 0, + }; + }), + remove: protectedProcedure .input(apiRemoveProject) .mutation(async ({ input, ctx }) => { diff --git a/apps/dokploy/server/api/routers/redis.ts b/apps/dokploy/server/api/routers/redis.ts index dfeff82bbc..94939bd208 100644 --- a/apps/dokploy/server/api/routers/redis.ts +++ b/apps/dokploy/server/api/routers/redis.ts @@ -6,6 +6,7 @@ import { createRedis, deployRedis, findEnvironmentById, + findMemberById, findProjectById, findRedisById, IS_CLOUD, @@ -20,7 +21,7 @@ import { } from "@dokploy/server"; import { db } from "@dokploy/server/db"; import { TRPCError } from "@trpc/server"; -import { eq } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { @@ -35,6 +36,7 @@ import { apiUpdateRedis, redis as redisTable, } from "@/server/db/schema"; +import { environments, projects } from "@/server/db/schema"; export const redisRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateRedis) @@ -450,4 +452,97 @@ export const redisRouter = createTRPCRouter({ await rebuildDatabase(redis.redisId, "redis"); return true; }), + search: protectedProcedure + .input( + z.object({ + q: z.string().optional(), + name: z.string().optional(), + appName: z.string().optional(), + description: z.string().optional(), + projectId: z.string().optional(), + environmentId: z.string().optional(), + limit: z.number().min(1).max(100).default(20), + offset: z.number().min(0).default(0), + }), + ) + .query(async ({ ctx, input }) => { + const baseConditions = [ + eq(projects.organizationId, ctx.session.activeOrganizationId), + ]; + if (input.projectId) { + baseConditions.push(eq(environments.projectId, input.projectId)); + } + if (input.environmentId) { + baseConditions.push(eq(redisTable.environmentId, input.environmentId)); + } + if (input.q?.trim()) { + const term = `%${input.q.trim()}%`; + baseConditions.push( + or( + ilike(redisTable.name, term), + ilike(redisTable.appName, term), + ilike(redisTable.description ?? "", term), + )!, + ); + } + if (input.name?.trim()) { + baseConditions.push(ilike(redisTable.name, `%${input.name.trim()}%`)); + } + if (input.appName?.trim()) { + baseConditions.push( + ilike(redisTable.appName, `%${input.appName.trim()}%`), + ); + } + if (input.description?.trim()) { + baseConditions.push( + ilike(redisTable.description ?? "", `%${input.description.trim()}%`), + ); + } + if (ctx.user.role === "member") { + const { accessedServices } = await findMemberById( + ctx.user.id, + ctx.session.activeOrganizationId, + ); + if (accessedServices.length === 0) return { items: [], total: 0 }; + baseConditions.push( + sql`${redisTable.redisId} IN (${sql.join( + accessedServices.map((id) => sql`${id}`), + sql`, `, + )})`, + ); + } + const where = and(...baseConditions); + const [items, countResult] = await Promise.all([ + db + .select({ + redisId: redisTable.redisId, + name: redisTable.name, + appName: redisTable.appName, + description: redisTable.description, + environmentId: redisTable.environmentId, + applicationStatus: redisTable.applicationStatus, + createdAt: redisTable.createdAt, + }) + .from(redisTable) + .innerJoin( + environments, + eq(redisTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where) + .orderBy(desc(redisTable.createdAt)) + .limit(input.limit) + .offset(input.offset), + db + .select({ count: sql`count(*)::int` }) + .from(redisTable) + .innerJoin( + environments, + eq(redisTable.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where(where), + ]); + return { items, total: countResult[0]?.count ?? 0 }; + }), }); diff --git a/apps/dokploy/server/utils/deploy.ts b/apps/dokploy/server/utils/deploy.ts index f4591e3b3a..bb429002a7 100644 --- a/apps/dokploy/server/utils/deploy.ts +++ b/apps/dokploy/server/utils/deploy.ts @@ -50,3 +50,34 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => { throw error; } }; + +export type QueueJobRow = { + id: string; + name?: string; + data: Record; + timestamp?: number; + processedOn?: number; + finishedOn?: number; + failedReason?: string; + state: string; +}; + +export const fetchDeployApiJobs = async ( + serverId: string, +): Promise => { + try { + const res = await fetch( + `${process.env.SERVER_URL}/jobs?serverId=${encodeURIComponent(serverId)}`, + { + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.API_KEY || "NO-DEFINED", + }, + }, + ); + if (!res.ok) return []; + return (await res.json()) as QueueJobRow[]; + } catch { + return []; + } +}; diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index fd6e597dc4..891dc553f9 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -10,7 +10,11 @@ import { type apiCreateDeploymentSchedule, type apiCreateDeploymentServer, type apiCreateDeploymentVolumeBackup, + applications, + compose, deployments, + environments, + projects, } from "@dokploy/server/db/schema"; import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory"; import { @@ -19,7 +23,7 @@ import { } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { format } from "date-fns"; -import { desc, eq } from "drizzle-orm"; +import { desc, eq, and, inArray, or, sql } from "drizzle-orm"; import type { z } from "zod"; import { type Application, @@ -38,6 +42,41 @@ import { findScheduleById } from "./schedule"; import { findServerById, type Server } from "./server"; import { findVolumeBackupById } from "./volume-backups"; +export type ServicePath = { href: string | null; label: string }; + +export async function resolveServicePath( + orgId: string, + data: Record, +): Promise { + try { + const applicationId = data?.applicationId as string | undefined; + const composeId = data?.composeId as string | undefined; + if (applicationId) { + const app = await findApplicationById(applicationId); + if (app.environment.project.organizationId !== orgId) { + return { href: null, label: "Application" }; + } + return { + href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`, + label: "Application", + }; + } + if (composeId) { + const comp = await findComposeById(composeId); + if (comp.environment.project.organizationId !== orgId) { + return { href: null, label: "Compose" }; + } + return { + href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`, + label: "Compose", + }; + } + } catch { + // not found or unauthorized + } + return { href: null, label: "—" }; +} + export type Deployment = typeof deployments.$inferSelect; export const findDeploymentById = async (deploymentId: string) => { @@ -738,6 +777,135 @@ export const findAllDeploymentsByComposeId = async (composeId: string) => { return deploymentsList; }; +const centralizedDeploymentsWith = { + application: { + columns: { applicationId: true, name: true, appName: true }, + with: { + environment: { + columns: { environmentId: true, name: true }, + with: { + project: { + columns: { projectId: true, name: true }, + }, + }, + }, + server: { + columns: { serverId: true, name: true, serverType: true }, + }, + buildServer: { + columns: { serverId: true, name: true, serverType: true }, + }, + }, + }, + compose: { + columns: { composeId: true, name: true, appName: true }, + with: { + environment: { + columns: { environmentId: true, name: true }, + with: { + project: { + columns: { projectId: true, name: true }, + }, + }, + }, + server: { + columns: { serverId: true, name: true, serverType: true }, + }, + }, + }, + server: { + columns: { serverId: true, name: true, serverType: true }, + }, + buildServer: { + columns: { serverId: true, name: true, serverType: true }, + }, +} as const; + +async function getApplicationIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise { + const rows = await db + .select({ applicationId: applications.applicationId }) + .from(applications) + .innerJoin( + environments, + eq(applications.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(applications.applicationId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ); + return rows.map((r) => r.applicationId); +} + +async function getComposeIdsInOrg( + orgId: string, + accessedServices: string[] | null, +): Promise { + const rows = await db + .select({ composeId: compose.composeId }) + .from(compose) + .innerJoin( + environments, + eq(compose.environmentId, environments.environmentId), + ) + .innerJoin(projects, eq(environments.projectId, projects.projectId)) + .where( + accessedServices !== null + ? and( + eq(projects.organizationId, orgId), + inArray(compose.composeId, accessedServices), + ) + : eq(projects.organizationId, orgId), + ); + return rows.map((r) => r.composeId); +} + +/** + * All deployments for applications and compose in the org. + * Pass accessedServices for members (only those services), null for owner/admin. + */ +export const findAllDeploymentsCentralized = async ( + orgId: string, + accessedServices: string[] | null, +) => { + if (accessedServices !== null && accessedServices.length === 0) { + return []; + } + + const [appIds, compIds] = await Promise.all([ + getApplicationIdsInOrg(orgId, accessedServices), + getComposeIdsInOrg(orgId, accessedServices), + ]); + + if (appIds.length === 0 && compIds.length === 0) { + return []; + } + + const conditions = [ + ...(appIds.length > 0 ? [inArray(deployments.applicationId, appIds)] : []), + ...(compIds.length > 0 ? [inArray(deployments.composeId, compIds)] : []), + ]; + const whereClause = + conditions.length === 0 + ? sql`1 = 0` + : conditions.length === 1 + ? conditions[0] + : or(...conditions); + + return db.query.deployments.findMany({ + where: whereClause, + orderBy: desc(deployments.createdAt), + with: centralizedDeploymentsWith, + }); +}; + export const updateDeployment = async ( deploymentId: string, deploymentData: Partial, diff --git a/packages/server/src/services/environment.ts b/packages/server/src/services/environment.ts index d37e7b789f..9be18a2871 100644 --- a/packages/server/src/services/environment.ts +++ b/packages/server/src/services/environment.ts @@ -34,42 +34,139 @@ export const createEnvironment = async ( export const findEnvironmentById = async (environmentId: string) => { const environment = await db.query.environments.findFirst({ where: eq(environments.environmentId, environmentId), + columns: { + name: true, + description: true, + environmentId: true, + isDefault: true, + projectId: true, + env: true, + }, with: { applications: { with: { - deployments: true, - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + name: true, + applicationId: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, mariadb: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + mariadbId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, mongo: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + mongoId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, mysql: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + mysqlId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, postgres: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + postgresId: true, + name: true, + description: true, + createdAt: true, + applicationStatus: true, + serverId: true, }, }, redis: { with: { - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + redisId: true, + name: true, + createdAt: true, + applicationStatus: true, + description: true, + serverId: true, }, }, compose: { with: { - deployments: true, - server: true, + server: { + columns: { + name: true, + serverId: true, + }, + }, + }, + columns: { + composeId: true, + name: true, + createdAt: true, + composeStatus: true, + description: true, + serverId: true, }, }, project: true, @@ -98,6 +195,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => { compose: true, project: true, }, + columns: { + name: true, + description: true, + environmentId: true, + isDefault: true, + }, }); return projectEnvironments; }; @@ -169,6 +272,7 @@ export const duplicateEnvironment = async ( name: input.name, description: input.description || originalEnvironment.description, projectId: originalEnvironment.projectId, + env: originalEnvironment.env, }) .returning() .then((value) => value[0]);