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..414ee7d9d --- /dev/null +++ b/apps/api/src/service.ts @@ -0,0 +1,239 @@ +import { logger } from "./logger.js"; + +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/api/src/utils.ts b/apps/api/src/utils.ts index 13708006a..b99ec492a 100644 --- a/apps/api/src/utils.ts +++ b/apps/api/src/utils.ts @@ -9,7 +9,7 @@ import { updateCompose, updatePreviewDeployment, } from "@dokploy/server"; -import type { DeployJob } from "./schema"; +import type { DeployJob } from "./schema.js"; export const deploy = async (job: DeployJob) => { try { 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/organization/handle-organization.tsx b/apps/dokploy/components/dashboard/organization/handle-organization.tsx index c676e0233..d9c7c8ce5 100644 --- a/apps/dokploy/components/dashboard/organization/handle-organization.tsx +++ b/apps/dokploy/components/dashboard/organization/handle-organization.tsx @@ -24,7 +24,6 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; const organizationSchema = z.object({ @@ -55,8 +54,6 @@ export function AddOrganization({ organizationId }: Props) { const { mutateAsync, isLoading } = organizationId ? api.organization.update.useMutation() : api.organization.create.useMutation(); - const { refetch: refetchActiveOrganization } = - authClient.useActiveOrganization(); const form = useForm({ resolver: zodResolver(organizationSchema), @@ -89,7 +86,7 @@ export function AddOrganization({ organizationId }: Props) { utils.organization.all.invalidate(); if (organizationId) { utils.organization.one.invalidate({ organizationId }); - refetchActiveOrganization(); + utils.organization.active.invalidate(); } setOpen(false); }) diff --git a/apps/dokploy/components/dashboard/search-command.tsx b/apps/dokploy/components/dashboard/search-command.tsx index 6fd798955..b98099b5f 100644 --- a/apps/dokploy/components/dashboard/search-command.tsx +++ b/apps/dokploy/components/dashboard/search-command.tsx @@ -23,7 +23,6 @@ import { CommandList, CommandSeparator, } from "@/components/ui/command"; -import { authClient } from "@/lib/auth-client"; import { api } from "@/utils/api"; import { StatusTooltip } from "../shared/status-tooltip"; @@ -56,7 +55,7 @@ export const SearchCommand = () => { const router = useRouter(); const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); - const { data: session } = authClient.useSession(); + const { data: session } = api.user.session.useQuery(); const { data } = api.project.all.useQuery(undefined, { enabled: !!session, }); @@ -174,6 +173,14 @@ export const SearchCommand = () => { > Projects + { + router.push("/dashboard/deployments"); + setOpen(false); + }} + > + Deployments + {!isCloud && ( <> { const [isOpen, setIsOpen] = useState(false); - const { data: activeOrganization } = authClient.useActiveOrganization(); - const { data: session } = authClient.useSession(); + const { data: activeOrganization } = api.organization.active.useQuery(); + + const { data: session } = api.user.session.useQuery(); const { data } = api.user.get.useQuery(); const [manifest, setManifest] = useState(""); const [isOrganization, setIsOrganization] = useState(false); @@ -52,7 +52,7 @@ export const AddGithubProvider = () => { ); setManifest(manifest); - }, [data?.id]); + }, [activeOrganization?.id, session?.user?.id]); return ( @@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
diff --git a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx index e778f2e96..076f7abf9 100644 --- a/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx +++ b/apps/dokploy/components/dashboard/settings/users/add-invitation.tsx @@ -55,7 +55,7 @@ export const AddInvitation = () => { api.notification.getEmailProviders.useQuery(); const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation(); const [error, setError] = useState(null); - const { data: activeOrganization } = authClient.useActiveOrganization(); + const { data: activeOrganization } = api.organization.active.useQuery(); const form = useForm({ defaultValues: { diff --git a/apps/dokploy/components/dashboard/settings/users/show-users.tsx b/apps/dokploy/components/dashboard/settings/users/show-users.tsx index a52cfda6d..019e1e6bc 100644 --- a/apps/dokploy/components/dashboard/settings/users/show-users.tsx +++ b/apps/dokploy/components/dashboard/settings/users/show-users.tsx @@ -37,7 +37,7 @@ export const ShowUsers = () => { const { mutateAsync } = api.user.remove.useMutation(); const utils = api.useUtils(); - const { data: session } = authClient.useSession(); + const { data: session } = api.user.session.useQuery(); return (
diff --git a/apps/dokploy/components/layouts/side.tsx b/apps/dokploy/components/layouts/side.tsx index 78ee49573..5f1811468 100644 --- a/apps/dokploy/components/layouts/side.tsx +++ b/apps/dokploy/components/layouts/side.tsx @@ -26,6 +26,7 @@ import { Package, Palette, PieChart, + Rocket, Server, ShieldCheck, Star, @@ -146,6 +147,12 @@ const MENU: Menu = { url: "/dashboard/projects", icon: Folder, }, + { + isSingle: true, + title: "Deployments", + url: "/dashboard/deployments", + icon: Rocket, + }, { isSingle: true, title: "Monitoring", @@ -552,7 +559,7 @@ function SidebarLogo() { enabled: !isCloud, }); const { data: user } = api.user.get.useQuery(); - const { data: session } = authClient.useSession(); + const { data: session } = api.user.session.useQuery(); const { data: organizations, refetch, @@ -563,8 +570,7 @@ function SidebarLogo() { const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } = api.organization.setDefault.useMutation(); const { isMobile } = useSidebar(); - const { data: activeOrganization } = authClient.useActiveOrganization(); - const _utils = api.useUtils(); + const { data: activeOrganization } = api.organization.active.useQuery(); const { data: invitations, refetch: refetchInvitations } = api.user.getInvitations.useQuery(); diff --git a/apps/dokploy/components/shared/code-editor.tsx b/apps/dokploy/components/shared/code-editor.tsx index ae776c97a..d65d39c32 100644 --- a/apps/dokploy/components/shared/code-editor.tsx +++ b/apps/dokploy/components/shared/code-editor.tsx @@ -10,7 +10,8 @@ import { yaml } from "@codemirror/lang-yaml"; import { StreamLanguage } from "@codemirror/language"; import { properties } from "@codemirror/legacy-modes/mode/properties"; import { shell } from "@codemirror/legacy-modes/mode/shell"; -import { EditorView } from "@codemirror/view"; +import { search, searchKeymap } from "@codemirror/search"; +import { EditorView, keymap } from "@codemirror/view"; import { githubDark, githubLight } from "@uiw/codemirror-theme-github"; import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror"; import { useTheme } from "next-themes"; @@ -156,6 +157,8 @@ export const CodeEditor = ({ }} theme={resolvedTheme === "dark" ? githubDark : githubLight} extensions={[ + search(), + keymap.of(searchKeymap), language === "yaml" ? yaml() : language === "json" diff --git a/apps/dokploy/package.json b/apps/dokploy/package.json index fa3db1922..a0d6a2958 100644 --- a/apps/dokploy/package.json +++ b/apps/dokploy/package.json @@ -1,6 +1,6 @@ { "name": "dokploy", - "version": "v0.27.1", + "version": "v0.28.5", "private": true, "license": "Apache-2.0", "type": "module", @@ -54,7 +54,8 @@ "@codemirror/lang-yaml": "^6.1.2", "@codemirror/language": "^6.11.0", "@codemirror/legacy-modes": "6.4.0", - "@codemirror/view": "6.29.0", + "@codemirror/search": "^6.6.0", + "@codemirror/view": "^6.39.15", "@dokploy/server": "workspace:*", "@dokploy/trpc-openapi": "0.0.4", "@faker-js/faker": "^8.4.1", diff --git a/apps/dokploy/pages/api/providers/github/setup.ts b/apps/dokploy/pages/api/providers/github/setup.ts index bd540058d..ff5a4a351 100644 --- a/apps/dokploy/pages/api/providers/github/setup.ts +++ b/apps/dokploy/pages/api/providers/github/setup.ts @@ -10,22 +10,29 @@ type Query = { state: string; installation_id: string; setup_action: string; - userId: string; }; export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { - const { code, state, installation_id, userId }: Query = req.query as Query; + const { code, state, installation_id }: Query = req.query as Query; if (!code) { return res.status(400).json({ error: "Missing code parameter" }); } - const [action, value] = state?.split(":"); - // Value could be the organizationId or the githubProviderId + const [action, ...rest] = state?.split(":"); + // For gh_init: rest[0] = organizationId, rest[1] = userId + // For gh_setup: rest[0] = githubProviderId if (action === "gh_init") { + const organizationId = rest[0]; + const userId = rest[1] || (req.query.userId as string); + + if (!userId) { + return res.status(400).json({ error: "Missing userId parameter" }); + } + const octokit = new Octokit({}); const { data } = await octokit.request( "POST /app-manifests/{code}/conversions", @@ -44,7 +51,7 @@ export default async function handler( githubWebhookSecret: data.webhook_secret, githubPrivateKey: data.pem, }, - value as string, + organizationId as string, userId, ); } else if (action === "gh_setup") { @@ -53,7 +60,7 @@ export default async function handler( .set({ githubInstallationId: installation_id, }) - .where(eq(github.githubId, value as string)) + .where(eq(github.githubId, rest[0] as string)) .returning(); } diff --git a/apps/dokploy/pages/dashboard/deployments.tsx b/apps/dokploy/pages/dashboard/deployments.tsx new file mode 100644 index 000000000..744301abf --- /dev/null +++ b/apps/dokploy/pages/dashboard/deployments.tsx @@ -0,0 +1,94 @@ +import { validateRequest } from "@dokploy/server/lib/auth"; +import { Rocket } from "lucide-react"; +import type { GetServerSidePropsContext } from "next"; +import { useRouter } from "next/router"; +import type { ReactElement } from "react"; +import { ShowDeploymentsTable } from "@/components/dashboard/deployments/show-deployments-table"; +import { ShowQueueTable } from "@/components/dashboard/deployments/show-queue-table"; +import { DashboardLayout } from "@/components/layouts/dashboard-layout"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +const TAB_VALUES = ["deployments", "queue"] as const; +type TabValue = (typeof TAB_VALUES)[number]; + +function isValidTab(t: string): t is TabValue { + return TAB_VALUES.includes(t as TabValue); +} + +function DeploymentsPage() { + const router = useRouter(); + const tab = + router.query.tab && isValidTab(router.query.tab as string) + ? (router.query.tab as TabValue) + : "deployments"; + + const setTab = (value: string) => { + 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 af901311e..00ca67a7e 100644 --- a/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx +++ b/apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx @@ -785,7 +785,7 @@ const EnvironmentPage = ( } if (success > 0) { toast.success( - `${success} service${success !== 1 ? "s" : ""} deployed successfully`, + `${success} service${success !== 1 ? "s" : ""} queued for deployment`, ); } if (failed > 0) { diff --git a/apps/dokploy/server/api/routers/deployment.ts b/apps/dokploy/server/api/routers/deployment.ts index 9004a0a05..71d34f6fc 100644 --- a/apps/dokploy/server/api/routers/deployment.ts +++ b/apps/dokploy/server/api/routers/deployment.ts @@ -4,10 +4,15 @@ import { findAllDeploymentsByApplicationId, findAllDeploymentsByComposeId, findAllDeploymentsByServerId, + findAllDeploymentsCentralized, findApplicationById, findComposeById, findDeploymentById, + findMemberById, findServerById, + IS_CLOUD, + removeDeployment, + resolveServicePath, updateDeploymentStatus, } from "@dokploy/server"; import { TRPCError } from "@trpc/server"; @@ -20,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({ @@ -67,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) @@ -78,10 +143,8 @@ export const deploymentRouter = createTRPCRouter({ rollback: true, }, }); - return deploymentsList; }), - killProcess: protectedProcedure .input( z.object({ diff --git a/apps/dokploy/server/api/routers/mount.ts b/apps/dokploy/server/api/routers/mount.ts index 814d3d392..94c8ee69b 100644 --- a/apps/dokploy/server/api/routers/mount.ts +++ b/apps/dokploy/server/api/routers/mount.ts @@ -21,8 +21,7 @@ export const mountRouter = createTRPCRouter({ create: protectedProcedure .input(apiCreateMount) .mutation(async ({ input }) => { - await createMount(input); - return true; + return await createMount(input); }), remove: protectedProcedure .input(apiRemoveMount) diff --git a/apps/dokploy/server/api/routers/organization.ts b/apps/dokploy/server/api/routers/organization.ts index 834c8a399..887ecfe78 100644 --- a/apps/dokploy/server/api/routers/organization.ts +++ b/apps/dokploy/server/api/routers/organization.ts @@ -355,4 +355,13 @@ export const organizationRouter = createTRPCRouter({ return { success: true }; }), + active: protectedProcedure.query(async ({ ctx }) => { + if (!ctx.session.activeOrganizationId) { + return null; + } + + return await db.query.organization.findFirst({ + where: eq(organization.id, ctx.session.activeOrganizationId), + }); + }), }); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index bb7269fa7..4ab7118c7 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -149,12 +149,12 @@ export const settingsRouter = createTRPCRouter({ // Check if port 8080 is already in use before enabling dashboard const portCheck = await checkPortInUse(8080, input.serverId); if (portCheck.isInUse) { - const conflictingContainer = portCheck.conflictingContainer - ? ` by container "${portCheck.conflictingContainer}"` + const conflictInfo = portCheck.conflictingContainer + ? ` by ${portCheck.conflictingContainer}` : ""; throw new TRPCError({ code: "CONFLICT", - message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`, + message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`, }); } newPorts.push({ diff --git a/apps/dokploy/server/api/routers/user.ts b/apps/dokploy/server/api/routers/user.ts index 3f217ceed..f67b62925 100644 --- a/apps/dokploy/server/api/routers/user.ts +++ b/apps/dokploy/server/api/routers/user.ts @@ -101,6 +101,16 @@ export const userRouter = createTRPCRouter({ return memberResult; }), + session: protectedProcedure.query(async ({ ctx }) => { + return { + user: { + id: ctx.user.id, + }, + session: { + activeOrganizationId: ctx.session.activeOrganizationId, + }, + }; + }), get: protectedProcedure.query(async ({ ctx }) => { const memberResult = await db.query.member.findFirst({ where: and( 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/apps/monitoring/database/containers.go b/apps/monitoring/database/containers.go index 4e41f5fae..4fc290d35 100644 --- a/apps/monitoring/database/containers.go +++ b/apps/monitoring/database/containers.go @@ -58,13 +58,13 @@ func (db *DB) GetLastNContainerMetrics(containerName string, limit int) ([]Conta WITH recent_metrics AS ( SELECT metrics_json FROM container_metrics - WHERE container_name = ? + WHERE container_name = ? OR container_name LIKE ? ORDER BY timestamp DESC LIMIT ? ) SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC ` - rows, err := db.Query(query, containerName, limit) + rows, err := db.Query(query, containerName, containerName+".%", limit) if err != nil { return nil, err } @@ -98,12 +98,12 @@ func (db *DB) GetAllMetricsContainer(containerName string) ([]ContainerMetric, e WITH recent_metrics AS ( SELECT metrics_json FROM container_metrics - WHERE container_name = ? + WHERE container_name = ? OR container_name LIKE ? ORDER BY timestamp DESC ) SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC ` - rows, err := db.Query(query, containerName) + rows, err := db.Query(query, containerName, containerName+".%") if err != nil { return nil, err } diff --git a/packages/server/src/constants/index.ts b/packages/server/src/constants/index.ts index de0e48a2e..227abaef8 100644 --- a/packages/server/src/constants/index.ts +++ b/packages/server/src/constants/index.ts @@ -2,8 +2,24 @@ import path from "node:path"; import Docker from "dockerode"; export const IS_CLOUD = process.env.IS_CLOUD === "true"; +export const DOCKER_API_VERSION = process.env.DOCKER_API_VERSION; +export const DOCKER_HOST = process.env.DOCKER_HOST; +export const DOCKER_PORT = process.env.DOCKER_PORT + ? Number(process.env.DOCKER_PORT) + : undefined; + export const CLEANUP_CRON_JOB = "50 23 * * *"; -export const docker = new Docker(); +export const docker = new Docker({ + ...(DOCKER_API_VERSION && { + version: DOCKER_API_VERSION, + }), + ...(DOCKER_HOST && { + host: DOCKER_HOST, + }), + ...(DOCKER_PORT && { + port: DOCKER_PORT, + }), +}); // When not set, use the legacy default so 2FA remains working for users who // enabled it before BETTER_AUTH_SECRET was introduced . diff --git a/packages/server/src/db/schema/application.ts b/packages/server/src/db/schema/application.ts index 3d912654c..ce700dd92 100644 --- a/packages/server/src/db/schema/application.ts +++ b/packages/server/src/db/schema/application.ts @@ -362,12 +362,13 @@ const createSchema = createInsertSchema(applications, { previewPath: z.string().optional(), previewCertificateType: z.enum(["letsencrypt", "none", "custom"]).optional(), previewRequireCollaboratorPermissions: z.boolean().optional(), - watchPaths: z.array(z.string()).optional(), + watchPaths: z.array(z.string()).optional().optional(), previewLabels: z.array(z.string()).optional(), cleanCache: z.boolean().optional(), stopGracePeriodSwarm: z.bigint().nullable(), endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(), ulimitsSwarm: UlimitsSwarmSchema.nullable(), + enableSubmodules: z.boolean().optional(), }); export const apiCreateApplication = createSchema.pick({ @@ -432,13 +433,13 @@ export const apiSaveGithubProvider = createSchema owner: true, buildPath: true, githubId: true, - watchPaths: true, - enableSubmodules: true, }) .required() .extend({ triggerType: z.enum(["push", "tag"]).default("push"), - }); + }) + .required() + .merge(createSchema.pick({ enableSubmodules: true, watchPaths: true })); export const apiSaveGitlabProvider = createSchema .pick({ @@ -450,10 +451,9 @@ export const apiSaveGitlabProvider = createSchema gitlabId: true, gitlabProjectId: true, gitlabPathNamespace: true, - watchPaths: true, - enableSubmodules: true, }) - .required(); + .required() + .merge(createSchema.pick({ enableSubmodules: true, watchPaths: true })); export const apiSaveBitbucketProvider = createSchema .pick({ @@ -464,10 +464,9 @@ export const apiSaveBitbucketProvider = createSchema bitbucketRepositorySlug: true, bitbucketId: true, applicationId: true, - watchPaths: true, - enableSubmodules: true, }) - .required(); + .required() + .merge(createSchema.pick({ enableSubmodules: true, watchPaths: true })); export const apiSaveGiteaProvider = createSchema .pick({ @@ -477,10 +476,9 @@ export const apiSaveGiteaProvider = createSchema giteaOwner: true, giteaRepository: true, giteaId: true, - watchPaths: true, - enableSubmodules: true, }) - .required(); + .required() + .merge(createSchema.pick({ enableSubmodules: true, watchPaths: true })); export const apiSaveDockerProvider = createSchema .pick({ @@ -505,6 +503,7 @@ export const apiSaveGitProvider = createSchema .merge( createSchema.pick({ customGitSSHKeyId: true, + enableSubmodules: true, }), ); diff --git a/packages/server/src/db/schema/mount.ts b/packages/server/src/db/schema/mount.ts index 299f39caf..70d374dce 100644 --- a/packages/server/src/db/schema/mount.ts +++ b/packages/server/src/db/schema/mount.ts @@ -99,17 +99,15 @@ const createSchema = createInsertSchema(mounts, { mountPath: z.string().min(1), mountId: z.string().optional(), filePath: z.string().optional(), - serviceType: z - .enum([ - "application", - "postgres", - "mysql", - "mariadb", - "mongo", - "redis", - "compose", - ]) - .default("application"), + serviceType: z.enum([ + "application", + "postgres", + "mysql", + "mariadb", + "mongo", + "redis", + "compose", + ]), }); export type ServiceType = NonNullable< diff --git a/packages/server/src/services/admin.ts b/packages/server/src/services/admin.ts index d7e7e9a77..0e65c7d14 100644 --- a/packages/server/src/services/admin.ts +++ b/packages/server/src/services/admin.ts @@ -125,7 +125,37 @@ export const getTrustedOrigins = async () => { }, }); - if (members.length === 0) { + if (IS_CLOUD) { + const now = Date.now(); + if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) { + return trustedOriginsCache.data; + } + try { + const trustedOrigins = await runQuery(); + trustedOriginsCache = { + data: trustedOrigins, + expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS, + }; + return trustedOrigins; + } catch (error) { + console.error("Failed to fetch trusted origins:", error); + return trustedOriginsCache?.data ?? []; + } + } + + try { + return await runQuery(); + } catch (error) { + console.error("Failed to fetch trusted origins:", error); + return []; + } +}; + +export const getTrustedProviders = async () => { + try { + const providers = await db.query.ssoProvider.findMany(); + return providers.map((provider) => provider.providerId); + } catch (error) { return []; } diff --git a/packages/server/src/services/deployment.ts b/packages/server/src/services/deployment.ts index 6244ec8eb..b9d0d2637 100644 --- a/packages/server/src/services/deployment.ts +++ b/packages/server/src/services/deployment.ts @@ -10,13 +10,18 @@ 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 { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; import { TRPCError } from "@trpc/server"; import { format } from "date-fns"; -import { desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray, or, sql } from "drizzle-orm"; +import type { z } from "zod"; import { type Application, findApplicationById, @@ -34,6 +39,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) => { @@ -74,12 +114,12 @@ export const createDeployment = async ( >, ) => { const application = await findApplicationById(deployment.applicationId); + await removeLastTenDeployments( + deployment.applicationId, + "application", + application.serverId, + ); try { - await removeLastTenDeployments( - deployment.applicationId, - "application", - application.serverId, - ); const serverId = application.buildServerId || application.serverId; const { LOGS_PATH } = paths(!!serverId); @@ -157,13 +197,12 @@ export const createDeploymentPreview = async ( const previewDeployment = await findPreviewDeploymentById( deployment.previewDeploymentId, ); + await removeLastTenDeployments( + deployment.previewDeploymentId, + "previewDeployment", + previewDeployment?.application?.serverId, + ); try { - await removeLastTenDeployments( - deployment.previewDeploymentId, - "previewDeployment", - previewDeployment?.application?.serverId, - ); - const appName = `${previewDeployment.appName}`; const { LOGS_PATH } = paths(!!previewDeployment?.application?.serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); @@ -238,12 +277,12 @@ export const createDeploymentCompose = async ( >, ) => { const compose = await findComposeById(deployment.composeId); + await removeLastTenDeployments( + deployment.composeId, + "compose", + compose.serverId, + ); try { - await removeLastTenDeployments( - deployment.composeId, - "compose", - compose.serverId, - ); const { LOGS_PATH } = paths(!!compose.serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const fileName = `${compose.appName}-${formattedDateTime}.log`; @@ -326,8 +365,8 @@ export const createDeploymentBackup = async ( } else if (backup.backupType === "compose") { serverId = backup.compose?.serverId; } + await removeLastTenDeployments(deployment.backupId, "backup", serverId); try { - await removeLastTenDeployments(deployment.backupId, "backup", serverId); const { LOGS_PATH } = paths(!!serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const fileName = `${backup.appName}-${formattedDateTime}.log`; @@ -396,12 +435,12 @@ export const createDeploymentSchedule = async ( ) => { const schedule = await findScheduleById(deployment.scheduleId); + const serverId = + schedule.application?.serverId || + schedule.compose?.serverId || + schedule.server?.serverId; + await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId); try { - const serverId = - schedule.application?.serverId || - schedule.compose?.serverId || - schedule.server?.serverId; - await removeLastTenDeployments(deployment.scheduleId, "schedule", serverId); const { SCHEDULES_PATH } = paths(!!serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const fileName = `${schedule.appName}-${formattedDateTime}.log`; @@ -472,14 +511,14 @@ export const createDeploymentVolumeBackup = async ( ) => { const volumeBackup = await findVolumeBackupById(deployment.volumeBackupId); + const serverId = + volumeBackup.application?.serverId || volumeBackup.compose?.serverId; + await removeLastTenDeployments( + deployment.volumeBackupId, + "volumeBackup", + serverId, + ); try { - const serverId = - volumeBackup.application?.serverId || volumeBackup.compose?.serverId; - await removeLastTenDeployments( - deployment.volumeBackupId, - "volumeBackup", - serverId, - ); const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss"); const fileName = `${volumeBackup.appName}-${formattedDateTime}.log`; @@ -554,11 +593,27 @@ export const removeDeployment = async (deploymentId: string) => { const deployment = await db .delete(deployments) .where(eq(deployments.deploymentId, deploymentId)) - .returning(); - return deployment[0]; + .returning() + .then((result) => result[0]); + + if (!deployment) { + return null; + } + + const logPath = path.join(deployment.logPath); + if (logPath && logPath !== ".") { + const command = `rm -f ${logPath};`; + if (deployment.serverId) { + await execAsyncRemote(deployment.serverId, command); + } else { + await execAsync(command); + } + } + + return deployment; } catch (error) { const message = - error instanceof Error ? error.message : "Error creating the deployment"; + error instanceof Error ? error.message : "Error removing the deployment"; throw new TRPCError({ code: "BAD_REQUEST", message, @@ -626,34 +681,49 @@ const removeLastTenDeployments = async ( if (serverId) { let command = ""; for (const oldDeployment of deploymentsToDelete) { - const logPath = path.join(oldDeployment.logPath); - if (oldDeployment.rollbackId) { - await removeRollbackById(oldDeployment.rollbackId); - } + try { + const logPath = path.join(oldDeployment.logPath); + if (oldDeployment.rollbackId) { + await removeRollbackById(oldDeployment.rollbackId); + } - if (logPath !== ".") { - command += ` - rm -rf ${logPath}; - `; + if (logPath && logPath !== ".") { + command += `rm -rf ${logPath};`; + } + await removeDeployment(oldDeployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`, + err, + ); } - await removeDeployment(oldDeployment.deploymentId); } - await execAsyncRemote(serverId, command); + if (command) { + await execAsyncRemote(serverId, command); + } } else { for (const oldDeployment of deploymentsToDelete) { - if (oldDeployment.rollbackId) { - await removeRollbackById(oldDeployment.rollbackId); + try { + if (oldDeployment.rollbackId) { + await removeRollbackById(oldDeployment.rollbackId); + } + const logPath = path.join(oldDeployment.logPath); + if ( + logPath && + logPath !== "." && + existsSync(logPath) && + !oldDeployment.errorMessage + ) { + await fsPromises.unlink(logPath); + } + await removeDeployment(oldDeployment.deploymentId); + } catch (err) { + console.error( + `Failed to remove deployment ${oldDeployment.deploymentId} during cleanup:`, + err, + ); } - const logPath = path.join(oldDeployment.logPath); - if ( - existsSync(logPath) && - !oldDeployment.errorMessage && - logPath !== "." - ) { - await fsPromises.unlink(logPath); - } - await removeDeployment(oldDeployment.deploymentId); } } } @@ -717,6 +787,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/registry.ts b/packages/server/src/services/registry.ts index ec8db8fa8..d30e178bb 100644 --- a/packages/server/src/services/registry.ts +++ b/packages/server/src/services/registry.ts @@ -15,7 +15,7 @@ function shEscape(s: string | undefined): string { return `'${s.replace(/'/g, `'\\''`)}'`; } -function safeDockerLoginCommand( +export function safeDockerLoginCommand( registry: string | undefined, user: string | undefined, pass: string | undefined, diff --git a/packages/server/src/services/rollbacks.ts b/packages/server/src/services/rollbacks.ts index 00c60ebc8..51d978572 100644 --- a/packages/server/src/services/rollbacks.ts +++ b/packages/server/src/services/rollbacks.ts @@ -23,7 +23,7 @@ import { findDeploymentById } from "./deployment"; import type { Mount } from "./mount"; import type { Port } from "./port"; import type { Project } from "./project"; -import type { Registry } from "./registry"; +import { type Registry, safeDockerLoginCommand } from "./registry"; export const createRollback = async ( input: z.infer, @@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => { const command = `docker image rm ${image} --force`; if (serverId) { - await execAsyncRemote(command, serverId); + await execAsyncRemote(serverId, command); } else { await execAsync(command); } @@ -171,6 +171,23 @@ export const rollback = async (rollbackId: string) => { ); }; +const dockerLoginForRegistry = async ( + registry: Registry, + serverId?: string | null, +) => { + const loginCommand = safeDockerLoginCommand( + registry.registryUrl, + registry.username, + registry.password, + ); + + if (serverId) { + await execAsyncRemote(serverId, loginCommand); + } else { + await execAsync(loginCommand); + } +}; + const rollbackApplication = async ( appName: string, image: string, @@ -188,6 +205,14 @@ const rollbackApplication = async ( throw new Error("Full context is required for rollback"); } + // Ensure Docker daemon is authenticated with the rollback registry + // before updating the swarm service. The authconfig in CreateServiceOptions + // alone is not sufficient — Docker Swarm also relies on the daemon's + // cached credentials (~/.docker/config.json) to distribute auth to nodes. + if (fullContext.rollbackRegistry) { + await dockerLoginForRegistry(fullContext.rollbackRegistry, serverId); + } + const docker = await getRemoteDocker(serverId); // Use the same configuration as mechanizeDockerContainer diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index f3603a8f0..07aaf690c 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -413,17 +413,38 @@ export const checkPortInUse = async ( serverId?: string, ): Promise<{ isInUse: boolean; conflictingContainer?: string }> => { try { - const command = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; - const { stdout } = serverId - ? await execAsyncRemote(serverId, command) - : await execAsync(command); + // Check if port is in use by a Docker container + const dockerCommand = `docker ps -a --format '{{.Names}}' | grep -v '^dokploy-traefik$' | while read name; do docker port "$name" 2>/dev/null | grep -q ':${port}' && echo "$name" && break; done || true`; + const { stdout: dockerOut } = serverId + ? await execAsyncRemote(serverId, dockerCommand) + : await execAsync(dockerCommand); - const container = stdout.trim(); + const container = dockerOut.trim(); - return { - isInUse: !!container, - conflictingContainer: container || undefined, - }; + if (container) { + return { + isInUse: true, + conflictingContainer: `container "${container}"`, + }; + } + + // Check if port is in use by a host-level service (non-Docker) + // Dokploy runs inside a container, so we spawn an ephemeral container + // with --net=host to share the host's network stack and use nc -z to + // check if something is listening on the port + const hostCommand = `docker run --rm --net=host busybox sh -c 'nc -z 0.0.0.0 ${port} 2>/dev/null && echo in_use || echo free'`; + const { stdout: hostOut } = serverId + ? await execAsyncRemote(serverId, hostCommand) + : await execAsync(hostCommand); + + if (hostOut.includes("in_use")) { + return { + isInUse: true, + conflictingContainer: "a host-level service", + }; + } + + return { isInUse: false }; } catch (error) { console.error("Error checking port availability:", error); return { isInUse: false }; diff --git a/packages/server/src/utils/ai/select-ai-provider.ts b/packages/server/src/utils/ai/select-ai-provider.ts index 08f6b7383..b6477c573 100644 --- a/packages/server/src/utils/ai/select-ai-provider.ts +++ b/packages/server/src/utils/ai/select-ai-provider.ts @@ -30,6 +30,18 @@ export function selectAIProvider(config: { apiUrl: string; apiKey: string }) { baseURL: config.apiUrl, }); case "azure": + // Azure OpenAI-compatible endpoints already include /v1 in the path. + // Using createAzure with such URLs causes a doubled /v1//v1/ suffix. + if (config.apiUrl.includes("/v1")) { + return createOpenAICompatible({ + name: "azure", + baseURL: config.apiUrl, + headers: { + "api-key": config.apiKey, + Authorization: `Bearer ${config.apiKey}`, + }, + }); + } return createAzure({ apiKey: config.apiKey, baseURL: config.apiUrl, diff --git a/packages/server/src/utils/backups/compose.ts b/packages/server/src/utils/backups/compose.ts index 1963f2c91..34f6d2a9b 100644 --- a/packages/server/src/utils/backups/compose.ts +++ b/packages/server/src/utils/backups/compose.ts @@ -14,13 +14,14 @@ export const runComposeBackup = async ( compose: Compose, backup: BackupSchedule, ) => { - const { environmentId, name } = compose; + const { environmentId, name, appName } = compose; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); - const { prefix, databaseType } = backup; + const { prefix, databaseType, serviceName } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; + const s3AppName = serviceName ? `${appName}_${serviceName}` : appName; + const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "Compose Backup", diff --git a/packages/server/src/utils/backups/index.ts b/packages/server/src/utils/backups/index.ts index 8da8f116a..68a883da2 100644 --- a/packages/server/src/utils/backups/index.ts +++ b/packages/server/src/utils/backups/index.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { CLEANUP_CRON_JOB } from "@dokploy/server/constants"; import { member } from "@dokploy/server/db/schema"; import type { BackupSchedule } from "@dokploy/server/services/backup"; @@ -11,7 +10,7 @@ import { startLogCleanup } from "../access-log/handler"; import { cleanupAll } from "../docker/utils"; import { sendDockerCleanupNotifications } from "../notifications/docker-cleanup"; import { execAsync, execAsyncRemote } from "../process/execAsync"; -import { getS3Credentials, scheduleBackup } from "./utils"; +import { getS3Credentials, normalizeS3Path, scheduleBackup } from "./utils"; export const initCronJobs = async () => { console.log("Setting up cron jobs...."); @@ -95,6 +94,20 @@ export const initCronJobs = async () => { } }; +const getServiceAppName = (backup: BackupSchedule): string => { + if (backup.compose?.appName) { + return backup.serviceName + ? `${backup.compose.appName}_${backup.serviceName}` + : backup.compose.appName; + } + const serviceAppName = + backup.postgres?.appName || + backup.mysql?.appName || + backup.mariadb?.appName || + backup.mongo?.appName; + return serviceAppName || backup.appName; +}; + export const keepLatestNBackups = async ( backup: BackupSchedule, serverId?: string | null, @@ -105,18 +118,16 @@ export const keepLatestNBackups = async ( try { const rcloneFlags = getS3Credentials(backup.destination); - const backupFilesPath = path.join( - `:s3:${backup.destination.bucket}`, - backup.prefix, - ); + const appName = getServiceAppName(backup); + const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`; // --include "*.sql.gz" or "*.zip" ensures nothing else other than the dokploy backup files are touched by rclone const rcloneList = `rclone lsf ${rcloneFlags.join(" ")} --include "*${backup.databaseType === "web-server" ? ".zip" : ".sql.gz"}" ${backupFilesPath}`; // when we pipe the above command with this one, we only get the list of files we want to delete const sortAndPickUnwantedBackups = `sort -r | tail -n +$((${backup.keepLatestCount}+1)) | xargs -I{}`; // this command deletes the files - // to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}/{} - const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}/{}`; + // to test the deletion before actually deleting we can add --dry-run before ${backupFilesPath}{} + const rcloneDelete = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`; const rcloneCommand = `${rcloneList} | ${sortAndPickUnwantedBackups} ${rcloneDelete}`; diff --git a/packages/server/src/utils/backups/mariadb.ts b/packages/server/src/utils/backups/mariadb.ts index 56cb1a9aa..089b3cb04 100644 --- a/packages/server/src/utils/backups/mariadb.ts +++ b/packages/server/src/utils/backups/mariadb.ts @@ -14,13 +14,13 @@ export const runMariadbBackup = async ( mariadb: Mariadb, backup: BackupSchedule, ) => { - const { environmentId, name } = mariadb; + const { environmentId, name, appName } = mariadb; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "MariaDB Backup", diff --git a/packages/server/src/utils/backups/mongo.ts b/packages/server/src/utils/backups/mongo.ts index 2071478a0..d1b04e68b 100644 --- a/packages/server/src/utils/backups/mongo.ts +++ b/packages/server/src/utils/backups/mongo.ts @@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; export const runMongoBackup = async (mongo: Mongo, backup: BackupSchedule) => { - const { environmentId, name } = mongo; + const { environmentId, name, appName } = mongo; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "MongoDB Backup", diff --git a/packages/server/src/utils/backups/mysql.ts b/packages/server/src/utils/backups/mysql.ts index d131090fa..461a17bf9 100644 --- a/packages/server/src/utils/backups/mysql.ts +++ b/packages/server/src/utils/backups/mysql.ts @@ -11,13 +11,13 @@ import { execAsync, execAsyncRemote } from "../process/execAsync"; import { getBackupCommand, getS3Credentials, normalizeS3Path } from "./utils"; export const runMySqlBackup = async (mysql: MySql, backup: BackupSchedule) => { - const { environmentId, name } = mysql; + const { environmentId, name, appName } = mysql; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; const deployment = await createDeploymentBackup({ backupId: backup.backupId, title: "MySQL Backup", diff --git a/packages/server/src/utils/backups/postgres.ts b/packages/server/src/utils/backups/postgres.ts index 9241f2103..3371b0cf9 100644 --- a/packages/server/src/utils/backups/postgres.ts +++ b/packages/server/src/utils/backups/postgres.ts @@ -14,7 +14,7 @@ export const runPostgresBackup = async ( postgres: Postgres, backup: BackupSchedule, ) => { - const { name, environmentId } = postgres; + const { name, environmentId, appName } = postgres; const environment = await findEnvironmentById(environmentId); const project = await findProjectById(environment.projectId); @@ -26,7 +26,7 @@ export const runPostgresBackup = async ( const { prefix } = backup; const destination = backup.destination; const backupFileName = `${new Date().toISOString()}.sql.gz`; - const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`; try { const rcloneFlags = getS3Credentials(destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; diff --git a/packages/server/src/utils/backups/web-server.ts b/packages/server/src/utils/backups/web-server.ts index 4d13ae31a..1a51d23ea 100644 --- a/packages/server/src/utils/backups/web-server.ts +++ b/packages/server/src/utils/backups/web-server.ts @@ -31,7 +31,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { const { BASE_PATH } = paths(); const tempDir = await mkdtemp(join(tmpdir(), "dokploy-backup-")); const backupFileName = `webserver-backup-${timestamp}.zip`; - const s3Path = `:s3:${destination.bucket}/${normalizeS3Path(backup.prefix)}${backupFileName}`; + const s3Path = `:s3:${destination.bucket}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`; try { await execAsync(`mkdir -p ${tempDir}/filesystem`); @@ -67,7 +67,7 @@ export const runWebServerBackup = async (backup: BackupSchedule) => { await execAsync(cleanupCommand); await execAsync( - `rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`, + `rsync -a --ignore-errors --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`, ); writeStream.write("Copied filesystem to temp directory\n"); diff --git a/packages/server/src/utils/builders/compose.ts b/packages/server/src/utils/builders/compose.ts index 5eede59d5..ca2b8a6b5 100644 --- a/packages/server/src/utils/builders/compose.ts +++ b/packages/server/src/utils/builders/compose.ts @@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`; cd "${projectPath}"; - ${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --attachable ${compose.appName}` : ""} + ${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""} env -i PATH="$PATH" ${exportEnvCommand} docker ${command.split(" ").join(" ")} 2>&1 || { echo "Error: ❌ Docker command failed"; exit 1; } ${compose.isolatedDeployment ? `docker network connect ${compose.appName} $(docker ps --filter "name=dokploy-traefik" -q) >/dev/null 2>&1` : ""} diff --git a/packages/server/src/utils/docker/compose.ts b/packages/server/src/utils/docker/compose.ts index a78b416ec..2e2011b03 100644 --- a/packages/server/src/utils/docker/compose.ts +++ b/packages/server/src/utils/docker/compose.ts @@ -18,7 +18,9 @@ export const randomizeComposeFile = async ( ) => { const compose = await findComposeById(composeId); const composeFile = compose.composeFile; - const composeData = parse(composeFile) as ComposeSpecification; + const composeData = parse(composeFile, { + maxAliasCount: 10000, + }) as ComposeSpecification; const randomSuffix = suffix || generateRandomHash(); diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 86ed1e86c..5d01eebff 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -63,7 +63,9 @@ export const loadDockerCompose = async ( if (existsSync(path)) { const yamlStr = readFileSync(path, "utf8"); - const parsedConfig = parse(yamlStr) as ComposeSpecification; + const parsedConfig = parse(yamlStr, { + maxAliasCount: 10000, + }) as ComposeSpecification; return parsedConfig; } return null; @@ -86,7 +88,9 @@ export const loadDockerComposeRemote = async ( return null; } if (!stdout) return null; - const parsedConfig = parse(stdout) as ComposeSpecification; + const parsedConfig = parse(stdout, { + maxAliasCount: 10000, + }) as ComposeSpecification; return parsedConfig; } catch { return null; diff --git a/packages/server/src/utils/providers/gitea.ts b/packages/server/src/utils/providers/gitea.ts index 1555e7713..7e5640d3d 100644 --- a/packages/server/src/utils/providers/gitea.ts +++ b/packages/server/src/utils/providers/gitea.ts @@ -209,7 +209,10 @@ export const testGiteaConnection = async (input: { giteaId: string }) => { }); } - const baseUrl = provider.giteaUrl.replace(/\/+$/, ""); + const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace( + /\/+$/, + "", + ); // Use /user/repos to get authenticated user's repositories with pagination let allRepos = 0; @@ -266,7 +269,9 @@ export const getGiteaRepositories = async (giteaId?: string) => { await refreshGiteaToken(giteaId); const giteaProvider = await findGiteaById(giteaId); - const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, ""); + const baseUrl = ( + giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl + ).replace(/\/+$/, ""); // Use /user/repos to get authenticated user's repositories with pagination let allRepositories: any[] = []; @@ -331,7 +336,9 @@ export const getGiteaBranches = async (input: { const giteaProvider = await findGiteaById(input.giteaId); - const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, ""); + const baseUrl = ( + giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl + ).replace(/\/+$/, ""); // Handle pagination for branches let allBranches: any[] = []; diff --git a/packages/server/src/utils/providers/gitlab.ts b/packages/server/src/utils/providers/gitlab.ts index 22e5df3ae..2f11d97ef 100644 --- a/packages/server/src/utils/providers/gitlab.ts +++ b/packages/server/src/utils/providers/gitlab.ts @@ -211,10 +211,13 @@ export const getGitlabBranches = async (input: { const allBranches = []; let page = 1; const perPage = 100; // GitLab's max per page is 100 + const baseUrl = ( + gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl + ).replace(/\/+$/, ""); while (true) { const branchesResponse = await fetch( - `${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`, + `${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`, { headers: { Authorization: `Bearer ${gitlabProvider.accessToken}`, @@ -289,10 +292,13 @@ export const validateGitlabProvider = async (gitlabProvider: Gitlab) => { const allProjects = []; let page = 1; const perPage = 100; // GitLab's max per page is 100 + const baseUrl = ( + gitlabProvider.gitlabInternalUrl || gitlabProvider.gitlabUrl + ).replace(/\/+$/, ""); while (true) { const response = await fetch( - `${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`, + `${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`, { headers: { Authorization: `Bearer ${gitlabProvider.accessToken}`, diff --git a/packages/server/src/utils/restore/compose.ts b/packages/server/src/utils/restore/compose.ts index d55b12fd8..10797a51d 100644 --- a/packages/server/src/utils/restore/compose.ts +++ b/packages/server/src/utils/restore/compose.ts @@ -69,6 +69,7 @@ export const restoreComposeBackup = async ( }, restoreType: composeType, rcloneCommand, + backupFile: backupInput.backupFile, }); emit("Starting restore..."); diff --git a/packages/server/src/utils/restore/utils.ts b/packages/server/src/utils/restore/utils.ts index 23052e642..7300ca479 100644 --- a/packages/server/src/utils/restore/utils.ts +++ b/packages/server/src/utils/restore/utils.ts @@ -30,7 +30,7 @@ export const getMongoRestoreCommand = ( databaseUser: string, databasePassword: string, ) => { - return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`; + return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive --drop"`; }; export const getComposeSearchCommand = ( diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index 97400b1b9..6a328a1d9 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -152,16 +152,13 @@ export const createRouterConfig = async ( } if ((entryPoint === "websecure" && https) || !https) { - // redirects - for (const redirect of redirects) { - let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`; - if (domain.domainType === "preview") { - middlewareName = `redirect-${appName.replace( - /^preview-(.+)-[^-]+$/, - "$1", - )}-${redirect.uniqueConfigKey}`; + // redirects - skip for preview deployments as wildcard subdomains + // should not inherit parent redirect rules (e.g., www-redirect) + if (domain.domainType !== "preview") { + for (const redirect of redirects) { + const middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`; + routerConfig.middlewares?.push(middlewareName); } - routerConfig.middlewares?.push(middlewareName); } // security diff --git a/packages/server/src/utils/volume-backups/backup.ts b/packages/server/src/utils/volume-backups/backup.ts index 3d229ef64..e192fd698 100644 --- a/packages/server/src/utils/volume-backups/backup.ts +++ b/packages/server/src/utils/volume-backups/backup.ts @@ -4,6 +4,24 @@ import { findComposeById } from "@dokploy/server/services/compose"; import type { findVolumeBackupById } from "@dokploy/server/services/volume-backups"; import { getS3Credentials, normalizeS3Path } from "../backups/utils"; +export const getVolumeServiceAppName = ( + volumeBackup: Awaited>, +): string => { + if (volumeBackup.compose?.appName) { + return volumeBackup.serviceName + ? `${volumeBackup.compose.appName}_${volumeBackup.serviceName}` + : volumeBackup.compose.appName; + } + const serviceAppName = + volumeBackup.application?.appName || + volumeBackup.postgres?.appName || + volumeBackup.mysql?.appName || + volumeBackup.mariadb?.appName || + volumeBackup.mongo?.appName || + volumeBackup.redis?.appName; + return serviceAppName || volumeBackup.appName; +}; + export const backupVolume = async ( volumeBackup: Awaited>, ) => { @@ -12,8 +30,9 @@ export const backupVolume = async ( volumeBackup.application?.serverId || volumeBackup.compose?.serverId; const { VOLUME_BACKUPS_PATH, VOLUME_BACKUP_LOCK_PATH } = paths(!!serverId); const destination = volumeBackup.destination; + const s3AppName = getVolumeServiceAppName(volumeBackup); const backupFileName = `${volumeName}-${new Date().toISOString()}.tar`; - const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`; + const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`; const rcloneFlags = getS3Credentials(volumeBackup.destination); const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`; const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName); diff --git a/packages/server/src/utils/volume-backups/utils.ts b/packages/server/src/utils/volume-backups/utils.ts index b508c6b88..6a51e765d 100644 --- a/packages/server/src/utils/volume-backups/utils.ts +++ b/packages/server/src/utils/volume-backups/utils.ts @@ -12,7 +12,7 @@ import { import { scheduledJobs, scheduleJob } from "node-schedule"; import { getS3Credentials, normalizeS3Path } from "../backups/utils"; import { sendVolumeBackupNotifications } from "../notifications/volume-backup"; -import { backupVolume } from "./backup"; +import { backupVolume, getVolumeServiceAppName } from "./backup"; // Helper functions to extract project info from volume backup const getProjectName = ( @@ -81,9 +81,9 @@ const cleanupOldVolumeBackups = async ( try { const rcloneFlags = getS3Credentials(destination); - const normalizedPrefix = normalizeS3Path(prefix); - const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`; - const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`; + const s3AppName = getVolumeServiceAppName(volumeBackup); + const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`; + const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`; const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`; const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`; const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`; @@ -131,14 +131,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => { ? "mongodb" : volumeBackup.serviceType; - await sendVolumeBackupNotifications({ - projectName, - applicationName: volumeBackup.name, - volumeName: volumeBackup.volumeName, - serviceType: mappedServiceType, - type: "success", - organizationId, - }); + try { + await sendVolumeBackupNotifications({ + projectName, + applicationName: volumeBackup.name, + volumeName: volumeBackup.volumeName, + serviceType: mappedServiceType, + type: "success", + organizationId, + }); + } catch (notificationError) { + console.error( + "Failed to send volume backup success notification", + notificationError, + ); + } } catch (error) { const { VOLUME_BACKUPS_PATH } = paths(!!serverId); const volumeBackupPath = path.join( @@ -160,14 +167,21 @@ export const runVolumeBackup = async (volumeBackupId: string) => { ? "mongodb" : volumeBackup.serviceType; - await sendVolumeBackupNotifications({ - projectName, - applicationName: volumeBackup.name, - volumeName: volumeBackup.volumeName, - serviceType: mappedServiceType, - type: "error", - organizationId, - errorMessage: error instanceof Error ? error.message : String(error), - }); + try { + await sendVolumeBackupNotifications({ + projectName, + applicationName: volumeBackup.name, + volumeName: volumeBackup.volumeName, + serviceType: mappedServiceType, + type: "error", + organizationId, + errorMessage: error instanceof Error ? error.message : String(error), + }); + } catch (notificationError) { + console.error( + "Failed to send volume backup error notification", + notificationError, + ); + } } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d3865148..d2b0fed93 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,9 +130,12 @@ importers: '@codemirror/legacy-modes': specifier: 6.4.0 version: 6.4.0 + '@codemirror/search': + specifier: ^6.6.0 + version: 6.6.0 '@codemirror/view': - specifier: 6.29.0 - version: 6.29.0 + specifier: ^6.39.15 + version: 6.39.15 '@dokploy/server': specifier: workspace:* version: link:../../packages/server @@ -240,10 +243,10 @@ importers: version: 10.45.2 '@uiw/codemirror-theme-github': specifier: ^4.23.12 - version: 4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0) + version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15) '@uiw/react-codemirror': specifier: ^4.23.12 - version: 4.23.12(@babel/runtime@7.27.3)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@xterm/addon-attach': specifier: 0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -1200,11 +1203,8 @@ packages: '@codemirror/theme-one-dark@6.1.2': resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==} - '@codemirror/view@6.29.0': - resolution: {integrity: sha512-ED4ims4fkf7eOA+HYLVP8VVg3NMllt1FPm9PEJBfYFnidKlRITBaua38u68L1F60eNtw2YNcDN5jsIzhKZwWQA==} - - '@codemirror/view@6.36.8': - resolution: {integrity: sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg==} + '@codemirror/view@6.39.15': + resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==} '@dokploy/trpc-openapi@0.0.4': resolution: {integrity: sha512-a7VKunKu9arq57bP9MPH7ikJuKfT5SILnNy70vMqf1stm5IrqMG3Y7CIFprFe0DZiw3bwjue0KpETIATBftN6w==} @@ -8858,17 +8858,17 @@ snapshots: '@codemirror/autocomplete@6.18.6': dependencies: - '@codemirror/language': 6.11.0 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 - '@lezer/common': 1.2.3 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 '@codemirror/commands@6.8.1': dependencies: - '@codemirror/language': 6.11.0 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 - '@lezer/common': 1.2.3 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 '@codemirror/lang-css@6.3.1': dependencies: @@ -8895,12 +8895,12 @@ snapshots: '@codemirror/language@6.11.0': dependencies: - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 - '@lezer/common': 1.2.3 - '@lezer/highlight': 1.2.1 - '@lezer/lr': 1.4.2 - style-mod: 4.1.2 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 '@codemirror/legacy-modes@6.4.0': dependencies: @@ -8924,18 +8924,12 @@ snapshots: '@codemirror/theme-one-dark@6.1.2': dependencies: - '@codemirror/language': 6.11.0 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 - '@lezer/highlight': 1.2.1 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 + '@lezer/highlight': 1.2.3 - '@codemirror/view@6.29.0': - dependencies: - '@codemirror/state': 6.5.2 - style-mod: 4.1.2 - w3c-keyname: 2.2.8 - - '@codemirror/view@6.36.8': + '@codemirror/view@6.39.15': dependencies: '@codemirror/state': 6.5.2 style-mod: 4.1.2 @@ -12263,39 +12257,39 @@ snapshots: dependencies: '@types/node': 20.17.51 - '@uiw/codemirror-extensions-basic-setup@4.23.12(@codemirror/autocomplete@6.18.6)(@codemirror/commands@6.8.1)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)': + '@uiw/codemirror-extensions-basic-setup@4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)': dependencies: - '@codemirror/autocomplete': 6.18.6 - '@codemirror/commands': 6.8.1 - '@codemirror/language': 6.11.0 - '@codemirror/lint': 6.8.5 - '@codemirror/search': 6.5.11 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 + '@codemirror/autocomplete': 6.20.0 + '@codemirror/commands': 6.10.2 + '@codemirror/language': 6.12.1 + '@codemirror/lint': 6.9.4 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 - '@uiw/codemirror-theme-github@4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)': + '@uiw/codemirror-theme-github@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)': dependencies: - '@uiw/codemirror-themes': 4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0) + '@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15) transitivePeerDependencies: - '@codemirror/language' - '@codemirror/state' - '@codemirror/view' - '@uiw/codemirror-themes@4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)': + '@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)': dependencies: - '@codemirror/language': 6.11.0 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 - '@uiw/react-codemirror@4.23.12(@babel/runtime@7.27.3)(@codemirror/autocomplete@6.18.6)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/theme-one-dark@6.1.2)(@codemirror/view@6.29.0)(codemirror@6.0.1)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + '@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.15)(codemirror@6.0.2)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: - '@babel/runtime': 7.27.3 - '@codemirror/commands': 6.8.1 - '@codemirror/state': 6.5.2 - '@codemirror/theme-one-dark': 6.1.2 - '@codemirror/view': 6.29.0 - '@uiw/codemirror-extensions-basic-setup': 4.23.12(@codemirror/autocomplete@6.18.6)(@codemirror/commands@6.8.1)(@codemirror/language@6.11.0)(@codemirror/lint@6.8.5)(@codemirror/search@6.5.11)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0) - codemirror: 6.0.1 + '@babel/runtime': 7.28.6 + '@codemirror/commands': 6.10.2 + '@codemirror/state': 6.5.4 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.39.15 + '@uiw/codemirror-extensions-basic-setup': 4.25.4(@codemirror/autocomplete@6.20.0)(@codemirror/commands@6.10.2)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.4)(@codemirror/search@6.6.0)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15) + codemirror: 6.0.2 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) transitivePeerDependencies: @@ -12871,13 +12865,13 @@ snapshots: codemirror@6.0.1: dependencies: - '@codemirror/autocomplete': 6.18.6 - '@codemirror/commands': 6.8.1 - '@codemirror/language': 6.11.0 - '@codemirror/lint': 6.8.5 - '@codemirror/search': 6.5.11 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.29.0 + '@codemirror/autocomplete': 6.20.0 + '@codemirror/commands': 6.10.2 + '@codemirror/language': 6.12.1 + '@codemirror/lint': 6.9.4 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.15 color-convert@2.0.1: dependencies: