Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
6b2eedefd7 Add scrolling to organization picker dropdown
Co-authored-by: Siumauricio <47042324+Siumauricio@users.noreply.github.com>
2026-02-12 15:49:50 +00:00
copilot-swe-agent[bot]
5c45cfcefe Initial plan 2026-02-12 15:46:36 +00:00
94 changed files with 385 additions and 10562 deletions

View File

@@ -1,11 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
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
LEMON_SQUEEZY_STORE_ID=""

View File

@@ -10,7 +10,6 @@ import {
type DeployJob,
deployJobSchema,
} from "./schema.js";
import { fetchDeploymentJobs } from "./service.js";
import { deploy } from "./utils.js";
const app = new Hono();
@@ -119,6 +118,7 @@ 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,29 +176,6 @@ 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"],

View File

@@ -1,239 +0,0 @@
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<string, unknown>;
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<InngestRun[]> => {
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<string, unknown>;
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<string, InngestRun[]>,
serverId: string,
): DeploymentJobRow[] {
const requested = events.filter(
(e) =>
e.name === "deployment/requested" &&
(e.data as Record<string, unknown>)?.serverId === serverId,
);
const rows: DeploymentJobRow[] = [];
for (const ev of requested) {
const data = (ev.data ?? {}) as Record<string, unknown>;
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<DeploymentJobRow[]> => {
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<string, unknown>)?.serverId === serverId,
);
// Limit to avoid too many run fetches
const toFetch = requestedForServer.slice(0, 50);
const runsByEventId = new Map<string, InngestRun[]>();
await Promise.all(
toFetch.map(async (ev) => {
const runs = await fetchInngestRunsForEvent(ev.id);
runsByEventId.set(ev.id, runs);
}),
);
return buildDeploymentRowsFromRuns(toFetch, runsByEventId, serverId);
};

View File

@@ -9,7 +9,7 @@ import {
updateCompose,
updatePreviewDeployment,
} from "@dokploy/server";
import type { DeployJob } from "./schema.js";
import type { DeployJob } from "./schema";
export const deploy = async (job: DeployJob) => {
try {

View File

@@ -7,6 +7,7 @@ import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -24,7 +25,6 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { api } from "@/utils/api";

View File

@@ -1,8 +1,8 @@
import { Loader2 } from "lucide-react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
import { badgeStateColor } from "@/components/dashboard/application/logs/show";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,

View File

@@ -1,613 +0,0 @@
"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<AppRouter>["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<SortingState>([
{ id: "createdAt", desc: true },
]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [globalFilter, setGlobalFilter] = useState("");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
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;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Service
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return <span className="text-muted-foreground"></span>;
return (
<div className="flex items-center gap-2">
{info.type === "Application" ? (
<Rocket className="size-4 text-muted-foreground shrink-0" />
) : (
<Boxes className="size-4 text-muted-foreground shrink-0" />
)}
<div className="flex flex-col min-w-0">
<span className="font-medium truncate">{info.name}</span>
<Badge variant="outline" className="w-fit text-[10px]">
{info.type}
</Badge>
</div>
</div>
);
},
},
{
id: "projectName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.projectName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Project
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.projectName ?? "—"}
</span>
);
},
},
{
id: "environmentName",
accessorFn: (row: DeploymentRow) =>
getServiceInfo(row)?.environmentName ?? "",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Environment
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
return (
<span className="text-muted-foreground">
{info?.environmentName ?? "—"}
</span>
);
},
},
{
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;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Server
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
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 <span className="text-muted-foreground"></span>;
}
return (
<div className="flex flex-col gap-0.5 text-sm">
{serverName && (
<div className="flex items-center gap-1.5 flex-wrap">
<Server className="size-3.5 text-muted-foreground shrink-0" />
<span className="truncate">{serverName}</span>
{serverType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{serverType}
</Badge>
)}
</div>
)}
{showBuild && buildServerName && (
<div className="flex items-center gap-1.5 text-muted-foreground flex-wrap">
<span className="text-[10px]">Build:</span>
<span className="truncate text-xs">{buildServerName}</span>
{buildServerType && (
<Badge
variant="outline"
className="text-[10px] font-normal"
>
{buildServerType}
</Badge>
)}
</div>
)}
</div>
);
},
},
{
accessorKey: "title",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Title
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-sm truncate max-w-[200px] block">
{row.original.title || "—"}
</span>
),
},
{
accessorKey: "status",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Status
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const status = row.original.status ?? "running";
return (
<Badge variant={statusVariants[status] ?? "secondary"}>
{status}
</Badge>
);
},
},
{
accessorKey: "createdAt",
header: ({
column,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 size-4" />
</Button>
),
cell: ({ row }: { row: { original: DeploymentRow } }) => (
<span className="text-muted-foreground text-sm whitespace-nowrap">
{row.original.createdAt
? new Date(row.original.createdAt).toLocaleString()
: "—"}
</span>
),
},
{
header: "",
id: "actions",
enableSorting: false,
cell: ({ row }: { row: { original: DeploymentRow } }) => {
const info = getServiceInfo(row.original);
if (!info) return null;
return (
<Button variant="ghost" size="sm" asChild>
<Link href={info.href} className="gap-1">
<ExternalLink className="size-4" />
Open
</Link>
</Button>
);
},
},
],
[],
);
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 (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, project, environment, server..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All statuses</SelectItem>
<SelectItem value="running">Running</SelectItem>
<SelectItem value="done">Done</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="cancelled">Cancelled</SelectItem>
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="application">Application</SelectItem>
<SelectItem value="compose">Compose</SelectItem>
</SelectContent>
</Select>
</div>
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[45vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading deployments...</span>
</div>
) : (
<>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className=" text-center"
>
<div className="flex flex-col min-h-[45vh] items-center justify-center gap-2 text-muted-foreground">
<Rocket className="size-8" />
<p className="font-medium">No deployments found</p>
<p className="text-sm">
Deployments from applications and compose will
appear here.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-col gap-4 px-4 py-4 border-t sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
Rows per page
</span>
<Select
value={String(pagination.pageSize)}
onValueChange={(value) => {
setPagination((p) => ({
...p,
pageSize: Number(value),
pageIndex: 0,
}));
}}
>
<SelectTrigger className="h-8 w-[70px]">
<SelectValue />
</SelectTrigger>
<SelectContent side="top">
{[10, 25, 50, 100].map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground whitespace-nowrap">
Showing{" "}
{filteredData.length === 0
? 0
: pagination.pageIndex * pagination.pageSize + 1}{" "}
to{" "}
{Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredData.length,
)}{" "}
of {filteredData.length} entries
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
<ChevronLeft className="size-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -1,217 +0,0 @@
"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<AppRouter>["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 (
<div className="px-0">
{isLoading ? (
<div className="flex gap-4 w-full items-center justify-center min-h-[30vh] text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading queue...</span>
</div>
) : (
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Job ID</TableHead>
<TableHead>Label</TableHead>
<TableHead>Type</TableHead>
<TableHead>State</TableHead>
<TableHead>Added</TableHead>
<TableHead>Processed</TableHead>
<TableHead>Finished</TableHead>
<TableHead>Error</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{queueList?.length ? (
queueList.map((row) => {
const d = row.data as Record<string, unknown>;
const appType = d?.applicationType as string | undefined;
const pathInfo = row.servicePath;
const hasLink = pathInfo?.href != null;
return (
<TableRow key={String(row.id)}>
<TableCell className="font-mono text-xs">
{String(row.id)}
</TableCell>
<TableCell className="max-w-[200px] truncate">
{getJobLabel(row)}
</TableCell>
<TableCell>{appType ?? row.name ?? "—"}</TableCell>
<TableCell>
<Badge variant={stateVariants[row.state] ?? "outline"}>
{row.state}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.timestamp)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.processedOn)}
</TableCell>
<TableCell className="text-muted-foreground text-xs">
{formatTs(row.finishedOn)}
</TableCell>
<TableCell className="max-w-[180px] truncate text-xs text-destructive">
{row.failedReason ?? "—"}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{hasLink ? (
<Button variant="ghost" size="sm" asChild>
<Link href={pathInfo!.href!}>
<ArrowRight className="size-4 mr-1" />
Service
</Link>
</Button>
) : (
<span className="text-muted-foreground text-xs">
</span>
)}
{isCloud &&
row.state === "active" &&
(d?.applicationId != null ||
d?.composeId != null) && (
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
disabled={isCancelling}
onClick={() => {
const appId =
typeof d.applicationId === "string"
? d.applicationId
: undefined;
const compId =
typeof d.composeId === "string"
? d.composeId
: undefined;
if (appId) {
void cancelApplicationDeployment({
applicationId: appId,
});
} else if (compId) {
void cancelComposeDeployment({
composeId: compId,
});
}
}}
>
<XCircle className="size-4 mr-1" />
Cancel
</Button>
)}
</div>
</TableCell>
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={9} className="text-center py-12">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground min-h-[30vh]">
<ListTodo className="size-8" />
<p className="font-medium">Queue is empty</p>
<p className="text-sm">
Deployment jobs will appear here when they are queued.
</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
</div>
);
}

View File

@@ -24,6 +24,7 @@ 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({
@@ -54,6 +55,8 @@ 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<OrganizationFormValues>({
resolver: zodResolver(organizationSchema),
@@ -86,7 +89,7 @@ export function AddOrganization({ organizationId }: Props) {
utils.organization.all.invalidate();
if (organizationId) {
utils.organization.one.invalidate({ organizationId });
utils.organization.active.invalidate();
refetchActiveOrganization();
}
setOpen(false);
})

View File

@@ -23,6 +23,7 @@ import {
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
import { StatusTooltip } from "../shared/status-tooltip";
@@ -55,7 +56,7 @@ export const SearchCommand = () => {
const router = useRouter();
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const { data: session } = api.user.session.useQuery();
const { data: session } = authClient.useSession();
const { data } = api.project.all.useQuery(undefined, {
enabled: !!session,
});
@@ -173,14 +174,6 @@ export const SearchCommand = () => {
>
Projects
</CommandItem>
<CommandItem
onSelect={() => {
router.push("/dashboard/deployments");
setOpen(false);
}}
>
Deployments
</CommandItem>
{!isCloud && (
<>
<CommandItem

View File

@@ -19,9 +19,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -17,9 +17,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -12,13 +12,13 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: session } = api.user.session.useQuery();
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [activeOrganization?.id, session?.user?.id]);
}, [data?.id]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -98,8 +98,8 @@ export const AddGithubProvider = () => {
<form
action={
isOrganization
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}:${session?.user?.id ?? ""}`
? `https://github.com/organizations/${organizationName}/settings/apps/new?state=gh_init:${activeOrganization?.id}`
: `https://github.com/settings/apps/new?state=gh_init:${activeOrganization?.id}`
}
method="post"
>

View File

@@ -19,9 +19,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -18,9 +18,9 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormDescription,
FormLabel,
FormMessage,
} from "@/components/ui/form";

View File

@@ -353,7 +353,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
webhookUrl: notification.discord?.webhookUrl,
decoration: notification.discord?.decoration ?? undefined,
decoration: notification.discord?.decoration || undefined,
name: notification.name,
dockerCleanup: notification.dockerCleanup,
serverThreshold: notification.serverThreshold,
@@ -400,7 +400,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
volumeBackup: notification.volumeBackup,
type: notification.notificationType,
appToken: notification.gotify?.appToken,
decoration: notification.gotify?.decoration ?? undefined,
decoration: notification.gotify?.decoration || undefined,
priority: notification.gotify?.priority,
serverUrl: notification.gotify?.serverUrl,
name: notification.name,

View File

@@ -55,7 +55,7 @@ export const AddInvitation = () => {
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: activeOrganization } = authClient.useActiveOrganization();
const form = useForm<AddInvitation>({
defaultValues: {

View File

@@ -37,7 +37,7 @@ export const ShowUsers = () => {
const { mutateAsync } = api.user.remove.useMutation();
const utils = api.useUtils();
const { data: session } = api.user.session.useQuery();
const { data: session } = authClient.useSession();
return (
<div className="w-full">

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -24,6 +23,7 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
const schema = z.object({

View File

@@ -1,7 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { ArrowRightLeft, Plus, Trash2 } from "lucide-react";
import { useTranslation } from "next-i18next";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import type React from "react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
@@ -36,6 +35,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHealthCheckAfterMutation } from "@/hooks/use-health-check-after-mutation";
import { api } from "@/utils/api";
interface Props {

View File

@@ -1,540 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, ImagePlus, Loader2, Palette, X } from "lucide-react";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { CodeEditor } from "@/components/shared/code-editor";
import { Logo } from "@/components/shared/logo";
import { Button } from "@/components/ui/button";
import { CardDescription, CardTitle } from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const formSchema = z.object({
appName: z.string().max(256).optional().or(z.literal("")),
tagline: z.string().max(512).optional().or(z.literal("")),
logoUrl: z
.string()
.optional()
.or(z.literal(""))
.refine(
(v) =>
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
"Must be a valid URL or uploaded image",
),
faviconUrl: z
.string()
.optional()
.or(z.literal(""))
.refine(
(v) =>
!v || v.startsWith("data:") || z.string().url().safeParse(v).success,
"Must be a valid URL or uploaded image",
),
customCss: z.string().max(8192).optional().or(z.literal("")),
});
type FormValues = z.infer<typeof formSchema>;
const MAX_LOGO_SIZE_BYTES = 2 * 1024 * 1024; // 2MB
const MAX_FAVICON_SIZE_BYTES = 512 * 1024; // 512KB (favicons are small)
/** Reference of CSS variables used by the app (from globals.css). Use in :root for light, .dark for dark mode. Values: HSL without hsl() e.g. 220 70% 50% */
const CSS_VARIABLES_REFERENCE = `/* General */
--background, --foreground
--card, --card-foreground
--popover, --popover-foreground
--primary, --primary-foreground
--secondary, --secondary-foreground
--muted, --muted-foreground
--accent, --accent-foreground
--destructive, --destructive-foreground
--border, --input, --ring
--radius (e.g. 0.5rem)
--overlay (e.g. rgba(0,0,0,0.2))
/* Sidebar */
--sidebar-background, --sidebar-foreground
--sidebar-primary, --sidebar-primary-foreground
--sidebar-accent, --sidebar-accent-foreground
--sidebar-border, --sidebar-ring
/* Charts */
--chart-1, --chart-2, --chart-3, --chart-4, --chart-5`;
/** Default theme CSS (mirrors globals.css). Load into editor so user can edit variables without writing from scratch. */
const DEFAULT_THEME_CSS = `:root {
--terminal-paste: rgba(0, 0, 0, 0.2);
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 10% 3.9%;
--radius: 0.5rem;
--overlay: rgba(0, 0, 0, 0.2);
--chart-1: 173 58% 39%;
--chart-2: 12 76% 61%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--terminal-paste: rgba(255, 255, 255, 0.2);
--background: 0 0% 0%;
--foreground: 0 0% 98%;
--card: 240 4% 10%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 4% 10%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 84.2% 50.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 4% 10%;
--ring: 240 4.9% 83.9%;
--overlay: rgba(0, 0, 0, 0.5);
--chart-1: 220 70% 50%;
--chart-2: 340 75% 55%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 160 60% 45%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
`;
export const WhitelabelForm = () => {
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
const { data, isLoading } = api.whitelabel.get.useQuery();
const utils = api.useUtils();
const { mutateAsync, isLoading: isPending } =
api.whitelabel.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
appName: "",
tagline: "",
logoUrl: "",
faviconUrl: "",
customCss: "",
},
});
useEffect(() => {
if (data) {
form.reset({
appName: data.appName ?? "",
tagline: data.tagline ?? "",
logoUrl: data.logoUrl ?? "",
faviconUrl: data.faviconUrl ?? "",
customCss: data.customCss ?? "",
});
}
}, [data, form]);
const onSubmit = async (values: FormValues) => {
await mutateAsync({
appName: values.appName?.trim() || null,
tagline: values.tagline?.trim() || null,
logoUrl: values.logoUrl?.trim() || null,
faviconUrl: values.faviconUrl?.trim() || null,
customCss: values.customCss?.trim() || null,
})
.then(() => {
utils.whitelabel.get.invalidate();
toast.success("Whitelabel settings saved");
})
.catch((error) => {
toast.error(error.message ?? "Failed to save");
});
};
return (
<div className="flex flex-col gap-4 rounded-lg border p-4">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Palette className="size-6 text-muted-foreground" />
<CardTitle className="text-xl">Whitelabelling</CardTitle>
</div>
<CardDescription>
Customize the app name and logos for your self-hosted instance.
These will appear on the login page, sidebar, and browser tab.
</CardDescription>
</div>
</div>
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[25vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App name</FormLabel>
<FormControl>
<Input placeholder="e.g. My Company DevOps" {...field} />
</FormControl>
<FormDescription>
Replaces &quot;Dokploy&quot; in the UI when set.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="tagline"
render={({ field }) => (
<FormItem>
<FormLabel>Tagline</FormLabel>
<FormControl>
<Input
placeholder="e.g. The Open Source alternative to Netlify, Vercel, Heroku."
{...field}
/>
</FormControl>
<FormDescription>
Quote shown on the login/onboarding side panel. Leave empty
for default.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="logoUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Logo</FormLabel>
<FormControl>
<>
{field.value?.startsWith("data:") ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
<Logo
className="size-12 shrink-0"
logoUrl={field.value}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Uploaded image
</p>
<p className="text-xs text-muted-foreground">
Image is stored and will appear in sidebar and
login.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
logoFileInputRef.current?.click()
}
>
<ImagePlus className="mr-1 size-4" />
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange("")}
>
<X className="mr-1 size-4" />
Use URL instead
</Button>
</div>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="https://example.com/logo.png"
{...field}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => logoFileInputRef.current?.click()}
>
<ImagePlus className="mr-2 size-4" />
Upload image
</Button>
</div>
)}
<input
ref={logoFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_LOGO_SIZE_BYTES) {
toast.error("Image size must be less than 2MB");
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
e.target.value = "";
}}
/>
</>
</FormControl>
<FormDescription>
Paste a logo URL or upload an image (max 2MB). Used in
sidebar and login.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="faviconUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Favicon</FormLabel>
<FormControl>
<>
{field.value?.startsWith("data:") ? (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-lg border p-3 bg-muted/30">
{field.value && (
// biome-ignore lint/performance/noImgElement: favicon preview from data URL
<img
src={field.value}
alt="Favicon"
className="size-8 shrink-0 object-contain"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">
Uploaded favicon
</p>
<p className="text-xs text-muted-foreground">
Shown in the browser tab.
</p>
</div>
<div className="flex gap-2 shrink-0">
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
faviconFileInputRef.current?.click()
}
>
<ImagePlus className="mr-1 size-4" />
Replace
</Button>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange("")}
>
<X className="mr-1 size-4" />
Use URL instead
</Button>
</div>
</div>
</div>
) : (
<div className="flex gap-2">
<Input
placeholder="https://example.com/favicon.ico"
{...field}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={() => faviconFileInputRef.current?.click()}
>
<ImagePlus className="mr-2 size-4" />
Upload image
</Button>
</div>
)}
<input
ref={faviconFileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > MAX_FAVICON_SIZE_BYTES) {
toast.error(
"Favicon size must be less than 512KB",
);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const result = event.target?.result as string;
field.onChange(result);
};
reader.readAsDataURL(file);
}
e.target.value = "";
}}
/>
</>
</FormControl>
<FormDescription>
Paste a favicon URL or upload an image (max 512KB). Shown in
the browser tab.
</FormDescription>
</FormItem>
)}
/>
<FormField
control={form.control}
name="customCss"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between gap-2">
<FormLabel>Custom CSS</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
form.setValue("customCss", DEFAULT_THEME_CSS, {
shouldDirty: true,
});
toast.success("Default theme loaded. Edit and save.");
}}
>
Load default theme
</Button>
</div>
<FormControl>
<CodeEditor
language="css"
lineWrapping
lineNumbers={false}
wrapperClassName="min-h-[180px] rounded-md border"
placeholder={`:root {
--primary: 220 70% 50%;
--primary-foreground: 0 0% 100%;
}
.dark {
--primary: 220 70% 50%;
}`}
{...field}
/>
</FormControl>
<FormDescription>
Optional. Override theme colors using CSS variables. Use{" "}
<code className="rounded bg-muted px-1">:root</code> for
light mode and{" "}
<code className="rounded bg-muted px-1">.dark</code> for
dark mode. Values in HSL without &quot;hsl()&quot;.
Don&apos;t use quotes around colors (e.g.{" "}
<code className="rounded bg-muted px-1">red</code>, not
&quot;red&quot;). Max 8KB.
</FormDescription>
<Collapsible className="mt-2 group">
<CollapsibleTrigger className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="size-4 shrink-0 transition-transform group-data-[state=open]:rotate-180" />
Available CSS variables
</CollapsibleTrigger>
<CollapsibleContent>
<pre className="mt-2 rounded-md border bg-muted p-3 text-xs font-mono overflow-x-auto whitespace-pre text-muted-foreground">
{CSS_VARIABLES_REFERENCE}
</pre>
</CollapsibleContent>
</Collapsible>
</FormItem>
)}
/>
{form.watch("logoUrl") && (
<div className="rounded-lg border p-4 bg-muted/30">
<p className="text-sm text-muted-foreground mb-2">
Logo preview
</p>
<Logo
className="size-12"
logoUrl={form.watch("logoUrl") || undefined}
/>
</div>
)}
<Button type="submit" disabled={isPending}>
{isPending ? "Saving..." : "Save"}
</Button>
</form>
</Form>
)}
</div>
);
};

View File

@@ -1,23 +1,14 @@
import Link from "next/link";
import type React from "react";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { GithubIcon } from "../icons/data-tools-icons";
import { Logo } from "../shared/logo";
import { Button } from "../ui/button";
const DEFAULT_TAGLINE =
"The Open Source alternative to Netlify, Vercel, Heroku.";
interface Props {
children: React.ReactNode;
}
export const OnboardingLayout = ({ children }: Props) => {
const { data: whitelabel } = api.whitelabel.get.useQuery();
const appName = whitelabel?.appName ?? "Dokploy";
const logoUrl = whitelabel?.logoUrl ?? undefined;
const tagline = whitelabel?.tagline ?? DEFAULT_TAGLINE;
return (
<div className="container relative min-h-svh flex-col items-center justify-center flex lg:max-w-none lg:grid lg:grid-cols-2 lg:px-0 w-full">
<div className="relative hidden h-full flex-col p-10 text-primary dark:border-r lg:flex">
@@ -26,12 +17,15 @@ export const OnboardingLayout = ({ children }: Props) => {
href="https://dokploy.com"
className="relative z-20 flex items-center text-lg font-medium gap-4 text-primary"
>
<Logo className="size-10" logoUrl={logoUrl} />
{appName}
<Logo className="size-10" />
Dokploy
</Link>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg text-primary">&ldquo;{tagline}&rdquo;</p>
<p className="text-lg text-primary">
&ldquo;The Open Source alternative to Netlify, Vercel,
Heroku.&rdquo;
</p>
</blockquote>
</div>
</div>

View File

@@ -24,9 +24,7 @@ import {
LogIn,
type LucideIcon,
Package,
Palette,
PieChart,
Rocket,
Server,
ShieldCheck,
Star,
@@ -147,12 +145,6 @@ const MENU: Menu = {
url: "/dashboard/projects",
icon: Folder,
},
{
isSingle: true,
title: "Deployments",
url: "/dashboard/deployments",
icon: Rocket,
},
{
isSingle: true,
title: "Monitoring",
@@ -414,15 +406,6 @@ const MENU: Menu = {
// Only enabled for admins in non-cloud environments
isEnabled: ({ auth }) => !!(auth?.role === "owner"),
},
{
isSingle: true,
title: "Whitelabelling",
url: "/dashboard/settings/whitelabelling",
icon: Palette,
// Proprietary: only in non-cloud, admins only
isEnabled: ({ auth, isCloud }) =>
!!((auth?.role === "owner" || auth?.role === "admin") && !isCloud),
},
{
isSingle: true,
title: "SSO",
@@ -555,11 +538,8 @@ function LogoWrapper() {
function SidebarLogo() {
const { state } = useSidebar();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery(undefined, {
enabled: !isCloud,
});
const { data: user } = api.user.get.useQuery();
const { data: session } = api.user.session.useQuery();
const { data: session } = authClient.useSession();
const {
data: organizations,
refetch,
@@ -570,7 +550,8 @@ function SidebarLogo() {
const { mutateAsync: setDefaultOrganization, isLoading: isSettingDefault } =
api.organization.setDefault.useMutation();
const { isMobile } = useSidebar();
const { data: activeOrganization } = api.organization.active.useQuery();
const { data: activeOrganization } = authClient.useActiveOrganization();
const _utils = api.useUtils();
const { data: invitations, refetch: refetchInvitations } =
api.user.getInvitations.useQuery();
@@ -629,11 +610,7 @@ function SidebarLogo() {
"transition-all",
state === "collapsed" ? "size-4" : "size-5",
)}
logoUrl={
activeOrganization?.logo ||
whitelabel?.logoUrl ||
undefined
}
logoUrl={activeOrganization?.logo || undefined}
/>
</div>
<div
@@ -643,9 +620,7 @@ function SidebarLogo() {
)}
>
<p className="text-sm font-medium leading-none">
{activeOrganization?.name ??
whitelabel?.appName ??
"Select Organization"}
{activeOrganization?.name ?? "Select Organization"}
</p>
</div>
</div>
@@ -655,15 +630,15 @@ function SidebarLogo() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
className="rounded-lg"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
>
<DropdownMenuLabel className="text-xs text-muted-foreground shrink-0">
<DropdownMenuLabel className="text-xs text-muted-foreground">
Organizations
</DropdownMenuLabel>
<div className="overflow-y-auto overflow-x-hidden min-h-0 -mx-1 px-1">
<div className="max-h-[60vh] overflow-y-auto">
{organizations?.map((org) => {
const isDefault = org.members?.[0]?.isDefault ?? false;
return (

View File

@@ -2,8 +2,8 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
export function SignInWithGithub() {
const [isLoading, setIsLoading] = useState(false);

View File

@@ -2,8 +2,8 @@
import { useState } from "react";
import { toast } from "sonner";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { authClient } from "@/lib/auth-client";
export function SignInWithGoogle() {
const [isLoading, setIsLoading] = useState(false);

View File

@@ -2,9 +2,9 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useState } from "react";
import type { FieldArrayPath } from "react-hook-form";
import { useFieldArray, useForm, useWatch } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,7 +28,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const DEFAULT_SCOPES = ["openid", "email", "profile"];
@@ -59,7 +58,6 @@ const oidcProviderSchema = z.object({
type OidcProviderForm = z.infer<typeof oidcProviderSchema>;
interface RegisterOidcDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -72,86 +70,16 @@ const formDefaultValues = {
scopes: [...DEFAULT_SCOPES],
};
function parseOidcConfig(oidcConfig: string | null): {
clientId?: string;
clientSecret?: string;
scopes?: string[];
} | null {
if (!oidcConfig) return null;
try {
const parsed = JSON.parse(oidcConfig) as {
clientId?: string;
clientSecret?: string;
scopes?: string[];
};
return {
clientId: parsed.clientId,
clientSecret: parsed.clientSecret,
scopes: Array.isArray(parsed.scopes) ? parsed.scopes : undefined,
};
} catch {
return null;
}
}
export function RegisterOidcDialog({
providerId,
children,
}: RegisterOidcDialogProps) {
export function RegisterOidcDialog({ children }: RegisterOidcDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const form = useForm<OidcProviderForm>({
resolver: zodResolver(oidcProviderSchema),
defaultValues: formDefaultValues,
});
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const baseURL = useUrl();
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const oidc = parseOidcConfig(data.oidcConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
clientId: oidc?.clientId ?? "",
clientSecret: oidc?.clientSecret ?? "",
scopes:
oidc?.scopes && oidc.scopes.length > 0
? oidc.scopes
: [...DEFAULT_SCOPES],
});
}, [data, open, form]);
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<OidcProviderForm>,
@@ -202,11 +130,7 @@ export function RegisterOidcDialog({
},
});
toast.success(
isEdit
? "OIDC provider updated successfully"
: "OIDC provider registered successfully",
);
toast.success("OIDC provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -222,13 +146,11 @@ export function RegisterOidcDialog({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{isEdit ? "Update OIDC provider" : "Register OIDC provider"}
</DialogTitle>
<DialogTitle>Register OIDC provider</DialogTitle>
<DialogDescription>
{isEdit
? "Change issuer, domains, client settings or scopes. Provider ID cannot be changed."
: "Add any OIDC-compliant identity provider (e.g. Okta, Azure AD, Google Workspace, Auth0, Keycloak). Discovery will fill endpoints from the issuer URL when possible."}
Add any OIDC-compliant identity provider (e.g. Okta, Azure AD,
Google Workspace, Auth0, Keycloak). Discovery will fill endpoints
from the issuer URL when possible.
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -240,28 +162,11 @@ export function RegisterOidcDialog({
<FormItem>
<FormLabel>Provider ID</FormLabel>
<FormControl>
<Input
placeholder="e.g. okta or my-idp"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
<Input placeholder="e.g. okta or my-idp" {...field} />
</FormControl>
<FormDescription>
Unique identifier; used in callback URL path.
{isEdit && " Cannot be changed when editing."}
</FormDescription>
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -436,7 +341,7 @@ export function RegisterOidcDialog({
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
{isEdit ? "Update provider" : "Register provider"}
Register provider
</Button>
</DialogFooter>
</form>

View File

@@ -3,12 +3,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import {
type FieldArrayPath,
useFieldArray,
useForm,
useWatch,
} from "react-hook-form";
import { type FieldArrayPath, useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -33,7 +28,6 @@ import {
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
const domainsArraySchema = z
.array(z.string().trim())
@@ -64,7 +58,6 @@ const samlProviderSchema = z.object({
type SamlProviderForm = z.infer<typeof samlProviderSchema>;
interface RegisterSamlDialogProps {
providerId?: string;
children: React.ReactNode;
}
@@ -77,83 +70,24 @@ const formDefaultValues: SamlProviderForm = {
idpMetadataXml: "",
};
function parseSamlConfig(samlConfig: string | null): {
entryPoint?: string;
cert?: string;
idpMetadataXml?: string;
} | null {
if (!samlConfig) return null;
try {
const parsed = JSON.parse(samlConfig) as {
entryPoint?: string;
cert?: string;
idpMetadata?: { metadata?: string };
};
return {
entryPoint: parsed.entryPoint,
cert: parsed.cert,
idpMetadataXml: parsed.idpMetadata?.metadata,
};
} catch {
return null;
}
}
export function RegisterSamlDialog({
providerId,
children,
}: RegisterSamlDialogProps) {
export function RegisterSamlDialog({ children }: RegisterSamlDialogProps) {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { mutateAsync, isLoading } = api.sso.register.useMutation();
const { data } = api.sso.one.useQuery(
{ providerId: providerId ?? "" },
{ enabled: !!providerId && open },
);
const registerMutation = api.sso.register.useMutation();
const updateMutation = api.sso.update.useMutation();
const [baseURL, setBaseURL] = useState("");
const isEdit = !!providerId;
const mutateAsync = isEdit
? updateMutation.mutateAsync
: registerMutation.mutateAsync;
const isLoading = isEdit
? updateMutation.isLoading
: registerMutation.isLoading;
const baseURL = useUrl();
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const form = useForm<SamlProviderForm>({
resolver: zodResolver(samlProviderSchema),
defaultValues: formDefaultValues,
});
useEffect(() => {
if (!data || !open) return;
const domains = data.domain
? data.domain
.split(",")
.map((d) => d.trim())
.filter(Boolean)
: [""];
if (domains.length === 0) domains.push("");
const saml = parseSamlConfig(data.samlConfig);
form.reset({
providerId: data.providerId,
issuer: data.issuer,
domains,
entryPoint: saml?.entryPoint ?? "",
cert: saml?.cert ?? "",
idpMetadataXml: saml?.idpMetadataXml ?? "",
});
}, [data, open, form]);
const watchedProviderId = useWatch({
control: form.control,
name: "providerId",
defaultValue: "",
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "domains" as FieldArrayPath<SamlProviderForm>,
@@ -199,11 +133,7 @@ export function RegisterSamlDialog({
},
});
toast.success(
isEdit
? "SAML provider updated successfully"
: "SAML provider registered successfully",
);
toast.success("SAML provider registered successfully");
form.reset(formDefaultValues);
setOpen(false);
await utils.sso.listProviders.invalidate();
@@ -219,13 +149,10 @@ export function RegisterSamlDialog({
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{isEdit ? "Update SAML provider" : "Register SAML provider"}
</DialogTitle>
<DialogTitle>Register SAML provider</DialogTitle>
<DialogDescription>
{isEdit
? "Change issuer, domains, entry point or certificate. Provider ID cannot be changed."
: "Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML, OneLogin). You need the IdP's SSO URL and signing certificate."}
Add a SAML 2.0 identity provider (e.g. Okta SAML, Azure AD SAML,
OneLogin). You need the IdP&apos;s SSO URL and signing certificate.
</DialogDescription>
</DialogHeader>
<Form {...form}>
@@ -240,26 +167,8 @@ export function RegisterSamlDialog({
<Input
placeholder="e.g. okta-saml or azure-saml"
{...field}
readOnly={isEdit}
className={isEdit ? "bg-muted" : undefined}
/>
</FormControl>
{isEdit && (
<FormDescription>
Cannot be changed when editing.
</FormDescription>
)}
{baseURL && (
<div className="rounded-md bg-muted px-3 py-2 text-xs">
<p className="font-medium text-muted-foreground">
Callback URL (configure in your IdP)
</p>
<p className="mt-0.5 break-all font-mono">
{baseURL}/api/auth/sso/saml2/callback/
{watchedProviderId?.trim() || "..."}
</p>
</div>
)}
<FormMessage />
</FormItem>
)}
@@ -408,7 +317,7 @@ export function RegisterSamlDialog({
Cancel
</Button>
<Button type="submit" isLoading={isLoading}>
{isEdit ? "Update provider" : "Register provider"}
Register provider
</Button>
</DialogFooter>
</form>

View File

@@ -9,7 +9,7 @@ import {
Shield,
Trash2,
} from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
@@ -31,7 +31,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { useUrl } from "@/utils/hooks/use-url";
import { RegisterOidcDialog } from "./register-oidc-dialog";
import { RegisterSamlDialog } from "./register-saml-dialog";
@@ -77,12 +76,18 @@ export const SSOSettings = () => {
const utils = api.useUtils();
const [detailsProvider, setDetailsProvider] =
useState<ProviderForDetails | null>(null);
const baseURL = useUrl();
const [baseURL, setBaseURL] = useState("");
const [manageOriginsOpen, setManageOriginsOpen] = useState(false);
const [editingOrigin, setEditingOrigin] = useState<string | null>(null);
const [editingValue, setEditingValue] = useState("");
const [newOriginInput, setNewOriginInput] = useState("");
useEffect(() => {
if (typeof window !== "undefined") {
setBaseURL(window.location.origin);
}
}, []);
const { data: providers, isLoading } = api.sso.listProviders.useQuery();
const { data: userData } = api.user.get.useQuery(undefined, {
enabled: manageOriginsOpen,
@@ -266,22 +271,6 @@ export const SSOSettings = () => {
<Eye className="mr-1 size-3" />
View details
</Button>
{isOidc && (
<RegisterOidcDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterOidcDialog>
)}
{isSaml && (
<RegisterSamlDialog providerId={provider.providerId}>
<Button variant="ghost" size="sm">
<Pencil className="mr-1 size-3" />
Edit
</Button>
</RegisterSamlDialog>
)}
<DialogAction
title="Remove SSO provider"
description={`Remove provider "${provider.providerId}"? Users will no longer be able to sign in with this IdP.`}
@@ -361,7 +350,8 @@ export const SSOSettings = () => {
<DialogHeader>
<DialogTitle>SSO provider details</DialogTitle>
<DialogDescription>
Use Edit to change provider settings (OIDC or SAML).
View-only. To change settings, remove this provider and add it
again with the new values.
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 py-2">

View File

@@ -1,27 +0,0 @@
"use client";
import Head from "next/head";
import { api } from "@/utils/api";
/** When whitelabel is configured (non-cloud), overrides document title, favicon, and optional custom CSS. */
export function WhitelabelHead() {
const { data: whitelabel } = api.whitelabel.get.useQuery();
// Always render so we can override favicon (same key as _app default replaces it)
return (
<Head>
{whitelabel?.appName && <title>{whitelabel.appName}</title>}
{whitelabel?.faviconUrl ? (
<link rel="icon" href={whitelabel.faviconUrl} key="favicon" />
) : (
<link rel="icon" href="/icon.svg" key="favicon" />
)}
{whitelabel?.customCss?.trim() && (
<style
id="whitelabel-custom-css"
dangerouslySetInnerHTML={{ __html: whitelabel.customCss.trim() }}
/>
)}
</Head>
);
}

View File

@@ -4,14 +4,12 @@ import {
type CompletionContext,
type CompletionResult,
} from "@codemirror/autocomplete";
import { css } from "@codemirror/lang-css";
import { json } from "@codemirror/lang-json";
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 { search, searchKeymap } from "@codemirror/search";
import { EditorView, keymap } from "@codemirror/view";
import { EditorView } from "@codemirror/view";
import { githubDark, githubLight } from "@uiw/codemirror-theme-github";
import CodeMirror, { type ReactCodeMirrorProps } from "@uiw/react-codemirror";
import { useTheme } from "next-themes";
@@ -132,7 +130,7 @@ function dockerComposeComplete(
interface Props extends ReactCodeMirrorProps {
wrapperClassName?: string;
disabled?: boolean;
language?: "yaml" | "json" | "properties" | "shell" | "css";
language?: "yaml" | "json" | "properties" | "shell";
lineWrapping?: boolean;
lineNumbers?: boolean;
}
@@ -157,8 +155,6 @@ export const CodeEditor = ({
}}
theme={resolvedTheme === "dark" ? githubDark : githubLight}
extensions={[
search(),
keymap.of(searchKeymap),
language === "yaml"
? yaml()
: language === "json"

View File

@@ -1,17 +1,15 @@
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
className?: string;
logoUrl?: string;
}
export const Logo = ({ className = "size-14" }: Props) => {
const { data: whitelabel } = api.whitelabel.get.useQuery();
if (whitelabel?.logoUrl) {
export const Logo = ({ className = "size-14", logoUrl }: Props) => {
if (logoUrl) {
return (
<img
src={whitelabel?.logoUrl}
src={logoUrl}
alt="Organization Logo"
className={cn(className, "object-contain rounded-sm")}
/>

View File

@@ -1 +0,0 @@
ALTER TABLE "sso_provider" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1002,34 +1002,6 @@
"when": 1770615019498,
"tag": "0142_outstanding_tusk",
"breakpoints": true
},
{
"idx": 143,
"version": "7",
"when": 1770961667210,
"tag": "0143_brown_ultron",
"breakpoints": true
},
{
"idx": 144,
"version": "7",
"when": 1771222951607,
"tag": "0144_flaky_psylocke",
"breakpoints": true
},
{
"idx": 145,
"version": "7",
"when": 1771226249421,
"tag": "0145_modern_risque",
"breakpoints": true
},
{
"idx": 146,
"version": "7",
"when": 1771226541422,
"tag": "0146_last_kitty_pryde",
"breakpoints": true
}
]
}

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.28.5",
"version": "v0.27.1",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -49,13 +49,11 @@
"@ai-sdk/openai": "^2.0.16",
"@ai-sdk/openai-compatible": "^1.0.10",
"@codemirror/autocomplete": "^6.18.6",
"@codemirror/lang-css": "^6.2.1",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/search": "^6.6.0",
"@codemirror/view": "^6.39.15",
"@codemirror/view": "6.29.0",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.4",
"@faker-js/faker": "^8.4.1",

View File

@@ -9,7 +9,6 @@ import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { WhitelabelHead } from "@/components/proprietary/whitelabel-head";
import { Toaster } from "@/components/ui/sonner";
import { Languages } from "@/lib/languages";
import { api } from "@/utils/api";
@@ -27,10 +26,9 @@ type AppPropsWithLayout = AppProps & {
const MyApp = ({
Component,
pageProps: { whitelabelFaviconUrl, ...pageProps },
pageProps: { ...pageProps },
}: AppPropsWithLayout) => {
const getLayout = Component.getLayout ?? ((page) => page);
const faviconHref = whitelabelFaviconUrl ?? "/icon.svg";
return (
<>
@@ -43,9 +41,7 @@ const MyApp = ({
</style>
<Head>
<title>Dokploy</title>
<link rel="icon" href={faviconHref} key="favicon" />
</Head>
<WhitelabelHead />
<ThemeProvider
attribute="class"
defaultTheme="system"

View File

@@ -4,7 +4,7 @@ export default function Document() {
return (
<Html lang="en" className="font-sans">
<Head>
{/* Default favicon; WhitelabelHead overrides with key="favicon" when custom favicon is set */}
<link rel="icon" href="/icon.svg" />
</Head>
<body className="flex h-full w-full flex-col font-sans">
<Main />

View File

@@ -10,29 +10,22 @@ 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 }: Query = req.query as Query;
const { code, state, installation_id, userId }: Query = req.query as Query;
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
}
const [action, ...rest] = state?.split(":");
// For gh_init: rest[0] = organizationId, rest[1] = userId
// For gh_setup: rest[0] = githubProviderId
const [action, value] = state?.split(":");
// Value could be the organizationId or the 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",
@@ -51,7 +44,7 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
},
organizationId as string,
value as string,
userId,
);
} else if (action === "gh_setup") {
@@ -60,7 +53,7 @@ export default async function handler(
.set({
githubInstallationId: installation_id,
})
.where(eq(github.githubId, rest[0] as string))
.where(eq(github.githubId, value as string))
.returning();
}

View File

@@ -1,94 +0,0 @@
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 (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<CardHeader>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="text-xl font-bold flex items-center gap-2">
<Rocket className="size-5" />
Deployments
</CardTitle>
<CardDescription>
All application and compose deployments in one place.
</CardDescription>
</div>
</div>
<Tabs value={tab} onValueChange={setTab} className="w-full">
<TabsList className="mt-2">
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="queue">Queue</TabsTrigger>
</TabsList>
<TabsContent value="deployments" className="mt-0 pt-4">
<ShowDeploymentsTable />
</TabsContent>
<TabsContent value="queue" className="mt-0 pt-4">
<ShowQueueTable />
</TabsContent>
</Tabs>
</CardHeader>
</div>
</Card>
</div>
);
}
export default DeploymentsPage;
DeploymentsPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { user } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@@ -785,7 +785,7 @@ const EnvironmentPage = (
}
if (success > 0) {
toast.success(
`${success} service${success !== 1 ? "s" : ""} queued for deployment`,
`${success} service${success !== 1 ? "s" : ""} deployed successfully`,
);
}
if (failed > 0) {

View File

@@ -90,7 +90,6 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.project?.projectId || "",
@@ -122,8 +121,7 @@ const Service = (
/>
<Head>
<title>
Application: {data?.name} - {data?.environment.project.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Application: {data?.name} - {data?.environment.project.name} | Dokploy
</title>
</Head>
<div className="w-full">
@@ -167,7 +165,7 @@ const Service = (
: "destructive"
}
>
{data?.server?.name || `${whitelabel?.appName ?? "Dokploy"} Server`}
{data?.server?.name || "Dokploy Server"}
</Badge>
{data?.server?.serverStatus === "inactive" && (
<TooltipProvider delayDuration={0}>

View File

@@ -79,7 +79,6 @@ const Service = (
const { data } = api.compose.one.useQuery({ composeId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
projectId: data?.environment?.projectId || "",
@@ -111,8 +110,7 @@ const Service = (
/>
<Head>
<title>
Compose: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Compose: {data?.name} - {data?.environment?.project?.name} | Dokploy
</title>
</Head>
<div className="w-full">

View File

@@ -59,7 +59,6 @@ const Mariadb = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mariadb.one.useQuery({ mariadbId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -94,8 +93,8 @@ const Mariadb = (
<div className="flex flex-col gap-4">
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
</title>
</Head>
<Card className="h-full bg-sidebar p-2.5 rounded-xl w-full">

View File

@@ -59,7 +59,6 @@ const Mongo = (
const { data } = api.mongo.one.useQuery({ mongoId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -92,8 +91,7 @@ const Mongo = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
</title>
</Head>
<div className="w-full">

View File

@@ -58,7 +58,6 @@ const MySql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.mysql.one.useQuery({ mysqlId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -92,8 +91,8 @@ const MySql = (
<div className="flex flex-col gap-4">
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Database: {data?.name} - {data?.environment?.project?.name} |
Dokploy
</title>
</Head>
<div className="w-full">

View File

@@ -58,7 +58,6 @@ const Postgresql = (
const [tab, setSab] = useState<TabState>(activeTab);
const { data } = api.postgres.one.useQuery({ postgresId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -91,8 +90,7 @@ const Postgresql = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
</title>
</Head>
<div className="w-full">

View File

@@ -58,7 +58,6 @@ const Redis = (
const { data } = api.redis.one.useQuery({ redisId });
const { data: auth } = api.user.get.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: environments } = api.environment.byProjectId.useQuery({
@@ -91,8 +90,7 @@ const Redis = (
/>
<Head>
<title>
Database: {data?.name} - {data?.environment?.project?.name} |{" "}
{whitelabel?.appName ?? "Dokploy"}
Database: {data?.name} - {data?.environment?.project?.name} | Dokploy
</title>
</Head>
<div className="w-full">

View File

@@ -1,96 +0,0 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { WhitelabelForm } from "@/components/dashboard/settings/whitelabelling/whitelabel-form";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { EnterpriseFeatureGate } from "@/components/proprietary/enterprise-feature-gate";
import { Card } from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
const Page = () => {
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<div className="p-6">
<EnterpriseFeatureGate
lockedProps={{
title: "Whitelabelling",
description:
"Customise app name and logos (whitelabelling) is part of Dokploy Enterprise. Add a valid license to use it.",
ctaLabel: "Go to License",
}}
>
<WhitelabelForm />
</EnterpriseFeatureGate>
</div>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => (
<DashboardLayout metaName="Whitelabelling">{page}</DashboardLayout>
);
export async function getServerSideProps(
ctx: GetServerSidePropsContext<Record<string, string>>,
) {
const { req, res } = ctx;
const locale = await getLocale(req.cookies);
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user } = await validateRequest(req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
if (user.role !== "owner" && user.role !== "admin") {
return {
redirect: {
permanent: true,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: null as any,
user: user as any,
},
transformer: superjson,
});
await helpers.whitelabel.get.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
...(await serverSideTranslations(locale, ["settings"])),
},
};
}

View File

@@ -1,7 +1,6 @@
import { IS_CLOUD, isAdminPresent } from "@dokploy/server";
import { validateRequest } from "@dokploy/server/lib/auth";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
@@ -9,7 +8,6 @@ import { useRouter } from "next/router";
import { type ReactElement, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { SignInWithGithub } from "@/components/proprietary/auth/sign-in-with-github";
@@ -42,7 +40,6 @@ import {
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const LoginSchema = z.object({
@@ -62,7 +59,6 @@ interface Props {
export default function Home({ IS_CLOUD }: Props) {
const router = useRouter();
const { data: showSignInWithSSO } = api.sso.showSignInWithSSO.useQuery();
const { data: whitelabel } = api.whitelabel.get.useQuery();
const [isLoginLoading, setIsLoginLoading] = useState(false);
const [isTwoFactorLoading, setIsTwoFactorLoading] = useState(false);
const [isBackupCodeLoading, setIsBackupCodeLoading] = useState(false);
@@ -439,25 +435,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: context.req as any,
res: context.res as any,
db: null as any,
session: null,
user: null,
},
transformer: superjson,
});
await helpers.whitelabel.get.prefetch();
const whitelabel = await helpers.whitelabel.get.fetch();
return {
props: {
hasAdmin,
trpcState: helpers.dehydrate(),
whitelabelFaviconUrl: whitelabel?.faviconUrl ?? null,
},
};
}

View File

@@ -1,13 +1,11 @@
import { getUserByToken, IS_CLOUD } from "@dokploy/server";
import { zodResolver } from "@hookform/resolvers/zod";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import Link from "next/link";
import { useRouter } from "next/router";
import { type ReactElement, useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import superjson from "superjson";
import { z } from "zod";
import { OnboardingLayout } from "@/components/layouts/onboarding-layout";
import { AlertBlock } from "@/components/shared/alert-block";
@@ -24,7 +22,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const registerSchema = z
@@ -94,7 +91,6 @@ const Invitation = ({
initialData: invitation,
},
);
const { data: whitelabel } = api.whitelabel.get.useQuery();
const form = useForm<Register>({
defaultValues: {
@@ -350,21 +346,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
try {
const invitation = await getUserByToken(token);
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: ctx.req as any,
res: ctx.res as any,
db: null as any,
session: null,
user: null,
},
transformer: superjson,
});
if (!IS_CLOUD) {
await helpers.whitelabel.get.prefetch();
}
if (invitation.userAlreadyExists) {
return {
props: {
@@ -372,7 +353,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
token: token,
invitation: invitation,
userAlreadyExists: true,
trpcState: helpers.dehydrate(),
},
};
}
@@ -391,7 +371,6 @@ export async function getServerSideProps(ctx: GetServerSidePropsContext) {
isCloud: IS_CLOUD,
token: token,
invitation: invitation,
trpcState: helpers.dehydrate(),
},
};
} catch (error) {

View File

@@ -22,7 +22,6 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { authClient } from "@/lib/auth-client";
import { api } from "@/utils/api";
const loginSchema = z.object({
email: z
@@ -49,7 +48,6 @@ export default function Home() {
});
const [error, setError] = useState<string | null>(null);
const { data: whitelabel } = api.whitelabel.get.useQuery();
const [isLoading, setIsLoading] = useState(false);
const _router = useRouter();
const form = useForm<Login>({
@@ -84,9 +82,7 @@ export default function Home() {
<div className="flex flex-col items-center gap-4 w-full">
<Link href="/" className="flex flex-row items-center gap-2">
<Logo />
<span className="font-medium text-sm">
{whitelabel?.appName ?? "Dokploy"}
</span>
<span className="font-medium text-sm">Dokploy</span>
</Link>
<CardTitle className="text-2xl font-bold">Reset Password</CardTitle>
<CardDescription>

View File

@@ -22,13 +22,12 @@ import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { whitelabelRouter } from "./routers/proprietary/whitelabel";
import { portRouter } from "./routers/port";
import { postgresRouter } from "./routers/postgres";
import { previewDeploymentRouter } from "./routers/preview-deployment";
import { projectRouter } from "./routers/project";
import { licenseKeyRouter } from "./routers/proprietary/license-key";
import { ssoRouter } from "./routers/proprietary/sso";
import { redirectsRouter } from "./routers/redirects";
import { redisRouter } from "./routers/redis";
import { registryRouter } from "./routers/registry";
@@ -87,7 +86,6 @@ export const appRouter = createTRPCRouter({
organization: organizationRouter,
licenseKey: licenseKeyRouter,
sso: ssoRouter,
whitelabel: whitelabelRouter,
schedule: scheduleRouter,
rollback: rollbackRouter,
volumeBackups: volumeBackupsRouter,

View File

@@ -4,15 +4,10 @@ import {
findAllDeploymentsByApplicationId,
findAllDeploymentsByComposeId,
findAllDeploymentsByServerId,
findAllDeploymentsCentralized,
findApplicationById,
findComposeById,
findDeploymentById,
findMemberById,
findServerById,
IS_CLOUD,
removeDeployment,
resolveServicePath,
updateDeploymentStatus,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
@@ -25,10 +20,7 @@ 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({
@@ -75,63 +67,6 @@ 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<string, unknown>,
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<string, unknown>,
),
})),
);
}),
allByType: protectedProcedure
.input(apiFindAllByType)
@@ -143,8 +78,10 @@ export const deploymentRouter = createTRPCRouter({
rollback: true,
},
});
return deploymentsList;
}),
killProcess: protectedProcedure
.input(
z.object({

View File

@@ -21,7 +21,8 @@ export const mountRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateMount)
.mutation(async ({ input }) => {
return await createMount(input);
await createMount(input);
return true;
}),
remove: protectedProcedure
.input(apiRemoveMount)

View File

@@ -355,13 +355,4 @@ 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),
});
}),
});

View File

@@ -55,128 +55,9 @@ export const ssoRouter = createTRPCRouter({
samlConfig: true,
organizationId: true,
},
orderBy: [asc(ssoProvider.createdAt)],
});
return providers;
}),
one: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
const provider = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
providerId: true,
issuer: true,
domain: true,
oidcConfig: true,
samlConfig: true,
organizationId: true,
},
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to access it",
});
}
return provider;
}),
update: enterpriseProcedure
.input(ssoProviderBodySchema)
.mutation(async ({ ctx, input }) => {
const existing = await db.query.ssoProvider.findFirst({
where: and(
eq(ssoProvider.providerId, input.providerId),
eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
eq(ssoProvider.userId, ctx.session.userId),
),
columns: {
id: true,
issuer: true,
domain: true,
},
});
if (!existing) {
throw new TRPCError({
code: "NOT_FOUND",
message:
"SSO provider not found or you do not have permission to update it",
});
}
const providers = await db.query.ssoProvider.findMany({
where: eq(ssoProvider.organizationId, ctx.session.activeOrganizationId),
columns: { providerId: true, domain: true },
});
for (const provider of providers) {
if (provider.providerId === input.providerId) continue;
const providerDomains = provider.domain
.split(",")
.map((d) => d.trim().toLowerCase());
for (const domain of input.domains) {
if (providerDomains.includes(domain)) {
throw new TRPCError({
code: "BAD_REQUEST",
message: `Domain ${domain} is already registered for another provider`,
});
}
}
}
const issuerChanged =
normalizeTrustedOrigin(existing.issuer) !==
normalizeTrustedOrigin(input.issuer);
if (issuerChanged) {
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: { trustedOrigins: true },
});
const trustedOrigins = currentUser?.trustedOrigins ?? [];
const newOrigin = normalizeTrustedOrigin(input.issuer);
const isInTrustedOrigins = trustedOrigins.some(
(o) => o.toLowerCase() === newOrigin.toLowerCase(),
);
if (!isInTrustedOrigins) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"The new Issuer URL is not in your trusted origins list. Please add it in Manage origins before saving.",
});
}
}
const domain = input.domains.join(",");
const updateBody: {
issuer: string;
domain: string;
oidcConfig?: (typeof input)["oidcConfig"];
samlConfig?: (typeof input)["samlConfig"];
} = {
issuer: input.issuer,
domain,
};
if (input.oidcConfig != null) {
updateBody.oidcConfig = input.oidcConfig;
}
if (input.samlConfig != null) {
updateBody.samlConfig = input.samlConfig;
}
await auth.updateSSOProvider({
params: { providerId: input.providerId },
body: updateBody,
headers: requestToHeaders(ctx.req),
});
return { success: true };
}),
deleteProvider: enterpriseProcedure
.input(z.object({ providerId: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
@@ -221,6 +102,24 @@ export const ssoRouter = createTRPCRouter({
});
}
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
if (currentUser?.trustedOrigins) {
const issuerOrigin = normalizeTrustedOrigin(providerToDelete.issuer);
const updatedOrigins = currentUser.trustedOrigins.filter(
(origin) => origin.toLowerCase() !== issuerOrigin.toLowerCase(),
);
await db
.update(user)
.set({ trustedOrigins: updatedOrigins })
.where(eq(user.id, ctx.session.userId));
}
return { success: true };
}),
register: enterpriseProcedure
@@ -248,6 +147,25 @@ export const ssoRouter = createTRPCRouter({
}
}
const domain = input.domains.join(",");
const currentUser = await db.query.user.findFirst({
where: eq(user.id, ctx.session.userId),
columns: {
trustedOrigins: true,
},
});
const existingOrigins = currentUser?.trustedOrigins || [];
const issuerOrigin = normalizeTrustedOrigin(input.issuer);
const newOrigins = Array.from(
new Set([...existingOrigins, issuerOrigin]),
);
await db
.update(user)
.set({ trustedOrigins: newOrigins })
.where(eq(user.id, ctx.session.userId));
await auth.registerSSOProvider({
body: {

View File

@@ -1,61 +0,0 @@
import { getWebServerSettings, updateWebServerSettings } from "@dokploy/server";
import { IS_CLOUD } from "@dokploy/server/constants";
import { apiUpdateWhitelabelSettings } from "@dokploy/server/db/schema";
import {
createTRPCRouter,
enterpriseProcedure,
publicProcedure,
} from "@/server/api/trpc";
export const whitelabelRouter = createTRPCRouter({
get: publicProcedure.query(async () => {
if (IS_CLOUD) return null;
const settings = await getWebServerSettings();
if (!settings) return null;
return {
appName: settings.whitelabelAppName ?? null,
logoUrl: settings.whitelabelLogoUrl ?? null,
faviconUrl: settings.whitelabelFaviconUrl ?? null,
tagline: settings.whitelabelTagline ?? null,
customCss: settings.whitelabelCustomCss ?? null,
};
}),
update: enterpriseProcedure
.input(apiUpdateWhitelabelSettings)
.mutation(async ({ input }) => {
if (IS_CLOUD) {
throw new Error(
"Whitelabelling is only available in self-hosted (non-cloud) installations.",
);
}
await updateWebServerSettings({
...(input.appName !== undefined && {
whitelabelAppName: input.appName,
}),
...(input.tagline !== undefined && {
whitelabelTagline: input.tagline,
}),
...(input.logoUrl !== undefined && {
whitelabelLogoUrl: input.logoUrl,
}),
...(input.faviconUrl !== undefined && {
whitelabelFaviconUrl: input.faviconUrl,
}),
...(input.customCss !== undefined && {
whitelabelCustomCss: input.customCss,
}),
});
const settings = await getWebServerSettings();
return {
appName: settings?.whitelabelAppName ?? null,
logoUrl: settings?.whitelabelLogoUrl ?? null,
faviconUrl: settings?.whitelabelFaviconUrl ?? null,
tagline: settings?.whitelabelTagline ?? null,
customCss: settings?.whitelabelCustomCss ?? null,
};
}),
});

View File

@@ -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 conflictInfo = portCheck.conflictingContainer
? ` by ${portCheck.conflictingContainer}`
const conflictingContainer = portCheck.conflictingContainer
? ` by container "${portCheck.conflictingContainer}"`
: "";
throw new TRPCError({
code: "CONFLICT",
message: `Port 8080 is already in use${conflictInfo}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
message: `Port 8080 is already in use${conflictingContainer}. Please stop the conflicting service or use a different port for the Traefik dashboard.`,
});
}
newPorts.push({

View File

@@ -101,16 +101,6 @@ 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(

View File

@@ -50,34 +50,3 @@ export const cancelDeployment = async (cancelData: CancelDeploymentData) => {
throw error;
}
};
export type QueueJobRow = {
id: string;
name?: string;
data: Record<string, unknown>;
timestamp?: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: string;
};
export const fetchDeployApiJobs = async (
serverId: string,
): Promise<QueueJobRow[]> => {
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 [];
}
};

View File

@@ -1,8 +1,9 @@
import { exit } from "node:process";
import { exec } from "node:child_process";
import { exit } from "node:process";
import { promisify } from "node:util";
const execAsync = promisify(exec);
import { setupDirectories } from "@dokploy/server/setup/config-paths";
import { initializePostgres } from "@dokploy/server/setup/postgres-setup";
import { initializeRedis } from "@dokploy/server/setup/redis-setup";

View File

@@ -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 = ? OR container_name LIKE ?
WHERE container_name = ?
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, containerName+".%", limit)
rows, err := db.Query(query, 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 = ? OR container_name LIKE ?
WHERE container_name = ?
ORDER BY timestamp DESC
)
SELECT metrics_json FROM recent_metrics ORDER BY json_extract(metrics_json, '$.timestamp') ASC
`
rows, err := db.Query(query, containerName, containerName+".%")
rows, err := db.Query(query, containerName)
if err != nil {
return nil, err
}

View File

@@ -2,24 +2,8 @@ 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({
...(DOCKER_API_VERSION && {
version: DOCKER_API_VERSION,
}),
...(DOCKER_HOST && {
host: DOCKER_HOST,
}),
...(DOCKER_PORT && {
port: DOCKER_PORT,
}),
});
export const docker = new Docker();
// When not set, use the legacy default so 2FA remains working for users who
// enabled it before BETTER_AUTH_SECRET was introduced .

View File

@@ -362,13 +362,12 @@ 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().optional(),
watchPaths: z.array(z.string()).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({
@@ -433,13 +432,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({
@@ -451,9 +450,10 @@ export const apiSaveGitlabProvider = createSchema
gitlabId: true,
gitlabProjectId: true,
gitlabPathNamespace: true,
watchPaths: true,
enableSubmodules: true,
})
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
.required();
export const apiSaveBitbucketProvider = createSchema
.pick({
@@ -464,9 +464,10 @@ export const apiSaveBitbucketProvider = createSchema
bitbucketRepositorySlug: true,
bitbucketId: true,
applicationId: true,
watchPaths: true,
enableSubmodules: true,
})
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
.required();
export const apiSaveGiteaProvider = createSchema
.pick({
@@ -476,9 +477,10 @@ export const apiSaveGiteaProvider = createSchema
giteaOwner: true,
giteaRepository: true,
giteaId: true,
watchPaths: true,
enableSubmodules: true,
})
.required()
.merge(createSchema.pick({ enableSubmodules: true, watchPaths: true }));
.required();
export const apiSaveDockerProvider = createSchema
.pick({
@@ -503,7 +505,6 @@ export const apiSaveGitProvider = createSchema
.merge(
createSchema.pick({
customGitSSHKeyId: true,
enableSubmodules: true,
}),
);

View File

@@ -99,15 +99,17 @@ 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",
]),
serviceType: z
.enum([
"application",
"postgres",
"mysql",
"mariadb",
"mongo",
"redis",
"compose",
])
.default("application"),
});
export type ServiceType = NonNullable<

View File

@@ -1,5 +1,5 @@
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
import { pgTable, text } from "drizzle-orm/pg-core";
import { z } from "zod";
import { organization } from "./account";
import { user } from "./user";
@@ -15,7 +15,6 @@ export const ssoProvider = pgTable("sso_provider", {
onDelete: "cascade",
}),
domain: text("domain").notNull(),
createdAt: timestamp("created_at").notNull().defaultNow(),
});
export const ssoProviderRelations = relations(ssoProvider, ({ one }) => ({

View File

@@ -76,12 +76,6 @@ export const webServerSettings = pgTable("webServerSettings", {
cleanupCacheOnCompose: boolean("cleanupCacheOnCompose")
.notNull()
.default(false),
// Whitelabelling (non-cloud only)
whitelabelAppName: text("whitelabelAppName"),
whitelabelLogoUrl: text("whitelabelLogoUrl"),
whitelabelFaviconUrl: text("whitelabelFaviconUrl"),
whitelabelTagline: text("whitelabelTagline"),
whitelabelCustomCss: text("whitelabelCustomCss"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
@@ -131,59 +125,6 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
cleanupCacheApplications: z.boolean().optional(),
cleanupCacheOnPreviews: z.boolean().optional(),
cleanupCacheOnCompose: z.boolean().optional(),
whitelabelAppName: z.string().max(256).optional().nullable(),
whitelabelLogoUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
whitelabelFaviconUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
whitelabelTagline: z.string().max(512).optional().nullable(),
whitelabelCustomCss: z.string().max(8192).optional().nullable(),
});
export const apiUpdateWhitelabelSettings = z.object({
appName: z.string().max(256).optional().nullable(),
tagline: z.string().max(512).optional().nullable(),
logoUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
faviconUrl: z
.union([
z.string().url(),
z
.string()
.startsWith("data:"), // uploaded image as data URL
z.literal(""),
])
.optional()
.nullable()
.transform((v) => (v === "" ? null : v)),
customCss: z.string().max(8192).optional().nullable(),
});
export const apiAssignDomain = z

View File

@@ -18,7 +18,9 @@ import { getHubSpotUTK, submitToHubSpot } from "../utils/tracking/hubspot";
import { sendEmail } from "../verification/send-verification-email";
import { getPublicIpWithFallback } from "../wss/utils";
const trustedProviders = process.env?.TRUSTED_PROVIDERS?.split(",") || [];
const query = await db.query.ssoProvider.findMany();
const trustedProviders = query.map((provider) => provider.providerId);
const { handler, api } = betterAuth({
database: drizzleAdapter(db, {
@@ -347,7 +349,6 @@ export const auth = {
handler,
createApiKey: api.createApiKey,
registerSSOProvider: api.registerSSOProvider,
updateSSOProvider: api.updateSSOProvider,
};
export const validateRequest = async (request: IncomingMessage) => {

View File

@@ -125,37 +125,7 @@ export const getTrustedOrigins = async () => {
},
});
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) {
if (members.length === 0) {
return [];
}

View File

@@ -395,14 +395,16 @@ export const removeCompose = async (
if (compose.composeType === "stack") {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;
docker stack rm ${compose.appName};
rm -rf ${projectPath}`;
cd ${projectPath} && docker stack rm ${compose.appName} && rm -rf ${projectPath}`;
if (compose.serverId) {
await execAsyncRemote(compose.serverId, command);
} else {
await execAsync(command);
}
await execAsync(command, {
cwd: projectPath,
});
} else {
const command = `
docker network disconnect ${compose.appName} dokploy-traefik;

View File

@@ -10,18 +10,13 @@ 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 { and, desc, eq, inArray, or, sql } from "drizzle-orm";
import type { z } from "zod";
import { desc, eq } from "drizzle-orm";
import {
type Application,
findApplicationById,
@@ -39,41 +34,6 @@ 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<string, unknown>,
): Promise<ServicePath> {
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) => {
@@ -114,12 +74,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);
@@ -197,12 +157,13 @@ 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");
@@ -277,12 +238,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`;
@@ -365,8 +326,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`;
@@ -435,12 +396,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`;
@@ -511,14 +472,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`;
@@ -593,27 +554,11 @@ export const removeDeployment = async (deploymentId: string) => {
const deployment = await db
.delete(deployments)
.where(eq(deployments.deploymentId, deploymentId))
.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;
.returning();
return deployment[0];
} catch (error) {
const message =
error instanceof Error ? error.message : "Error removing the deployment";
error instanceof Error ? error.message : "Error creating the deployment";
throw new TRPCError({
code: "BAD_REQUEST",
message,
@@ -681,49 +626,34 @@ const removeLastTenDeployments = async (
if (serverId) {
let command = "";
for (const oldDeployment of deploymentsToDelete) {
try {
const logPath = path.join(oldDeployment.logPath);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
if (logPath && logPath !== ".") {
command += `rm -rf ${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 (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
if (logPath !== ".") {
command += `
rm -rf ${logPath};
`;
}
await removeDeployment(oldDeployment.deploymentId);
}
if (command) {
await execAsyncRemote(serverId, command);
}
await execAsyncRemote(serverId, command);
} else {
for (const oldDeployment of deploymentsToDelete) {
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,
);
if (oldDeployment.rollbackId) {
await removeRollbackById(oldDeployment.rollbackId);
}
const logPath = path.join(oldDeployment.logPath);
if (
existsSync(logPath) &&
!oldDeployment.errorMessage &&
logPath !== "."
) {
await fsPromises.unlink(logPath);
}
await removeDeployment(oldDeployment.deploymentId);
}
}
}
@@ -787,135 +717,6 @@ 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<string[]> {
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<string[]> {
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<Deployment>,

View File

@@ -15,7 +15,7 @@ function shEscape(s: string | undefined): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
export function safeDockerLoginCommand(
function safeDockerLoginCommand(
registry: string | undefined,
user: string | undefined,
pass: string | undefined,

View File

@@ -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, safeDockerLoginCommand } from "./registry";
import type { Registry } from "./registry";
export const createRollback = async (
input: z.infer<typeof createRollbackSchema>,
@@ -111,7 +111,7 @@ const deleteRollbackImage = async (image: string, serverId?: string | null) => {
const command = `docker image rm ${image} --force`;
if (serverId) {
await execAsyncRemote(serverId, command);
await execAsyncRemote(command, serverId);
} else {
await execAsync(command);
}
@@ -171,23 +171,6 @@ 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,
@@ -205,14 +188,6 @@ 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

View File

@@ -413,38 +413,17 @@ export const checkPortInUse = async (
serverId?: string,
): Promise<{ isInUse: boolean; conflictingContainer?: string }> => {
try {
// 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 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);
const container = dockerOut.trim();
const container = stdout.trim();
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 };
return {
isInUse: !!container,
conflictingContainer: container || undefined,
};
} catch (error) {
console.error("Error checking port availability:", error);
return { isInUse: false };

View File

@@ -30,18 +30,6 @@ 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,

View File

@@ -14,14 +14,13 @@ export const runComposeBackup = async (
compose: Compose,
backup: BackupSchedule,
) => {
const { environmentId, name, appName } = compose;
const { environmentId, name } = compose;
const environment = await findEnvironmentById(environmentId);
const project = await findProjectById(environment.projectId);
const { prefix, databaseType, serviceName } = backup;
const { prefix, databaseType } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.sql.gz`;
const s3AppName = serviceName ? `${appName}_${serviceName}` : appName;
const bucketDestination = `${s3AppName}/${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "Compose Backup",

View File

@@ -1,3 +1,4 @@
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";
@@ -10,7 +11,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, normalizeS3Path, scheduleBackup } from "./utils";
import { getS3Credentials, scheduleBackup } from "./utils";
export const initCronJobs = async () => {
console.log("Setting up cron jobs....");
@@ -94,20 +95,6 @@ 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,
@@ -118,16 +105,18 @@ export const keepLatestNBackups = async (
try {
const rcloneFlags = getS3Credentials(backup.destination);
const appName = getServiceAppName(backup);
const backupFilesPath = `:s3:${backup.destination.bucket}/${appName}/${normalizeS3Path(backup.prefix)}`;
const backupFilesPath = path.join(
`:s3:${backup.destination.bucket}`,
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}`;

View File

@@ -14,13 +14,13 @@ export const runMariadbBackup = async (
mariadb: Mariadb,
backup: BackupSchedule,
) => {
const { environmentId, name, appName } = mariadb;
const { environmentId, name } = 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 = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MariaDB Backup",

View File

@@ -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, appName } = mongo;
const { environmentId, name } = 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 = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MongoDB Backup",

View File

@@ -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, appName } = mysql;
const { environmentId, name } = 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 = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const deployment = await createDeploymentBackup({
backupId: backup.backupId,
title: "MySQL Backup",

View File

@@ -14,7 +14,7 @@ export const runPostgresBackup = async (
postgres: Postgres,
backup: BackupSchedule,
) => {
const { name, environmentId, appName } = postgres;
const { name, environmentId } = 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 = `${appName}/${normalizeS3Path(prefix)}${backupFileName}`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;

View File

@@ -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}/${backup.appName}/${normalizeS3Path(backup.prefix)}${backupFileName}`;
const s3Path = `:s3:${destination.bucket}/${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 --no-specials --no-devices ${BASE_PATH}/ ${tempDir}/filesystem/`,
`rsync -a --ignore-errors ${BASE_PATH}/ ${tempDir}/filesystem/`,
);
writeStream.write("Copied filesystem to temp directory\n");

View File

@@ -53,7 +53,7 @@ Compose Type: ${composeType} ✅`;
cd "${projectPath}";
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create ${compose.composeType === "stack" ? "--driver overlay" : ""} --attachable ${compose.appName}` : ""}
${compose.isolatedDeployment ? `docker network inspect ${compose.appName} >/dev/null 2>&1 || docker network create --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` : ""}
@@ -88,7 +88,7 @@ export const createCommand = (compose: ComposeNested) => {
let command = "";
if (composeType === "docker-compose") {
command = `compose -p ${appName} -f ${path} up -d --build --remove-orphans`;
command = `compose -p ${appName} -f ${path} up -d --build --pull always --remove-orphans`;
} else if (composeType === "stack") {
command = `stack deploy -c ${path} ${appName} --prune --with-registry-auth`;
}

View File

@@ -5,9 +5,9 @@ import { db } from "../../db/index";
import { user as userSchema } from "../../db/schema/user";
export const LICENSE_KEY_URL =
// process.env.NODE_ENV === "development"
// ? "http://localhost:4002"
"https://licenses-api.dokploy.com";
process.env.NODE_ENV === "development"
? "http://localhost:4002"
: "https://licenses-api.dokploy.com";
export const initEnterpriseBackupCronJobs = async () => {
scheduleJob("enterprise-check", "0 0 */3 * *", async () => {

View File

@@ -18,9 +18,7 @@ export const randomizeComposeFile = async (
) => {
const compose = await findComposeById(composeId);
const composeFile = compose.composeFile;
const composeData = parse(composeFile, {
maxAliasCount: 10000,
}) as ComposeSpecification;
const composeData = parse(composeFile) as ComposeSpecification;
const randomSuffix = suffix || generateRandomHash();

View File

@@ -63,9 +63,7 @@ export const loadDockerCompose = async (
if (existsSync(path)) {
const yamlStr = readFileSync(path, "utf8");
const parsedConfig = parse(yamlStr, {
maxAliasCount: 10000,
}) as ComposeSpecification;
const parsedConfig = parse(yamlStr) as ComposeSpecification;
return parsedConfig;
}
return null;
@@ -88,9 +86,7 @@ export const loadDockerComposeRemote = async (
return null;
}
if (!stdout) return null;
const parsedConfig = parse(stdout, {
maxAliasCount: 10000,
}) as ComposeSpecification;
const parsedConfig = parse(stdout) as ComposeSpecification;
return parsedConfig;
} catch {
return null;

View File

@@ -209,10 +209,7 @@ export const testGiteaConnection = async (input: { giteaId: string }) => {
});
}
const baseUrl = (provider.giteaInternalUrl || provider.giteaUrl).replace(
/\/+$/,
"",
);
const baseUrl = provider.giteaUrl.replace(/\/+$/, "");
// Use /user/repos to get authenticated user's repositories with pagination
let allRepos = 0;
@@ -269,9 +266,7 @@ export const getGiteaRepositories = async (giteaId?: string) => {
await refreshGiteaToken(giteaId);
const giteaProvider = await findGiteaById(giteaId);
const baseUrl = (
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
).replace(/\/+$/, "");
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
// Use /user/repos to get authenticated user's repositories with pagination
let allRepositories: any[] = [];
@@ -336,9 +331,7 @@ export const getGiteaBranches = async (input: {
const giteaProvider = await findGiteaById(input.giteaId);
const baseUrl = (
giteaProvider.giteaInternalUrl || giteaProvider.giteaUrl
).replace(/\/+$/, "");
const baseUrl = giteaProvider.giteaUrl.replace(/\/+$/, "");
// Handle pagination for branches
let allBranches: any[] = [];

View File

@@ -211,13 +211,10 @@ 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(
`${baseUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
`${gitlabProvider.gitlabUrl}/api/v4/projects/${input.id}/repository/branches?page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,
@@ -292,13 +289,10 @@ 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(
`${baseUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
`${gitlabProvider.gitlabUrl}/api/v4/projects?membership=true&page=${page}&per_page=${perPage}`,
{
headers: {
Authorization: `Bearer ${gitlabProvider.accessToken}`,

View File

@@ -69,7 +69,6 @@ export const restoreComposeBackup = async (
},
restoreType: composeType,
rcloneCommand,
backupFile: backupInput.backupFile,
});
emit("Starting restore...");

View File

@@ -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 --drop"`;
return `docker exec -i $CONTAINER_ID sh -c "mongorestore --username '${databaseUser}' --password '${databasePassword}' --authenticationDatabase admin --db ${database} --archive"`;
};
export const getComposeSearchCommand = (

View File

@@ -152,13 +152,16 @@ export const createRouterConfig = async (
}
if ((entryPoint === "websecure" && https) || !https) {
// 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);
// redirects
for (const redirect of redirects) {
let middlewareName = `redirect-${appName}-${redirect.uniqueConfigKey}`;
if (domain.domainType === "preview") {
middlewareName = `redirect-${appName.replace(
/^preview-(.+)-[^-]+$/,
"$1",
)}-${redirect.uniqueConfigKey}`;
}
routerConfig.middlewares?.push(middlewareName);
}
// security

View File

@@ -4,24 +4,6 @@ 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<ReturnType<typeof findVolumeBackupById>>,
): 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<ReturnType<typeof findVolumeBackupById>>,
) => {
@@ -30,9 +12,8 @@ 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 = `${s3AppName}/${normalizeS3Path(prefix || "")}${backupFileName}`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
const rcloneFlags = getS3Credentials(volumeBackup.destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const volumeBackupPath = path.join(VOLUME_BACKUPS_PATH, volumeBackup.appName);

View File

@@ -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, getVolumeServiceAppName } from "./backup";
import { backupVolume } 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 s3AppName = getVolumeServiceAppName(volumeBackup);
const backupFilesPath = `:s3:${destination.bucket}/${s3AppName}/${normalizeS3Path(prefix || "")}`;
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" ${backupFilesPath}`;
const normalizedPrefix = normalizeS3Path(prefix);
const backupFilesPath = `:s3:${destination.bucket}/${normalizedPrefix}`;
const listCommand = `rclone lsf ${rcloneFlags.join(" ")} --include \"${volumeName}-*.tar\" :s3:${destination.bucket}/${normalizedPrefix}`;
const sortAndPick = `sort -r | tail -n +$((${keepLatestCount}+1)) | xargs -I{}`;
const deleteCommand = `rclone delete ${rcloneFlags.join(" ")} ${backupFilesPath}{}`;
const fullCommand = `${listCommand} | ${sortAndPick} ${deleteCommand}`;
@@ -131,21 +131,14 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
? "mongodb"
: volumeBackup.serviceType;
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,
);
}
await sendVolumeBackupNotifications({
projectName,
applicationName: volumeBackup.name,
volumeName: volumeBackup.volumeName,
serviceType: mappedServiceType,
type: "success",
organizationId,
});
} catch (error) {
const { VOLUME_BACKUPS_PATH } = paths(!!serverId);
const volumeBackupPath = path.join(
@@ -167,21 +160,14 @@ export const runVolumeBackup = async (volumeBackupId: string) => {
? "mongodb"
: volumeBackup.serviceType;
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,
);
}
await sendVolumeBackupNotifications({
projectName,
applicationName: volumeBackup.name,
volumeName: volumeBackup.volumeName,
serviceType: mappedServiceType,
type: "error",
organizationId,
errorMessage: error instanceof Error ? error.message : String(error),
});
}
};

137
pnpm-lock.yaml generated
View File

@@ -115,9 +115,6 @@ importers:
'@codemirror/autocomplete':
specifier: ^6.18.6
version: 6.18.6
'@codemirror/lang-css':
specifier: ^6.2.1
version: 6.3.1
'@codemirror/lang-json':
specifier: ^6.0.1
version: 6.0.1
@@ -130,12 +127,9 @@ 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.39.15
version: 6.39.15
specifier: 6.29.0
version: 6.29.0
'@dokploy/server':
specifier: workspace:*
version: link:../../packages/server
@@ -243,10 +237,10 @@ importers:
version: 10.45.2
'@uiw/codemirror-theme-github':
specifier: ^4.23.12
version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
version: 4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)
'@uiw/react-codemirror':
specifier: ^4.23.12
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)
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)
'@xterm/addon-attach':
specifier: 0.10.0
version: 0.10.0(@xterm/xterm@5.5.0)
@@ -1176,9 +1170,6 @@ packages:
'@codemirror/commands@6.8.1':
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
'@codemirror/lang-css@6.3.1':
resolution: {integrity: sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==}
'@codemirror/lang-json@6.0.1':
resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==}
@@ -1203,8 +1194,11 @@ packages:
'@codemirror/theme-one-dark@6.1.2':
resolution: {integrity: sha512-F+sH0X16j/qFLMAfbciKTxVOwkdAS336b7AXTKOZhy8BR3eH/RelsnLgLFINrpST63mmN2OuwUt0W2ndUgYwUA==}
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
'@codemirror/view@6.29.0':
resolution: {integrity: sha512-ED4ims4fkf7eOA+HYLVP8VVg3NMllt1FPm9PEJBfYFnidKlRITBaua38u68L1F60eNtw2YNcDN5jsIzhKZwWQA==}
'@codemirror/view@6.36.8':
resolution: {integrity: sha512-yoRo4f+FdnD01fFt4XpfpMCcCAo9QvZOtbrXExn4SqzH32YC6LgzqxfLZw/r6Ge65xyY03mK/UfUqrVw1gFiFg==}
'@dokploy/trpc-openapi@0.0.4':
resolution: {integrity: sha512-a7VKunKu9arq57bP9MPH7ikJuKfT5SILnNy70vMqf1stm5IrqMG3Y7CIFprFe0DZiw3bwjue0KpETIATBftN6w==}
@@ -2213,9 +2207,6 @@ packages:
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
'@lezer/css@1.3.0':
resolution: {integrity: sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==}
'@lezer/highlight@1.2.1':
resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==}
@@ -8858,25 +8849,17 @@ snapshots:
'@codemirror/autocomplete@6.18.6':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/common': 1.5.1
'@codemirror/language': 6.11.0
'@codemirror/state': 6.5.2
'@codemirror/view': 6.29.0
'@lezer/common': 1.2.3
'@codemirror/commands@6.8.1':
dependencies:
'@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:
'@codemirror/autocomplete': 6.18.6
'@codemirror/language': 6.11.0
'@codemirror/state': 6.5.2
'@codemirror/view': 6.29.0
'@lezer/common': 1.2.3
'@lezer/css': 1.3.0
'@codemirror/lang-json@6.0.1':
dependencies:
@@ -8895,12 +8878,12 @@ snapshots:
'@codemirror/language@6.11.0':
dependencies:
'@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/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/legacy-modes@6.4.0':
dependencies:
@@ -8924,12 +8907,18 @@ snapshots:
'@codemirror/theme-one-dark@6.1.2':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@lezer/highlight': 1.2.3
'@codemirror/language': 6.11.0
'@codemirror/state': 6.5.2
'@codemirror/view': 6.29.0
'@lezer/highlight': 1.2.1
'@codemirror/view@6.39.15':
'@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':
dependencies:
'@codemirror/state': 6.5.2
style-mod: 4.1.2
@@ -9553,12 +9542,6 @@ snapshots:
'@lezer/common@1.2.3': {}
'@lezer/css@1.3.0':
dependencies:
'@lezer/common': 1.2.3
'@lezer/highlight': 1.2.1
'@lezer/lr': 1.4.2
'@lezer/highlight@1.2.1':
dependencies:
'@lezer/common': 1.2.3
@@ -12257,39 +12240,39 @@ snapshots:
dependencies:
'@types/node': 20.17.51
'@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)':
'@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)':
dependencies:
'@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/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-theme-github@4.25.4(@codemirror/language@6.12.1)(@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)':
dependencies:
'@uiw/codemirror-themes': 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)
'@uiw/codemirror-themes': 4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)
transitivePeerDependencies:
- '@codemirror/language'
- '@codemirror/state'
- '@codemirror/view'
'@uiw/codemirror-themes@4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.4)(@codemirror/view@6.39.15)':
'@uiw/codemirror-themes@4.23.12(@codemirror/language@6.11.0)(@codemirror/state@6.5.2)(@codemirror/view@6.29.0)':
dependencies:
'@codemirror/language': 6.12.1
'@codemirror/state': 6.5.4
'@codemirror/view': 6.39.15
'@codemirror/language': 6.11.0
'@codemirror/state': 6.5.2
'@codemirror/view': 6.29.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)':
'@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)':
dependencies:
'@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
'@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
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
transitivePeerDependencies:
@@ -12865,13 +12848,13 @@ snapshots:
codemirror@6.0.1:
dependencies:
'@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/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
color-convert@2.0.1:
dependencies: