Merge branch 'canary' into feat/quick-service-switcher

This commit is contained in:
Mohammed Imran
2026-03-02 02:23:12 +05:30
committed by GitHub
45 changed files with 1478 additions and 277 deletions

View File

@@ -14,13 +14,18 @@ vi.mock("@dokploy/server/db", () => {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
} as any;
return chain;
};
return {
db: {
select: vi.fn(),
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
@@ -31,6 +36,9 @@ vi.mock("@dokploy/server/db", () => {
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};

View File

@@ -15,13 +15,18 @@ vi.mock("@dokploy/server/db", () => {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
from: vi.fn(() => chain),
innerJoin: vi.fn(() => chain),
then: (resolve: (v: any) => void) => {
resolve([]);
},
};
return chain;
};
return {
db: {
select: vi.fn(),
select: vi.fn(() => createChainableMock()),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
@@ -32,6 +37,9 @@ vi.mock("@dokploy/server/db", () => {
patch: {
findMany: vi.fn().mockResolvedValue([]),
},
member: {
findMany: vi.fn().mockResolvedValue([]),
},
},
},
};

View File

@@ -12,8 +12,11 @@ vi.mock("@dokploy/server/db", () => {
chain.where = () => chain;
chain.values = () => chain;
chain.returning = () => Promise.resolve([{}]);
// biome-ignore lint/suspicious/noThenProperty: this is for testing
chain.then = undefined;
chain.from = () => chain;
chain.innerJoin = () => chain;
chain.then = (resolve: (value: unknown) => void) => {
resolve([]);
};
const tableMock = {
findFirst: vi.fn(() => Promise.resolve(undefined)),
@@ -22,7 +25,6 @@ vi.mock("@dokploy/server/db", () => {
update: vi.fn(() => chain),
delete: vi.fn(() => chain),
};
const createQueryMock = () => tableMock;
return {
db: {

View File

@@ -194,13 +194,21 @@ export const ShowDeployment = ({
{" "}
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
<TerminalLine
key={`${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
<TerminalLine
key={`extra-${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">

View File

@@ -459,7 +459,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
key={`${path}-${index}`}
variant="secondary"
className="flex items-center gap-1"
>

View File

@@ -448,7 +448,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
key={`${path}-${index}`}
variant="secondary"
className="flex items-center gap-1"
>

View File

@@ -402,7 +402,7 @@ export const DockerLogsId: React.FC<Props> = ({
{filteredLogs.length > 0 ? (
filteredLogs.map((filteredLog: LogLine, index: number) => (
<TerminalLine
key={index}
key={`${filteredLog.rawTimestamp ?? ""}-${index}`}
log={filteredLog}
searchTerm={search}
noTimestamp={!showTimestamp}

View File

@@ -1,6 +1,6 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdateMariadb = ({ mariadbId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
api.mariadb.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
utils.mariadb.one.invalidate({
mariadbId: mariadbId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Mariadb");
@@ -87,7 +89,7 @@ export const UpdateMariadb = ({ mariadbId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"

View File

@@ -1,6 +1,6 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdateMysql = ({ mysqlId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
api.mysql.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
utils.mysql.one.invalidate({
mysqlId: mysqlId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating MySQL");
@@ -87,7 +89,7 @@ export const UpdateMysql = ({ mysqlId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"

View File

@@ -25,7 +25,6 @@ import {
import { api } from "@/utils/api";
export type Services = {
appName: string;
serverId?: string | null;
name: string;
type:

View File

@@ -2,7 +2,6 @@ import {
AlertTriangle,
ArrowUpDown,
BookIcon,
ExternalLinkIcon,
FolderInput,
Loader2,
MoreHorizontalIcon,
@@ -16,7 +15,6 @@ import { toast } from "sonner";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { FocusShortcutInput } from "@/components/shared/focus-shortcut-input";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import {
AlertDialog,
AlertDialogAction,
@@ -40,10 +38,8 @@ import {
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -278,14 +274,6 @@ export const ShowProjects = () => {
)
.reduce((acc, curr) => acc + curr, 0);
const haveServicesWithDomains = project?.environments
.map(
(env) =>
env.applications.length > 0 ||
env.compose.length > 0,
)
.some(Boolean);
// Find default environment from accessible environments, or fall back to first accessible environment
const accessibleEnvironment =
project?.environments.find((env) => env.isDefault) ||
@@ -311,122 +299,6 @@ export const ShowProjects = () => {
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
{haveServicesWithDomains ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="absolute -right-3 -top-3 size-9 translate-y-1 rounded-full p-0 opacity-0 transition-all duration-200 group-hover:translate-y-0 group-hover:opacity-100"
size="sm"
variant="default"
>
<ExternalLinkIcon className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[200px] space-y-2 overflow-y-auto max-h-[400px]"
onClick={(e) => e.stopPropagation()}
>
{project.environments.some(
(env) => env.applications.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Applications
</DropdownMenuLabel>
{project.environments.map((env) =>
env.applications.map((app) => (
<div key={app.applicationId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{app.name}
<StatusTooltip
status={
app.applicationStatus
}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{app.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
{project.environments.some(
(env) => env.compose.length > 0,
) && (
<DropdownMenuGroup>
<DropdownMenuLabel>
Compose
</DropdownMenuLabel>
{project.environments.map((env) =>
env.compose.map((comp) => (
<div key={comp.composeId}>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="font-normal capitalize text-xs flex items-center justify-between">
{comp.name}
<StatusTooltip
status={comp.composeStatus}
/>
</DropdownMenuLabel>
<DropdownMenuSeparator />
{comp.domains.map((domain) => (
<DropdownMenuItem
key={domain.domainId}
asChild
>
<Link
className="space-x-4 text-xs cursor-pointer justify-between"
target="_blank"
href={`${
domain.https
? "https"
: "http"
}://${domain.host}${
domain.path
}`}
>
<span className="truncate">
{domain.host}
</span>
<ExternalLinkIcon className="size-4 shrink-0" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</div>
)),
)}
</DropdownMenuGroup>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">

View File

@@ -1,6 +1,6 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PenBoxIcon } from "lucide-react";
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
@@ -41,6 +41,7 @@ interface Props {
}
export const UpdateRedis = ({ redisId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, error, isError, isPending } =
api.redis.update.useMutation();
@@ -79,6 +80,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
utils.redis.one.invalidate({
redisId: redisId,
});
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating Redis");
@@ -87,7 +89,7 @@ export const UpdateRedis = ({ redisId }: Props) => {
};
return (
<Dialog>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"

View File

@@ -30,7 +30,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id ?? ""}&userId=${session?.user?.id ?? ""}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}-${randomString()}`,
url: origin,
hook_attributes: {
@@ -52,7 +52,7 @@ export const AddGithubProvider = () => {
);
setManifest(manifest);
}, [data?.id]);
}, [data?.id, activeOrganization?.id, session?.user?.id]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
@@ -131,7 +131,11 @@ export const AddGithubProvider = () => {
Unsure if you already have an app?
</a>
<Button
disabled={isOrganization && organizationName.length < 1}
disabled={
(isOrganization && organizationName.length < 1) ||
!activeOrganization?.id ||
!session?.user?.id
}
type="submit"
className="self-end"
>

View File

@@ -356,7 +356,7 @@ export const Configure2FA = () => {
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code
key={index}
key={`${code}-${index}`}
className="bg-background p-2 rounded text-sm font-mono text-center"
>
{code}

View File

@@ -401,7 +401,7 @@ export const Enable2FA = () => {
<div className="grid grid-cols-2 gap-2">
{backupCodes.map((code, index) => (
<code
key={index}
key={`${code}-${index}`}
className="bg-muted p-2 rounded text-sm font-mono"
>
{code}

View File

@@ -100,7 +100,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
const url = useUrl();
const { data: projects } = api.project.all.useQuery();
const { data: projects } = api.project.allForPermissions.useQuery();
const extractServicesFromProjects = () => {
if (!projects) return [];

View File

@@ -190,7 +190,7 @@ export const SetupServer = ({ serverId, asButton = false }: Props) => {
Automatic process
</span>
<Link
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2"
>

View File

@@ -172,7 +172,7 @@ export const CreateSSHKey = () => {
etc.)
</p>
<Link
href="https://docs.dokploy.com/docs/core/multi-server/instructions#requirements"
href="https://docs.dokploy.com/docs/core/remote-servers/instructions#requirements"
target="_blank"
className="text-primary flex flex-row gap-2 mt-2"
>

View File

@@ -307,9 +307,9 @@ export const WelcomeSuscription = () => {
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6">
{features.map((feature, index) => (
{features.map((feature) => (
<div
key={index}
key={feature.title}
className="flex flex-col items-start p-4 bg-card rounded-lg shadow-md hover:shadow-lg transition-shadow"
>
<div className="text-3xl mb-2">{feature.icon}</div>

View File

@@ -28,8 +28,12 @@ import {
import { Switch } from "@/components/ui/switch";
import { api, type RouterOutputs } from "@/utils/api";
type Project = RouterOutputs["project"]["all"][number];
type Environment = Project["environments"][number];
/** Shape returned by project.allForPermissions (admin only). Used for the permissions UI. */
type ProjectForPermissions =
RouterOutputs["project"]["allForPermissions"][number];
type EnvironmentForPermissions = ProjectForPermissions["environments"][number];
type Environment = EnvironmentForPermissions;
export type Services = {
appName: string;
@@ -173,7 +177,9 @@ interface Props {
export const AddUserPermissions = ({ userId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: projects } = api.project.all.useQuery();
const { data: projects } = api.project.allForPermissions.useQuery(undefined, {
enabled: isOpen,
});
const { data, refetch } = api.user.one.useQuery(
{

View File

@@ -11,20 +11,19 @@ interface Props {
export const DashboardLayout = ({ children }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: isUserSubscribed } = api.settings.isUserSubscribed.useQuery(
undefined,
{
enabled: isCloud === true,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
},
);
const { data: currentPlan } = api.stripe.getCurrentPlan.useQuery(undefined, {
enabled: isCloud === true,
refetchOnWindowFocus: false,
refetchOnMount: false,
refetchOnReconnect: false,
});
const isChatEnabled = isCloud === true && currentPlan === "startup";
return (
<>
<Page>{children}</Page>
{isCloud === true && isUserSubscribed === true && (
{isChatEnabled && (
<>
<HubSpotWidget />
</>

View File

@@ -32,7 +32,6 @@ interface Props {
}
export const BreadcrumbSidebar = ({ list }: Props) => {
console.log(list);
return (
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-[[data-collapsible=icon]]/sidebar-wrapper:h-12">
<div className="flex items-center justify-between w-full">

View File

@@ -60,7 +60,11 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
{" "}
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
<TerminalLine
key={`${log.rawTimestamp ?? ""}-${index}`}
log={log}
noTimestamp
/>
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.28.0",
"version": "v0.28.2",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -55,7 +55,7 @@
"@codemirror/legacy-modes": "6.4.0",
"@codemirror/view": "6.29.0",
"@dokploy/server": "workspace:*",
"@dokploy/trpc-openapi": "0.0.16",
"@dokploy/trpc-openapi": "0.0.17",
"@faker-js/faker": "^8.4.1",
"@hookform/resolvers": "^5.2.2",
"@octokit/auth-app": "^6.1.3",

View File

@@ -100,7 +100,6 @@ import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
export type Services = {
appName: string;
serverId?: string | null;
serverName?: string | null;
name: string;
@@ -146,7 +145,6 @@ export const extractServicesFromEnvironment = (
}
}
return {
appName: item.appName,
name: item.name,
type: "application",
id: item.applicationId,
@@ -161,7 +159,6 @@ export const extractServicesFromEnvironment = (
const mariadb: Services[] =
environment.mariadb?.map((item) => ({
appName: item.appName,
name: item.name,
type: "mariadb",
id: item.mariadbId,
@@ -174,7 +171,6 @@ export const extractServicesFromEnvironment = (
const postgres: Services[] =
environment.postgres?.map((item) => ({
appName: item.appName,
name: item.name,
type: "postgres",
id: item.postgresId,
@@ -187,7 +183,6 @@ export const extractServicesFromEnvironment = (
const mongo: Services[] =
environment.mongo?.map((item) => ({
appName: item.appName,
name: item.name,
type: "mongo",
id: item.mongoId,
@@ -200,7 +195,6 @@ export const extractServicesFromEnvironment = (
const redis: Services[] =
environment.redis?.map((item) => ({
appName: item.appName,
name: item.name,
type: "redis",
id: item.redisId,
@@ -213,7 +207,6 @@ export const extractServicesFromEnvironment = (
const mysql: Services[] =
environment.mysql?.map((item) => ({
appName: item.appName,
name: item.name,
type: "mysql",
id: item.mysqlId,
@@ -242,7 +235,6 @@ export const extractServicesFromEnvironment = (
}
}
return {
appName: item.appName,
name: item.name,
type: "compose",
id: item.composeId,

View File

@@ -1,18 +0,0 @@
# Proprietary Features
This directory contains all proprietary functionality of Dokploy.
## Purpose
This folder will house all **paid features** and premium functionality that are not part of the open source code.
## License
The code in this directory is under Dokploy's proprietary license. See [LICENSE_PROPRIETARY.md](../../../LICENSE_PROPRIETARY.md) for more details.
## Contact
If you want to learn more about our paid features or have any questions, please contact us at:
- Email: [sales@dokploy.com](mailto:sales@dokploy.com)
- Contact Form: [https://dokploy.com/contact](https://dokploy.com/contact)

View File

@@ -7,6 +7,7 @@ import {
findApplicationById,
findEnvironmentById,
findGitProviderById,
findMemberById,
findProjectById,
getApplicationStats,
IS_CLOUD,
@@ -32,7 +33,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { nanoid } from "nanoid";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
@@ -53,6 +54,8 @@ import {
apiSaveGitProvider,
apiUpdateApplication,
applications,
environments,
projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
@@ -1002,4 +1005,138 @@ export const applicationRouter = createTRPCRouter({
message: "Deployment cancellation only available in cloud version",
});
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
repository: z.string().optional(),
owner: z.string().optional(),
dockerImage: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(applications.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(applications.name, term),
ilike(applications.appName, term),
ilike(applications.description ?? "", term),
ilike(applications.repository ?? "", term),
ilike(applications.owner ?? "", term),
ilike(applications.dockerImage ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(applications.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(applications.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
applications.description ?? "",
`%${input.description.trim()}%`,
),
);
}
if (input.repository?.trim()) {
baseConditions.push(
ilike(applications.repository ?? "", `%${input.repository.trim()}%`),
);
}
if (input.owner?.trim()) {
baseConditions.push(
ilike(applications.owner ?? "", `%${input.owner.trim()}%`),
);
}
if (input.dockerImage?.trim()) {
baseConditions.push(
ilike(
applications.dockerImage ?? "",
`%${input.dockerImage.trim()}%`,
),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${applications.applicationId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
applicationId: applications.applicationId,
name: applications.name,
appName: applications.appName,
description: applications.description,
environmentId: applications.environmentId,
applicationStatus: applications.applicationStatus,
sourceType: applications.sourceType,
createdAt: applications.createdAt,
})
.from(applications)
.innerJoin(
environments,
eq(applications.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(applications.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(applications)
.innerJoin(
environments,
eq(applications.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return {
items,
total: countResult[0]?.count ?? 0,
};
}),
});

View File

@@ -16,6 +16,7 @@ import {
findDomainsByComposeId,
findEnvironmentById,
findGitProviderById,
findMemberById,
findProjectById,
findServerById,
getComposeContainer,
@@ -41,7 +42,7 @@ import {
} from "@dokploy/server/templates/github";
import { processTemplate } from "@dokploy/server/templates/processors";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import _ from "lodash";
import { nanoid } from "nanoid";
import { parse } from "toml";
@@ -58,6 +59,8 @@ import {
apiRedeployCompose,
apiUpdateCompose,
compose as composeTable,
environments,
projects,
} from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types";
@@ -1054,4 +1057,114 @@ export const composeRouter = createTRPCRouter({
message: "Deployment cancellation only available in cloud version",
});
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(composeTable.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(composeTable.name, term),
ilike(composeTable.appName, term),
ilike(composeTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(composeTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(composeTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
composeTable.description ?? "",
`%${input.description.trim()}%`,
),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${composeTable.composeId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
composeId: composeTable.composeId,
name: composeTable.name,
appName: composeTable.appName,
description: composeTable.description,
environmentId: composeTable.environmentId,
composeStatus: composeTable.composeStatus,
sourceType: composeTable.sourceType,
createdAt: composeTable.createdAt,
})
.from(composeTable)
.innerJoin(
environments,
eq(composeTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(composeTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(composeTable)
.innerJoin(
environments,
eq(composeTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return {
items,
total: countResult[0]?.count ?? 0,
};
}),
});

View File

@@ -11,7 +11,9 @@ import {
findMemberById,
updateEnvironmentById,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -21,6 +23,7 @@ import {
apiRemoveEnvironment,
apiUpdateEnvironment,
} from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
// Helper function to filter services within an environment based on user permissions
const filterEnvironmentServices = (
@@ -358,4 +361,92 @@ export const environmentRouter = createTRPCRouter({
});
}
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(environments.name, term),
ilike(environments.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(environments.name, `%${input.name.trim()}%`));
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
environments.description ?? "",
`%${input.description.trim()}%`,
),
);
}
if (ctx.user.role === "member") {
const { accessedEnvironments } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedEnvironments.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${environments.environmentId} IN (${sql.join(
accessedEnvironments.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
environmentId: environments.environmentId,
name: environments.name,
description: environments.description,
createdAt: environments.createdAt,
env: environments.env,
projectId: environments.projectId,
isDefault: environments.isDefault,
})
.from(environments)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(environments.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(environments)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return {
items,
total: countResult[0]?.count ?? 0,
};
}),
});

View File

@@ -8,6 +8,7 @@ import {
findBackupsByDbId,
findEnvironmentById,
findMariadbById,
findMemberById,
findProjectById,
IS_CLOUD,
rebuildDatabase,
@@ -22,7 +23,7 @@ import {
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -37,6 +38,7 @@ import {
apiUpdateMariaDB,
mariadb as mariadbTable,
} from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const mariadbRouter = createTRPCRouter({
create: protectedProcedure
@@ -446,4 +448,102 @@ export const mariadbRouter = createTRPCRouter({
await rebuildDatabase(mariadb.mariadbId, "mariadb");
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(mariadbTable.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(mariadbTable.name, term),
ilike(mariadbTable.appName, term),
ilike(mariadbTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(mariadbTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(mariadbTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
mariadbTable.description ?? "",
`%${input.description.trim()}%`,
),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${mariadbTable.mariadbId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
mariadbId: mariadbTable.mariadbId,
name: mariadbTable.name,
appName: mariadbTable.appName,
description: mariadbTable.description,
environmentId: mariadbTable.environmentId,
applicationStatus: mariadbTable.applicationStatus,
createdAt: mariadbTable.createdAt,
})
.from(mariadbTable)
.innerJoin(
environments,
eq(mariadbTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(mariadbTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(mariadbTable)
.innerJoin(
environments,
eq(mariadbTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -7,6 +7,7 @@ import {
deployMongo,
findBackupsByDbId,
findEnvironmentById,
findMemberById,
findMongoById,
findProjectById,
IS_CLOUD,
@@ -21,7 +22,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -36,6 +37,7 @@ import {
apiUpdateMongo,
mongo as mongoTable,
} from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const mongoRouter = createTRPCRouter({
create: protectedProcedure
@@ -476,4 +478,97 @@ export const mongoRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(eq(mongoTable.environmentId, input.environmentId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(mongoTable.name, term),
ilike(mongoTable.appName, term),
ilike(mongoTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(mongoTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(mongoTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(mongoTable.description ?? "", `%${input.description.trim()}%`),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${mongoTable.mongoId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
mongoId: mongoTable.mongoId,
name: mongoTable.name,
appName: mongoTable.appName,
description: mongoTable.description,
environmentId: mongoTable.environmentId,
applicationStatus: mongoTable.applicationStatus,
createdAt: mongoTable.createdAt,
})
.from(mongoTable)
.innerJoin(
environments,
eq(mongoTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(mongoTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(mongoTable)
.innerJoin(
environments,
eq(mongoTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -7,6 +7,7 @@ import {
deployMySql,
findBackupsByDbId,
findEnvironmentById,
findMemberById,
findMySqlById,
findProjectById,
IS_CLOUD,
@@ -21,7 +22,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -34,7 +35,9 @@ import {
apiSaveEnvironmentVariablesMySql,
apiSaveExternalPortMySql,
apiUpdateMySql,
environments,
mysql as mysqlTable,
projects,
} from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
@@ -471,4 +474,97 @@ export const mysqlRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(eq(mysqlTable.environmentId, input.environmentId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(mysqlTable.name, term),
ilike(mysqlTable.appName, term),
ilike(mysqlTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(mysqlTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(mysqlTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(mysqlTable.description ?? "", `%${input.description.trim()}%`),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${mysqlTable.mysqlId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
mysqlId: mysqlTable.mysqlId,
name: mysqlTable.name,
appName: mysqlTable.appName,
description: mysqlTable.description,
environmentId: mysqlTable.environmentId,
applicationStatus: mysqlTable.applicationStatus,
createdAt: mysqlTable.createdAt,
})
.from(mysqlTable)
.innerJoin(
environments,
eq(mysqlTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(mysqlTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(mysqlTable)
.innerJoin(
environments,
eq(mysqlTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -7,6 +7,7 @@ import {
deployPostgres,
findBackupsByDbId,
findEnvironmentById,
findMemberById,
findPostgresById,
findProjectById,
getMountPath,
@@ -22,7 +23,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -37,6 +38,7 @@ import {
apiUpdatePostgres,
postgres as postgresTable,
} from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
import { cancelJobs } from "@/server/utils/backup";
export const postgresRouter = createTRPCRouter({
@@ -483,4 +485,104 @@ export const postgresRouter = createTRPCRouter({
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(
eq(postgresTable.environmentId, input.environmentId),
);
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(postgresTable.name, term),
ilike(postgresTable.appName, term),
ilike(postgresTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(
ilike(postgresTable.name, `%${input.name.trim()}%`),
);
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(postgresTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(
postgresTable.description ?? "",
`%${input.description.trim()}%`,
),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${postgresTable.postgresId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
postgresId: postgresTable.postgresId,
name: postgresTable.name,
appName: postgresTable.appName,
description: postgresTable.description,
environmentId: postgresTable.environmentId,
applicationStatus: postgresTable.applicationStatus,
createdAt: postgresTable.createdAt,
})
.from(postgresTable)
.innerJoin(
environments,
eq(postgresTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(postgresTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(postgresTable)
.innerJoin(
environments,
eq(postgresTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -34,10 +34,14 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, sql } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
adminProcedure,
createTRPCRouter,
protectedProcedure,
} from "@/server/api/trpc";
import {
apiCreateProject,
apiFindOneProject,
@@ -219,31 +223,69 @@ export const projectRouter = createTRPCRouter({
applications.applicationId,
accessedServices,
),
with: { domains: true },
columns: {
applicationId: true,
name: true,
applicationStatus: true,
},
},
mariadb: {
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
columns: {
mariadbId: true,
name: true,
applicationStatus: true,
},
},
mongo: {
where: buildServiceFilter(mongo.mongoId, accessedServices),
columns: {
mongoId: true,
name: true,
applicationStatus: true,
},
},
mysql: {
where: buildServiceFilter(mysql.mysqlId, accessedServices),
columns: {
mysqlId: true,
name: true,
applicationStatus: true,
},
},
postgres: {
where: buildServiceFilter(
postgres.postgresId,
accessedServices,
),
columns: {
postgresId: true,
name: true,
applicationStatus: true,
},
},
redis: {
where: buildServiceFilter(redis.redisId, accessedServices),
columns: {
redisId: true,
name: true,
applicationStatus: true,
},
},
compose: {
where: buildServiceFilter(compose.composeId, accessedServices),
with: { domains: true },
columns: {
composeId: true,
name: true,
composeStatus: true,
},
},
},
columns: {
environmentId: true,
isDefault: true,
name: true,
},
},
},
orderBy: desc(projects.createdAt),
@@ -255,21 +297,50 @@ export const projectRouter = createTRPCRouter({
environments: {
with: {
applications: {
with: {
domains: true,
columns: {
applicationId: true,
name: true,
applicationStatus: true,
},
},
mariadb: {
columns: {
mariadbId: true,
},
},
mongo: {
columns: {
mongoId: true,
},
},
mysql: {
columns: {
mysqlId: true,
},
},
postgres: {
columns: {
postgresId: true,
},
},
redis: {
columns: {
redisId: true,
},
},
mariadb: true,
mongo: true,
mysql: true,
postgres: true,
redis: true,
compose: {
with: {
domains: true,
columns: {
composeId: true,
name: true,
composeStatus: true,
},
},
},
columns: {
name: true,
environmentId: true,
isDefault: true,
},
},
},
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
@@ -277,6 +348,183 @@ export const projectRouter = createTRPCRouter({
});
}),
/** All projects with full environments and services for the admin permissions UI. Admin only. */
allForPermissions: adminProcedure.query(async ({ ctx }) => {
return await db.query.projects.findMany({
where: eq(projects.organizationId, ctx.session.activeOrganizationId),
orderBy: desc(projects.createdAt),
columns: {
projectId: true,
name: true,
},
with: {
environments: {
columns: {
environmentId: true,
name: true,
isDefault: true,
},
with: {
applications: {
columns: {
applicationId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mariadb: {
columns: {
mariadbId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
postgres: {
columns: {
postgresId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mysql: {
columns: {
mysqlId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mongo: {
columns: {
mongoId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
redis: {
columns: {
redisId: true,
appName: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
compose: {
columns: {
composeId: true,
appName: true,
name: true,
createdAt: true,
composeStatus: true,
description: true,
serverId: true,
},
},
},
},
},
});
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
description: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(projects.name, term),
ilike(projects.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(projects.name, `%${input.name.trim()}%`));
}
if (input.description?.trim()) {
baseConditions.push(
ilike(projects.description ?? "", `%${input.description.trim()}%`),
);
}
if (ctx.user.role === "member") {
const { accessedProjects } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedProjects.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${projects.projectId} IN (${sql.join(
accessedProjects.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db.query.projects.findMany({
where,
limit: input.limit,
offset: input.offset,
orderBy: desc(projects.createdAt),
columns: {
projectId: true,
name: true,
description: true,
createdAt: true,
organizationId: true,
env: true,
},
}),
db
.select({ count: sql<number>`count(*)::int` })
.from(projects)
.where(where),
]);
return {
items,
total: countResult[0]?.count ?? 0,
};
}),
remove: protectedProcedure
.input(apiRemoveProject)
.mutation(async ({ input, ctx }) => {

View File

@@ -6,6 +6,7 @@ import {
createRedis,
deployRedis,
findEnvironmentById,
findMemberById,
findProjectById,
findRedisById,
IS_CLOUD,
@@ -20,7 +21,7 @@ import {
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { and, desc, eq, ilike, or, sql } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import {
@@ -35,6 +36,7 @@ import {
apiUpdateRedis,
redis as redisTable,
} from "@/server/db/schema";
import { environments, projects } from "@/server/db/schema";
export const redisRouter = createTRPCRouter({
create: protectedProcedure
.input(apiCreateRedis)
@@ -450,4 +452,97 @@ export const redisRouter = createTRPCRouter({
await rebuildDatabase(redis.redisId, "redis");
return true;
}),
search: protectedProcedure
.input(
z.object({
q: z.string().optional(),
name: z.string().optional(),
appName: z.string().optional(),
description: z.string().optional(),
projectId: z.string().optional(),
environmentId: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const baseConditions = [
eq(projects.organizationId, ctx.session.activeOrganizationId),
];
if (input.projectId) {
baseConditions.push(eq(environments.projectId, input.projectId));
}
if (input.environmentId) {
baseConditions.push(eq(redisTable.environmentId, input.environmentId));
}
if (input.q?.trim()) {
const term = `%${input.q.trim()}%`;
baseConditions.push(
or(
ilike(redisTable.name, term),
ilike(redisTable.appName, term),
ilike(redisTable.description ?? "", term),
)!,
);
}
if (input.name?.trim()) {
baseConditions.push(ilike(redisTable.name, `%${input.name.trim()}%`));
}
if (input.appName?.trim()) {
baseConditions.push(
ilike(redisTable.appName, `%${input.appName.trim()}%`),
);
}
if (input.description?.trim()) {
baseConditions.push(
ilike(redisTable.description ?? "", `%${input.description.trim()}%`),
);
}
if (ctx.user.role === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (accessedServices.length === 0) return { items: [], total: 0 };
baseConditions.push(
sql`${redisTable.redisId} IN (${sql.join(
accessedServices.map((id) => sql`${id}`),
sql`, `,
)})`,
);
}
const where = and(...baseConditions);
const [items, countResult] = await Promise.all([
db
.select({
redisId: redisTable.redisId,
name: redisTable.name,
appName: redisTable.appName,
description: redisTable.description,
environmentId: redisTable.environmentId,
applicationStatus: redisTable.applicationStatus,
createdAt: redisTable.createdAt,
})
.from(redisTable)
.innerJoin(
environments,
eq(redisTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where)
.orderBy(desc(redisTable.createdAt))
.limit(input.limit)
.offset(input.offset),
db
.select({ count: sql<number>`count(*)::int` })
.from(redisTable)
.innerJoin(
environments,
eq(redisTable.environmentId, environments.environmentId),
)
.innerJoin(projects, eq(environments.projectId, projects.projectId))
.where(where),
]);
return { items, total: countResult[0]?.count ?? 0 };
}),
});

View File

@@ -530,12 +530,12 @@ export const settingsRouter = createTRPCRouter({
getOpenApiDocument: protectedProcedure.query(
async ({ ctx }): Promise<unknown> => {
const protocol = ctx.req.headers["x-forwarded-proto"];
const url = `${protocol}://${ctx.req.headers.host}/api/trpc`;
const url = `${protocol}://${ctx.req.headers.host}/api`;
const openApiDocument = generateOpenApiDocument(appRouter, {
title: "tRPC OpenAPI",
version: packageInfo.version,
baseUrl: url,
docsUrl: `${url}/trpc/settings.getOpenApiDocument`,
docsUrl: `${url}/settings.getOpenApiDocument`,
tags: [
"admin",
"docker",

View File

@@ -21,9 +21,51 @@ import {
STARTUP_PRODUCT_ID,
WEBSITE_URL,
} from "@/server/utils/stripe";
import { adminProcedure, createTRPCRouter } from "../trpc";
import { adminProcedure, createTRPCRouter, protectedProcedure } from "../trpc";
export const stripeRouter = createTRPCRouter({
/** Returns the current billing plan for the user's organization. Used to gate features like chat (Startup only). */
getCurrentPlan: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) return null;
const owner = await findUserById(ctx.user.ownerId);
if (!owner?.stripeCustomerId) return null;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});
const subscriptions = await stripe.subscriptions.list({
customer: owner.stripeCustomerId,
status: "active",
expand: ["data.items.data.price"],
});
const activeSub = subscriptions.data[0];
if (!activeSub) return null;
const priceIds = activeSub.items.data.map(
(item) => (item.price as Stripe.Price).id,
);
if (
priceIds.some(
(id) =>
id === STARTUP_BASE_PRICE_MONTHLY_ID ||
id === STARTUP_BASE_PRICE_ANNUAL_ID,
)
) {
return "startup" as const;
}
if (
priceIds.some(
(id) => id === HOBBY_PRICE_MONTHLY_ID || id === HOBBY_PRICE_ANNUAL_ID,
)
) {
return "hobby" as const;
}
if (priceIds.some((id) => LEGACY_PRICE_IDS.includes(id))) {
return "legacy" as const;
}
return null;
}),
getProducts: adminProcedure.query(async ({ ctx }) => {
const user = await findUserById(ctx.user.ownerId);
const stripeCustomerId = user.stripeCustomerId;

View File

@@ -0,0 +1,38 @@
import { dbUrl } from "@dokploy/server/db/constants";
import * as schema from "@dokploy/server/db/schema";
import { and, eq } from "drizzle-orm";
import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
export { and, eq };
type Database = PostgresJsDatabase<typeof schema>;
/**
* Evita problemas de redeclaración global en monorepos.
* No usamos `declare global`.
*/
const globalForDb = globalThis as unknown as {
db?: Database;
};
let dbConnection: Database;
if (process.env.NODE_ENV === "production") {
// En producción no usamos global cache
dbConnection = drizzle(postgres(dbUrl), {
schema,
});
} else {
// En desarrollo reutilizamos conexión para evitar múltiples conexiones
if (!globalForDb.db) {
globalForDb.db = drizzle(postgres(dbUrl), {
schema,
});
}
dbConnection = globalForDb.db;
}
export const db: Database = dbConnection;
export { dbUrl };

View File

@@ -73,23 +73,25 @@ const { handler, api } = betterAuth({
disabled: process.env.NODE_ENV === "production",
},
async trustedOrigins() {
const trustedOrigins = await getTrustedOrigins();
if (IS_CLOUD) {
return trustedOrigins;
return getTrustedOrigins();
}
const settings = await getWebServerSettings();
if (!settings) {
return [];
}
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...(process.env.NODE_ENV === "development"
const [trustedOrigins, settings] = await Promise.all([
getTrustedOrigins(),
getWebServerSettings(),
]);
if (!settings) return [];
const devOrigins =
process.env.NODE_ENV === "development"
? [
"http://localhost:3000",
"https://absolutely-handy-falcon.ngrok-free.app",
]
: []),
: [];
return [
...(settings?.serverIp ? [`http://${settings?.serverIp}:3000`] : []),
...(settings?.host ? [`https://${settings?.host}`] : []),
...devOrigins,
...trustedOrigins,
];
},

View File

@@ -117,23 +117,33 @@ export const getDokployUrl = async () => {
return `http://${settings?.serverIp}:${process.env.PORT}`;
};
export const getTrustedOrigins = async () => {
const members = await db.query.member.findMany({
where: eq(member.role, "owner"),
with: {
user: true,
},
});
const TRUSTED_ORIGINS_CACHE_TTL_MS = 30 * 60_000;
let trustedOriginsCache: { data: string[]; expiresAt: number } | null = null;
if (members.length === 0) {
return [];
export const getTrustedOrigins = async () => {
const runQuery = async () => {
const rows = await db
.select({ trustedOrigins: user.trustedOrigins })
.from(member)
.innerJoin(user, eq(member.userId, user.id))
.where(eq(member.role, "owner"));
return Array.from(new Set(rows.flatMap((r) => r.trustedOrigins ?? [])));
};
if (IS_CLOUD) {
const now = Date.now();
if (trustedOriginsCache && now < trustedOriginsCache.expiresAt) {
return trustedOriginsCache.data;
}
const trustedOrigins = await runQuery();
trustedOriginsCache = {
data: trustedOrigins,
expiresAt: now + TRUSTED_ORIGINS_CACHE_TTL_MS,
};
return trustedOrigins;
}
const trustedOrigins = members.flatMap(
(member) => member.user.trustedOrigins || [],
);
return Array.from(new Set(trustedOrigins));
return runQuery();
};
export const getTrustedProviders = async () => {

View File

@@ -34,42 +34,139 @@ export const createEnvironment = async (
export const findEnvironmentById = async (environmentId: string) => {
const environment = await db.query.environments.findFirst({
where: eq(environments.environmentId, environmentId),
columns: {
name: true,
description: true,
environmentId: true,
isDefault: true,
projectId: true,
env: true,
},
with: {
applications: {
with: {
deployments: true,
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
name: true,
applicationId: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mariadb: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
mariadbId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mongo: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
mongoId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
mysql: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
mysqlId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
postgres: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
postgresId: true,
name: true,
description: true,
createdAt: true,
applicationStatus: true,
serverId: true,
},
},
redis: {
with: {
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
redisId: true,
name: true,
createdAt: true,
applicationStatus: true,
description: true,
serverId: true,
},
},
compose: {
with: {
deployments: true,
server: true,
server: {
columns: {
name: true,
serverId: true,
},
},
},
columns: {
composeId: true,
name: true,
createdAt: true,
composeStatus: true,
description: true,
serverId: true,
},
},
project: true,
@@ -98,6 +195,12 @@ export const findEnvironmentsByProjectId = async (projectId: string) => {
compose: true,
project: true,
},
columns: {
name: true,
description: true,
environmentId: true,
isDefault: true,
},
});
return projectEnvironments;
};
@@ -169,6 +272,7 @@ export const duplicateEnvironment = async (
name: input.name,
description: input.description || originalEnvironment.description,
projectId: originalEnvironment.projectId,
env: originalEnvironment.env,
})
.returning()
.then((value) => value[0]);

View File

@@ -281,17 +281,43 @@ const installRequirements = async (
.on("error", (err) => {
client.end();
if (err.level === "client-authentication") {
onData?.(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
);
const technicalDetail = `Error: ${err.message} ${err.level}`;
const friendlyMessage = [
"",
"❌ Couldn't connect to your server — the SSH key was not accepted.",
"",
"This usually means the key doesn't match what's on the server, or the key format is invalid.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
" • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
" • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab",
].join("\n");
onData?.(friendlyMessage);
reject(
new Error(
`Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`,
`Authentication failed: Invalid SSH private key. ${technicalDetail}`,
),
);
} else {
onData?.(`SSH connection error: ${err.message} ${err.level}`);
reject(new Error(`SSH connection error: ${err.message}`));
const technicalDetail = `${err.message} ${err.level ?? ""}`.trim();
const friendlyMessage = [
"",
"❌ Couldn't connect to your server.",
"",
"The connection failed before setup could run. Common causes: wrong IP or port, firewall blocking access, or the server is offline.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the server IP address and SSH port are correct and the server is powered on.",
" • If the server is in a private network, ensure Dokploy can reach it (VPN, firewall rules, or correct security groups).",
" • Make sure the SSH port (usually 22) is open and the SSH service is running on the server.",
].join("\n");
onData?.(friendlyMessage);
reject(new Error(`SSH connection error: ${technicalDetail}`));
}
})
.connect({

View File

@@ -106,10 +106,6 @@ export const writeDomainsToCompose = async (
compose: Compose,
domains: Domain[],
) => {
if (!domains.length) {
return "";
}
try {
const composeConverted = await addDomainToCompose(compose, domains);
const path = getComposePath(compose);
@@ -145,7 +141,7 @@ export const addDomainToCompose = async (
result = await loadDockerCompose(compose);
}
if (!result || domains.length === 0) {
if (!result) {
return null;
}

View File

@@ -201,14 +201,31 @@ export const execAsyncRemote = async (
.on("error", (err) => {
conn.end();
if (err.level === "client-authentication") {
const technicalDetail = `Error: ${err.message} ${err.level}`;
const friendlyMessage = [
"",
"❌ Couldn't connect to your server — the SSH key was not accepted.",
"",
"This usually means the key doesn't match what's on the server, or the key format is invalid.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
" • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
" • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab and then click on deployments tab and check the logs for more details.",
].join("\n");
const errorMsg = `Authentication failed: Invalid SSH private key. ❌ Error: ${err.message} ${err.level}`;
onData?.(errorMsg);
onData?.(friendlyMessage);
reject(
new ExecError(errorMsg, {
command,
serverId,
originalError: err,
}),
new ExecError(
`Authentication failed: Invalid SSH private key. ${friendlyMessage}`,
{
command,
serverId,
originalError: err,
},
),
);
} else {
const errorMsg = `SSH connection error: ${err.message}`;

10
pnpm-lock.yaml generated
View File

@@ -138,8 +138,8 @@ importers:
specifier: workspace:*
version: link:../../packages/server
'@dokploy/trpc-openapi':
specifier: 0.0.16
version: 0.0.16(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
specifier: 0.0.17
version: 0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)
'@faker-js/faker':
specifier: ^8.4.1
version: 8.4.1
@@ -1291,8 +1291,8 @@ packages:
'@codemirror/view@6.39.15':
resolution: {integrity: sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg==}
'@dokploy/trpc-openapi@0.0.16':
resolution: {integrity: sha512-95pukFwMkSKLfnUB21OnYVwmR832NVD6ewI65ZgpxYxTrmdVGCUzvphqs85fiq4UKV3qcuUSq3nn47d3Sh09zg==}
'@dokploy/trpc-openapi@0.0.17':
resolution: {integrity: sha512-pXWbqx2W0MoWav/wehEqcXzORLgn7PhnmLsZza1v6+lOSo0Vwuu47PrITbRYKQ2zZcR1nTL18TrgPuMzXK23Iw==}
peerDependencies:
'@trpc/server': ^11.1.0
zod: ^4.3.6
@@ -8867,7 +8867,7 @@ snapshots:
style-mod: 4.1.3
w3c-keyname: 2.2.8
'@dokploy/trpc-openapi@0.0.16(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
'@dokploy/trpc-openapi@0.0.17(@trpc/server@11.10.0(typescript@5.9.3))(zod-openapi@5.4.6(zod@4.3.6))(zod@4.3.6)':
dependencies:
'@trpc/server': 11.10.0(typescript@5.9.3)
co-body: 6.2.0