diff --git a/apps/api/.env.example b/apps/api/.env.example index 647e2a077..01edbec0c 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 8ddb56dec..0bb6e1401 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 000000000..495e30ca3 --- /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 000000000..770d4efd0 --- /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 000000000..e46b33a6a --- /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/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 6fd798955..bbd612d92 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 && ( <> { + 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/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 5aac2e8a7..7b2b414c0 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/utils/deploy.ts b/apps/dokploy/server/utils/deploy.ts index f4591e3b3..bb429002a 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 fd6e597dc..891dc553f 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,