Compare commits

..

25 Commits

Author SHA1 Message Date
Mauricio Siu
73d9373c70 Add licenseKey column to user_temp table and update journal and snapshot metadata for version 0090 2025-05-02 03:04:51 -06:00
Mauricio Siu
bbc2cbc78c Merge branch '187-backups-for-docker-compose' into feat/introduce-monitoring-self-hosted-pay 2025-05-01 21:19:59 -06:00
Mauricio Siu
e2e6513ee1 Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-05-01 21:05:46 -06:00
Mauricio Siu
4de599745e Remove SQL script that added 'licenseKey' column to 'user_temp' table 2025-04-04 01:05:53 -06:00
Mauricio Siu
60e40f4ad0 Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-04-04 01:05:27 -06:00
Mauricio Siu
439d2fe116 feat(licenses): enhance license validation to check for existing server IPs
- Added a condition in the validateLicense function to return success if the server IP is already included in the license's server IPs, improving the validation logic.
2025-03-26 01:20:50 -06:00
Mauricio Siu
09d0435971 Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-24 00:15:35 -06:00
Mauricio Siu
5810c94f4b feat(dashboard): enhance monitoring features with alert and toggle functionality
- Added AlertBlock component to display monitoring plan information.
- Introduced Switch component for toggling monitoring settings in both dashboard and service views.
- Updated state management for monitoring toggle to improve user experience.
- Cleaned up commented code for better readability and maintainability.
2025-03-23 20:07:29 -06:00
Mauricio Siu
1acd330462 feat(licenses): implement license deactivation and validation features 2025-03-23 19:40:54 -06:00
Mauricio Siu
4074942dbf feat(licenses): refactor license handling and introduce activation functionality
- Replaced validateLicense with saveLicense in the EnablePaidFeatures component for improved clarity.
- Updated user API to include saveLicense and activateLicense methods for better license management.
- Enhanced validateLicense function to fetch from a new endpoint and added activateLicense for server activation.
- Removed unnecessary console logging in license validation for cleaner code.
2025-03-23 19:04:24 -06:00
Mauricio Siu
5fd8fcfa1e feat(licenses): update Stripe billing portal configuration and clean up logging
- Added configuration ID for the Stripe billing portal session creation to enhance functionality.
- Removed console logging of the customer ID for improved security and cleaner code.
- Commented out the configuration creation code for future reference and potential use.
2025-03-23 18:55:43 -06:00
Mauricio Siu
39e6a98179 feat(licenses): add license management and user authentication features
- Introduced a new SQL schema for managing licenses and users, including foreign key relationships.
- Implemented API routes for license validation, activation, and OTP-based user authentication.
- Updated the license creation process to associate licenses with users and handle server IPs.
- Added support for the nanoid package to generate unique license keys.
- Refactored existing code to improve modularity and maintainability, including the separation of license and stripe-related logic into dedicated API routes.
- Enhanced error handling and logging for better debugging and user feedback.
2025-03-23 18:32:50 -06:00
Mauricio Siu
5180c785b4 feat(licenses): enhance Stripe webhook handling for license creation
- Added support for the "invoice.paid" event to create licenses upon successful payment.
- Refactored the handling of subscription updates to improve license deactivation logic.
- Removed the expiration date from the LicenseEmail component to simplify email content.
- Improved error handling for customer retrieval and subscription status checks.
2025-03-23 12:58:40 -06:00
Mauricio Siu
c8e6df4c29 feat(licenses): enhance license validation and loading state management
- Added loading state management in the EnablePaidFeatures component to improve user experience during license validation.
- Updated validateLicense function to return detailed error messages for better feedback.
- Modified user API to return validation results instead of a boolean, enhancing error handling.
- Removed unused SQL files and updated package.json scripts for better development workflow.
2025-03-23 12:29:55 -06:00
Mauricio Siu
9e30525569 feat(licenses): improve development setup and refactor email handling
- Updated development script in package.json to load environment variables from dotenv.
- Refactored email imports to use named exports for better clarity.
- Removed unused database expiration logic and adjusted license validation to rely on Stripe subscription status.
- Enhanced error logging in webhook processing and improved console output for server status.
- Updated SMTP configuration to use more descriptive environment variable names.
- Removed the expiresAt field from the database schema to streamline license management.
2025-03-23 12:15:25 -06:00
Mauricio Siu
b7874f053f feat(licenses): enhance license validation process
- Updated the validateLicense function to accept server IP for improved validation.
- Modified user API to utilize the new validateLicense signature.
- Added useEffect in EnablePaidFeatures component to set license key based on user data.
- Improved error handling and user feedback during license validation.
2025-03-23 04:55:16 -06:00
Mauricio Siu
fb0272a64d Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-23 04:42:56 -06:00
Mauricio Siu
2f4a3c964c fix(licenses): update README with new development server URL
- Changed the local development server URL from http://localhost:3000 to http://localhost:4002/api/health to reflect recent updates in the API integration.
2025-03-20 01:32:30 -06:00
Mauricio Siu
feb6970b09 feat(licenses): enhance API and database integration for checkout sessions
- Updated development server port in package.json to 4002.
- Introduced constants for website URLs based on environment.
- Refactored database connection logic to use drizzle with PostgreSQL.
- Added new API endpoint for creating checkout sessions with Stripe integration.
- Implemented utility function to generate Stripe items based on license type and quantity.
- Updated existing API routes to use a router for better organization.
2025-03-20 01:32:13 -06:00
Mauricio Siu
78682fa359 feat(licenses): update database schema and enhance license management
- Refactored migration script to import schema from the correct path.
- Updated package.json scripts for improved execution of migration and truncation tasks.
- Added new SQL file to define license-related types and table structure.
- Enhanced license management with new fields for Stripe customer and subscription IDs.
- Implemented license deactivation logic and improved error handling in license validation.
- Introduced health check endpoint for database connectivity verification.
2025-03-19 01:31:38 -06:00
Mauricio Siu
473d729416 feat(licenses): add migration and truncation scripts for database management
- Introduced migration script to handle database schema updates using Drizzle ORM.
- Added truncation script to clear the database schema and reset the state.
- Updated package.json scripts for easier execution of migration and truncation tasks.
- Refactored email and Stripe integration for better modularity and maintainability.
2025-03-19 00:57:14 -06:00
Mauricio Siu
523720606d Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-19 00:53:57 -06:00
Mauricio Siu
4b6db35f16 feat(licenses): implement license management system with email notifications
- Added database schema for licenses, including types and statuses.
- Implemented license creation, validation, and activation functionalities.
- Integrated email templates for sending license keys and resend requests.
- Updated package dependencies and configuration for PostgreSQL integration.
- Introduced migration and truncation scripts for database management.
- Enhanced API endpoints for license operations with error handling and validation.
2025-03-19 00:36:35 -06:00
Mauricio Siu
9d047164ee Merge branch 'canary' into feat/introduce-monitoring-self-hosted-pay 2025-03-18 21:51:09 -06:00
Mauricio Siu
a61436b8f0 feat(licenses): implement license management and validation features
- Added a new licenses application to handle license key management.
- Implemented API endpoints for validating license keys and managing paid features.
- Introduced a new component for enabling paid features in the dashboard.
- Updated database schema to include a licenseKey field for users.
- Refactored server API to remove the admin router and integrate license validation into user operations.
- Enhanced the monitoring setup process to require valid license keys.
- Updated pnpm workspace configuration to include the new licenses app.
2025-03-17 15:50:04 -06:00
166 changed files with 8667 additions and 10915 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -29,7 +29,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -56,7 +56,7 @@ RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.0.64
ARG RAILPACK_VERSION=0.0.37
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks

View File

@@ -19,8 +19,8 @@ See the License for the specific language governing permissions and limitations
The following additional terms apply to the multi-node support, Docker Compose file, Preview Deployments and Multi Server features of Dokploy. In the event of a conflict, these provisions shall take precedence over those in the Apache License:
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Schedules, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
- **Self-Hosted Version Free**: All features of Dokploy, including multi-node support, Docker Compose file support, Preview Deployments and Multi Server, will always be free to use in the self-hosted version.
- **Restriction on Resale**: The multi-node support, Docker Compose file support, Preview Deployments and Multi Server features cannot be sold or offered as a service by any party other than the copyright holder without prior written consent.
- **Modification Distribution**: Any modifications to the multi-node support, Docker Compose file support, Preview Deployments and Multi Server features must be distributed freely and cannot be sold or offered as a service.
For further inquiries or permissions, please contact us directly.

View File

@@ -80,30 +80,12 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Premium Supporters 🥇
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
<img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
</a>
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;">
<img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/>
</a>
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://supafort.com/?ref=dokploy" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="190"/></a>
</div>
### Elite Contributors 🥈
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://americancloud.com/?ref=dokploy" target="_blank" style="display: inline-block; padding: 10px; border-radius: 10px;">
<img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" height="70"/>
</a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Supporting Members 🥉
@@ -115,7 +97,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div>

View File

@@ -16,8 +16,6 @@ import { beforeEach, expect, test, vi } from "vitest";
const baseAdmin: User = {
https: false,
enablePaidFeatures: false,
allowImpersonation: false,
role: "user",
metricsConfig: {
containers: {
refreshRate: 20,

View File

@@ -1,4 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -201,22 +200,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
</CardHeader>
<CardContent>
<Form {...form}>
<AlertBlock>
Builders can consume significant memory and CPU resources
(recommended: 4+ GB RAM and 2+ CPU cores). For production
environments, please review our{" "}
<a
href="https://docs.dokploy.com/docs/core/applications/going-production"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Production Guide
</a>{" "}
for best practices and optimization recommendations. Builders are
suitable for development and prototyping purposes when you have
sufficient resources available.
</AlertBlock>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 p-2"

View File

@@ -15,15 +15,11 @@ import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
interface Props {
id: string;
type: "application" | "compose";
applicationId: string;
}
export const CancelQueues = ({ id, type }: Props) => {
const { mutateAsync, isLoading } =
type === "application"
? api.application.cleanQueues.useMutation()
: api.compose.cleanQueues.useMutation();
export const CancelQueues = ({ applicationId }: Props) => {
const { mutateAsync, isLoading } = api.application.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
@@ -52,8 +48,7 @@ export const CancelQueues = ({ id, type }: Props) => {
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
applicationId,
})
.then(() => {
toast.success("Queues are being cleaned");

View File

@@ -14,14 +14,10 @@ import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
id: string;
type: "application" | "compose";
applicationId: string;
}
export const RefreshToken = ({ id, type }: Props) => {
const { mutateAsync } =
type === "application"
? api.application.refreshToken.useMutation()
: api.compose.refreshToken.useMutation();
export const RefreshToken = ({ applicationId }: Props) => {
const { mutateAsync } = api.application.refreshToken.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
@@ -41,19 +37,12 @@ export const RefreshToken = ({ id, type }: Props) => {
<AlertDialogAction
onClick={async () => {
await mutateAsync({
applicationId: id || "",
composeId: id || "",
applicationId,
})
.then(() => {
if (type === "application") {
utils.application.one.invalidate({
applicationId: id,
});
} else {
utils.compose.one.invalidate({
composeId: id,
});
}
utils.application.one.invalidate({
applicationId,
});
toast.success("Refresh updated");
})
.catch(() => {

View File

@@ -1,69 +0,0 @@
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
import { useState } from "react";
import { ShowDeployment } from "../deployments/show-deployment";
import { ShowDeployments } from "./show-deployments";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
serverId?: string;
refreshToken?: string;
children?: React.ReactNode;
}
export const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
export const ShowDeploymentsModal = ({
id,
type,
serverId,
refreshToken,
children,
}: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{children ? (
children
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Logs
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl p-0">
<ShowDeployments
id={id}
type={type}
serverId={serverId}
refreshToken={refreshToken}
/>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);
};

View File

@@ -9,53 +9,28 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon, Clock, Loader2 } from "lucide-react";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
import { Badge } from "@/components/ui/badge";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
refreshToken?: string;
serverId?: string;
applicationId: string;
}
export const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
export const ShowDeployments = ({
id,
type,
refreshToken,
serverId,
}: Props) => {
export const ShowDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data: deployments, isLoading: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
id,
type,
},
{
enabled: !!id,
refetchInterval: 1000,
},
);
const { data } = api.application.one.useQuery({ applicationId });
const { data: deployments } = api.deployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: 1000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
@@ -63,48 +38,34 @@ export const ShowDeployments = ({
}, []);
return (
<Card className="bg-background border-none">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this {type}
See all the 10 last deployments for this application
</CardDescription>
</div>
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
<CancelQueues applicationId={applicationId} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the
config of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}
</div>
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${data?.refreshToken}`}
</span>
<RefreshToken applicationId={applicationId} />
</div>
</div>
)}
{isLoadingDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading deployments...
</span>
</div>
) : deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
@@ -135,23 +96,8 @@ export const ShowDeployments = ({
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
<Button
@@ -167,7 +113,7 @@ export const ShowDeployments = ({
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={data?.serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -38,67 +38,26 @@ import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { domain } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import { Dices } from "lucide-react";
import Link from "next/link";
import z from "zod";
export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
path: z.string().min(1).optional(),
port: z
.number()
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["certificateType"],
message: "Required",
});
}
if (input.certificateType === "custom" && !input.customCertResolver) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customCertResolver"],
message: "Required",
});
}
if (input.domainType === "compose" && !input.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["serviceName"],
message: "Required",
});
}
});
import type z from "zod";
type Domain = z.infer<typeof domain>;
interface Props {
id: string;
type: "application" | "compose";
applicationId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
export const AddDomain = ({
applicationId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
@@ -109,24 +68,14 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
},
);
const { data: application } =
type === "application"
? api.application.one.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.compose.one.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
@@ -140,23 +89,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
serverId: application?.serverId || "",
});
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: type === "compose" && !!id,
},
);
const form = useForm<Domain>({
resolver: zodResolver(domain),
defaultValues: {
@@ -166,15 +98,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: undefined,
domainType: type,
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const https = form.watch("https");
const domainType = form.watch("domainType");
useEffect(() => {
if (data) {
@@ -185,8 +114,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
port: data?.port || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
});
}
@@ -198,7 +125,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
https: false,
certificateType: undefined,
customCertResolver: undefined,
domainType: type,
});
}
}, [form, data, isLoading, domainId]);
@@ -222,37 +148,22 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
...(data.domainType === "application" && {
applicationId: id,
}),
...(data.domainType === "compose" && {
composeId: id,
}),
applicationId,
...data,
})
.then(async () => {
toast.success(dictionary.success);
if (data.domainType === "application") {
await utils.domain.byApplicationId.invalidate({
applicationId: id,
});
await utils.application.readTraefikConfig.invalidate({
applicationId: id,
});
} else if (data.domainType === "compose") {
await utils.domain.byComposeId.invalidate({
composeId: id,
});
}
await utils.domain.byApplicationId.invalidate({
applicationId,
});
await utils.application.readTraefikConfig.invalidate({ applicationId });
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch((e) => {
console.log(e);
.catch(() => {
toast.error(dictionary.error);
});
};
@@ -276,119 +187,6 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex flex-row items-end w-full gap-4">
{domainType === "compose" && (
<div className="flex flex-col gap-2 w-full">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load
the services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from
the last deployment/fetch from the
repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div>
<FormField
control={form.control}
name="host"

View File

@@ -16,13 +16,12 @@ import {
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./handle-domain";
import { AddDomain } from "./add-domain";
import { useState } from "react";
import {
Tooltip,
@@ -30,70 +29,36 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DnsHelperModal } from "./dns-helper-modal";
import type { ValidationStates } from "../../compose/domains/show-domains";
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
import { Badge } from "@/components/ui/badge";
export type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
message?: string;
};
export type ValidationStates = Record<string, ValidationState>;
interface Props {
id: string;
type: "application" | "compose";
applicationId: string;
}
export const ShowDomains = ({ id, type }: Props) => {
const { data: application } =
type === "application"
? api.application.one.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.compose.one.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
export const ShowDomains = ({ applicationId }: Props) => {
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const {
data,
refetch,
isLoading: isLoadingDomains,
} = type === "application"
? api.domain.byApplicationId.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.domain.byComposeId.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
const { data, refetch } = api.domain.byApplicationId.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
@@ -148,7 +113,7 @@ export const ShowDomains = ({ id, type }: Props) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<AddDomain applicationId={applicationId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -157,22 +122,15 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-base text-muted-foreground">
Loading domains...
</span>
</div>
) : data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<AddDomain applicationId={applicationId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -180,26 +138,19 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent h-fit"
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent"
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
@@ -224,8 +175,7 @@ export const ShowDomains = ({ id, type }: Props) => {
/>
)}
<AddDomain
id={id}
type={type}
applicationId={applicationId}
domainId={item.domainId}
>
<Button

View File

@@ -381,9 +381,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches && branches.length === 0 && (
<CommandItem>No branches found.</CommandItem>
)}
{branches?.map((branch: GiteaBranch) => (
<CommandItem
value={branch.name}

View File

@@ -13,7 +13,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
import { GitBranch, UploadCloud } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
@@ -34,49 +34,14 @@ interface Props {
}
export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
if (isLoading) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -158,7 +123,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -178,7 +143,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -198,7 +163,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -218,7 +183,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

@@ -0,0 +1,100 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { RouterOutputs } from "@/utils/api";
import { useState } from "react";
import { ShowDeployment } from "../deployments/show-deployment";
interface Props {
deployments: RouterOutputs["deployment"]["all"];
serverId?: string;
trigger?: React.ReactNode;
}
export const ShowPreviewBuilds = ({
deployments,
serverId,
trigger,
}: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{trigger ? (
trigger
) : (
<Button className="sm:w-auto w-full" size="sm" variant="outline">
View Builds
</Button>
)}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-5xl">
<DialogHeader>
<DialogTitle>Preview Builds</DialogTitle>
<DialogDescription>
See all the preview builds for this application on this Pull Request
</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4 gap-2"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="break-all text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
</div>
</div>
))}
</div>
</DialogContent>
<ShowDeployment
serverId={serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</Dialog>
);
};

View File

@@ -17,7 +17,7 @@ import {
ExternalLink,
FileText,
GitPullRequest,
Loader2,
Layers,
PenSquare,
RocketIcon,
Trash2,
@@ -25,8 +25,8 @@ import {
import { toast } from "sonner";
import { ShowModalLogs } from "../../settings/web-server/show-modal-logs";
import { AddPreviewDomain } from "./add-preview-domain";
import { ShowPreviewBuilds } from "./show-preview-builds";
import { ShowPreviewSettings } from "./show-preview-settings";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
interface Props {
applicationId: string;
@@ -38,16 +38,13 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
const { mutateAsync: deletePreviewDeployment, isLoading } =
api.previewDeployment.delete.useMutation();
const {
data: previewDeployments,
refetch: refetchPreviewDeployments,
isLoading: isLoadingPreviewDeployments,
} = api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const { data: previewDeployments, refetch: refetchPreviewDeployments } =
api.previewDeployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
},
);
const handleDeletePreviewDeployment = async (previewDeploymentId: string) => {
deletePreviewDeployment({
@@ -83,15 +80,8 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
each pull request you create.
</span>
</div>
{isLoadingPreviewDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 min-h-[35vh]">
<Loader2 className="size-5 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading preview deployments...
</span>
</div>
) : !previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[35vh]">
{!previewDeployments?.length ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No preview deployments found
@@ -178,10 +168,19 @@ export const ShowPreviewDeployments = ({ applicationId }: Props) => {
</Button>
</ShowModalLogs>
<ShowDeploymentsModal
id={deployment.previewDeploymentId}
type="previewDeployment"
<ShowPreviewBuilds
deployments={deployment.deployments || []}
serverId={data?.serverId || ""}
trigger={
<Button
variant="outline"
size="sm"
className="gap-2"
>
<Layers className="size-4" />
Builds
</Button>
}
/>
<AddPreviewDomain

View File

@@ -1,538 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import {
Info,
PlusCircle,
PenBoxIcon,
RefreshCw,
DatabaseZap,
} from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { cn } from "@/lib/utils";
import type { CacheType } from "../domains/handle-domain";
export const commonCronExpressions = [
{ label: "Every minute", value: "* * * * *" },
{ label: "Every hour", value: "0 * * * *" },
{ label: "Every day at midnight", value: "0 0 * * *" },
{ label: "Every Sunday at midnight", value: "0 0 * * 0" },
{ label: "Every month on the 1st at midnight", value: "0 0 1 * *" },
{ label: "Every 15 minutes", value: "*/15 * * * *" },
{ label: "Every weekday at midnight", value: "0 0 * * 1-5" },
];
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
enabled: z.boolean().default(true),
serviceName: z.string(),
scheduleType: z.enum([
"application",
"compose",
"server",
"dokploy-server",
]),
script: z.string(),
})
.superRefine((data, ctx) => {
if (data.scheduleType === "compose" && !data.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required",
path: ["serviceName"],
});
}
if (
(data.scheduleType === "dokploy-server" ||
data.scheduleType === "server") &&
!data.script
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Script is required",
path: ["script"],
});
}
if (
(data.scheduleType === "application" ||
data.scheduleType === "compose") &&
!data.command
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Command is required",
path: ["command"],
});
}
});
interface Props {
id?: string;
scheduleId?: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
cronExpression: "",
shellType: "bash",
command: "",
enabled: true,
serviceName: "",
scheduleType: scheduleType || "application",
script: "",
},
});
const scheduleTypeForm = form.watch("scheduleType");
const { data: schedule } = api.schedule.one.useQuery(
{ scheduleId: scheduleId || "" },
{ enabled: !!scheduleId },
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id || "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: !!id && scheduleType === "compose",
},
);
useEffect(() => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
enabled: schedule.enabled,
serviceName: schedule.serviceName || "",
scheduleType: schedule.scheduleType,
script: schedule.script || "",
});
}
}, [form, schedule, scheduleId]);
const { mutateAsync, isLoading } = scheduleId
? api.schedule.update.useMutation()
: api.schedule.create.useMutation();
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!id && !scheduleId) return;
await mutateAsync({
...values,
scheduleId: scheduleId || "",
...(scheduleType === "application" && {
applicationId: id || "",
}),
...(scheduleType === "compose" && {
composeId: id || "",
}),
...(scheduleType === "server" && {
serverId: id || "",
}),
...(scheduleType === "dokploy-server" && {
userId: id || "",
}),
})
.then(() => {
toast.success(
`Schedule ${scheduleId ? "updated" : "created"} successfully`,
);
utils.schedule.list.invalidate({
id,
scheduleType,
});
setIsOpen(false);
})
.catch((error) => {
toast.error(
error instanceof Error ? error.message : "An unknown error occurred",
);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{scheduleId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusCircle className="w-4 h-4 mr-2" />
Add Schedule
</Button>
)}
</DialogTrigger>
<DialogContent
className={cn(
"max-h-screen overflow-y-auto",
scheduleTypeForm === "dokploy-server" || scheduleTypeForm === "server"
? "max-h-[95vh] sm:max-w-2xl"
: " sm:max-w-lg",
)}
>
<DialogHeader>
<DialogTitle>{scheduleId ? "Edit" : "Create"} Schedule</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{scheduleTypeForm === "compose" && (
<div className="flex flex-col w-full gap-4">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this compose,
it will read the services from the last
deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Task Name
</FormLabel>
<FormControl>
<Input placeholder="Daily Database Backup" {...field} />
</FormControl>
<FormDescription>
A descriptive name for your scheduled task
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="cronExpression"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{(scheduleTypeForm === "application" ||
scheduleTypeForm === "compose") && (
<>
<FormField
control={form.control}
name="shellType"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Shell Type
</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select shell type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="bash">Bash</SelectItem>
<SelectItem value="sh">Sh</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Choose the shell to execute your command
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="command"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
Command
</FormLabel>
<FormControl>
<Input placeholder="npm run backup" {...field} />
</FormControl>
<FormDescription>
The command to execute in your container
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{(scheduleTypeForm === "dokploy-server" ||
scheduleTypeForm === "server") && (
<FormField
control={form.control}
name="script"
render={({ field }) => (
<FormItem>
<FormLabel>Script</FormLabel>
<FormControl>
<FormControl>
<CodeEditor
language="shell"
placeholder={`# This is a comment
echo "Hello, world!"
`}
className="h-96 font-mono"
{...field}
/>
</FormControl>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem>
<FormLabel className="flex items-center gap-2">
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
Enabled
</FormLabel>
</FormItem>
)}
/>
<Button type="submit" isLoading={isLoading} className="w-full">
{scheduleId ? "Update" : "Create"} Schedule
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,239 +0,0 @@
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { HandleSchedules } from "./handle-schedules";
import {
Clock,
Play,
Terminal,
Trash2,
ClipboardList,
Loader2,
} from "lucide-react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
import { ShowDeploymentsModal } from "../deployments/show-deployments-modal";
interface Props {
id: string;
scheduleType?: "application" | "compose" | "server" | "dokploy-server";
}
export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
const {
data: schedules,
isLoading: isLoadingSchedules,
refetch: refetchSchedules,
} = api.schedule.list.useQuery(
{
id: id || "",
scheduleType,
},
{
enabled: !!id,
},
);
const utils = api.useUtils();
const { mutateAsync: deleteSchedule, isLoading: isDeleting } =
api.schedule.delete.useMutation();
const { mutateAsync: runManually, isLoading } =
api.schedule.runManually.useMutation();
return (
<Card className="border px-6 shadow-none bg-transparent h-full min-h-[50vh]">
<CardHeader className="px-0">
<div className="flex justify-between items-center">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl font-bold flex items-center gap-2">
Scheduled Tasks
</CardTitle>
<CardDescription>
Schedule tasks to run automatically at specified intervals.
</CardDescription>
</div>
{schedules && schedules.length > 0 && (
<HandleSchedules id={id} scheduleType={scheduleType} />
)}
</div>
</CardHeader>
<CardContent className="px-0">
{isLoadingSchedules ? (
<div className="flex gap-4 w-full items-center justify-center text-center mx-auto min-h-[45vh]">
<Loader2 className="size-4 text-muted-foreground/70 transition-colors animate-spin self-center" />
<span className="text-sm text-muted-foreground/70">
Loading scheduled tasks...
</span>
</div>
) : schedules && schedules.length > 0 ? (
<div className="grid xl:grid-cols-2 gap-4 grid-cols-1 h-full">
{schedules.map((schedule) => {
const serverId =
schedule.serverId ||
schedule.application?.serverId ||
schedule.compose?.serverId;
return (
<div
key={schedule.scheduleId}
className=" flex items-center justify-between rounded-lg border p-3 transition-colors bg-muted/50"
>
<div className="flex items-start gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary/5">
<Clock className="size-4 text-primary/70" />
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium leading-none">
{schedule.name}
</h3>
<Badge
variant={schedule.enabled ? "default" : "secondary"}
className="text-[10px] px-1 py-0"
>
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge
variant="outline"
className="font-mono text-[10px] bg-transparent"
>
Cron: {schedule.cronExpression}
</Badge>
{schedule.scheduleType !== "server" &&
schedule.scheduleType !== "dokploy-server" && (
<>
<span className="text-xs text-muted-foreground/50">
</span>
<Badge
variant="outline"
className="font-mono text-[10px] bg-transparent"
>
{schedule.shellType}
</Badge>
</>
)}
</div>
{schedule.command && (
<div className="flex items-center gap-2">
<Terminal className="size-3.5 text-muted-foreground/70" />
<code className="font-mono text-[10px] text-muted-foreground/70">
{schedule.command}
</code>
</div>
)}
</div>
</div>
<div className="flex items-center gap-1.5">
<ShowDeploymentsModal
id={schedule.scheduleId}
type="schedule"
serverId={serverId || undefined}
>
<Button variant="ghost" size="icon">
<ClipboardList className="size-4 transition-colors " />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
isLoading={isLoading}
onClick={async () => {
toast.success("Schedule run successfully");
await runManually({
scheduleId: schedule.scheduleId,
}).then(async () => {
await new Promise((resolve) =>
setTimeout(resolve, 1500),
);
refetchSchedules();
});
}}
>
<Play className="size-4 transition-colors" />
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Schedule</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleSchedules
scheduleId={schedule.scheduleId}
id={id}
scheduleType={scheduleType}
/>
<DialogAction
title="Delete Schedule"
description="Are you sure you want to delete this schedule?"
type="destructive"
onClick={async () => {
await deleteSchedule({
scheduleId: schedule.scheduleId,
})
.then(() => {
utils.schedule.list.invalidate({
id,
scheduleType,
});
toast.success("Schedule deleted successfully");
})
.catch(() => {
toast.error("Error deleting schedule");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 "
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
) : (
<div className="flex flex-col gap-2 items-center justify-center py-12 rounded-lg">
<Clock className="size-8 mb-4 text-muted-foreground" />
<p className="text-lg font-medium text-muted-foreground">
No scheduled tasks
</p>
<p className="text-sm text-muted-foreground mt-1">
Create your first scheduled task to automate your workflows
</p>
<HandleSchedules id={id} scheduleType={scheduleType} />
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,66 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { api } from "@/utils/api";
import { Paintbrush } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const CancelQueuesCompose = ({ composeId }: Props) => {
const { mutateAsync, isLoading } = api.compose.cleanQueues.useMutation();
const { data: isCloud } = api.settings.isCloud.useQuery();
if (isCloud) {
return null;
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit" isLoading={isLoading}>
Cancel Queues
<Paintbrush className="size-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Are you sure to cancel the incoming deployments?
</AlertDialogTitle>
<AlertDialogDescription>
This will cancel all the incoming deployments
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
toast.success("Queues are being cleaned");
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,59 @@
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { api } from "@/utils/api";
import { RefreshCcw } from "lucide-react";
import { toast } from "sonner";
interface Props {
composeId: string;
}
export const RefreshTokenCompose = ({ composeId }: Props) => {
const { mutateAsync } = api.compose.refreshToken.useMutation();
const utils = api.useUtils();
return (
<AlertDialog>
<AlertDialogTrigger>
<RefreshCcw className="h-4 w-4 cursor-pointer text-muted-foreground" />
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently change the token
and all the previous tokens will be invalidated
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={async () => {
await mutateAsync({
composeId,
})
.then(() => {
utils.compose.one.invalidate({
composeId,
});
toast.success("Refresh Token updated");
})
.catch(() => {
toast.error("Error updating the refresh token");
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -0,0 +1,184 @@
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { TerminalLine } from "../../docker/logs/terminal-line";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
interface Props {
logPath: string | null;
serverId?: string;
open: boolean;
onClose: () => void;
errorMessage?: string;
}
export const ShowDeploymentCompose = ({
logPath,
open,
onClose,
serverId,
errorMessage,
}: Props) => {
const [data, setData] = useState("");
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [showExtraLogs, setShowExtraLogs] = useState(false);
const wsRef = useRef<WebSocket | null>(null); // Ref to hold WebSocket instance
const [autoScroll, setAutoScroll] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
};
const handleScroll = () => {
if (!scrollRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
const isAtBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 10;
setAutoScroll(isAtBottom);
};
useEffect(() => {
if (!open || !logPath) return;
setData("");
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/listen-deployment?logPath=${logPath}&serverId=${serverId}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws; // Store WebSocket instance in ref
ws.onmessage = (e) => {
setData((currentData) => currentData + e.data);
};
ws.onerror = (error) => {
console.error("WebSocket error: ", error);
};
ws.onclose = () => {
wsRef.current = null;
};
return () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
ws.close();
wsRef.current = null;
}
};
}, [logPath, open]);
useEffect(() => {
const logs = parseLogs(data);
let filteredLogsResult = logs;
if (serverId) {
let hideSubsequentLogs = false;
filteredLogsResult = logs.filter((log) => {
if (
log.message.includes(
"===================================EXTRA LOGS============================================",
)
) {
hideSubsequentLogs = true;
return showExtraLogs;
}
return showExtraLogs ? true : !hideSubsequentLogs;
});
}
setFilteredLogs(filteredLogsResult);
}, [data, showExtraLogs]);
useEffect(() => {
scrollToBottom();
if (autoScroll && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [filteredLogs, autoScroll]);
const optionalErrors = parseLogs(errorMessage || "");
return (
<Dialog
open={open}
onOpenChange={(e) => {
onClose();
if (!e) {
setData("");
}
if (wsRef.current) {
if (wsRef.current.readyState === WebSocket.OPEN) {
wsRef.current.close();
}
}
}}
>
<DialogContent className={"sm:max-w-5xl max-h-screen"}>
<DialogHeader>
<DialogTitle>Deployment</DialogTitle>
<DialogDescription className="flex items-center gap-2">
<span>
See all the details of this deployment |{" "}
<Badge variant="blank" className="text-xs">
{filteredLogs.length} lines
</Badge>
</span>
{serverId && (
<div className="flex items-center space-x-2">
<Checkbox
id="show-extra-logs"
checked={showExtraLogs}
onCheckedChange={(checked) =>
setShowExtraLogs(checked as boolean)
}
/>
<label
htmlFor="show-extra-logs"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Show Extra Logs
</label>
</div>
)}
</DialogDescription>
</DialogHeader>
<div
ref={scrollRef}
onScroll={handleScroll}
className="h-[720px] overflow-y-auto space-y-0 border p-4 bg-[#fafafa] dark:bg-[#050506] rounded custom-logs-scrollbar"
>
{filteredLogs.length > 0 ? (
filteredLogs.map((log: LogLine, index: number) => (
<TerminalLine key={index} log={log} noTimestamp />
))
) : (
<>
{optionalErrors.length > 0 ? (
optionalErrors.map((log: LogLine, index: number) => (
<TerminalLine key={`extra-${index}`} log={log} noTimestamp />
))
) : (
<div className="flex justify-center items-center h-full text-muted-foreground">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
)}
</>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,125 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueuesCompose } from "./cancel-queues-compose";
import { RefreshTokenCompose } from "./refresh-token-compose";
import { ShowDeploymentCompose } from "./show-deployment-compose";
interface Props {
composeId: string;
}
export const ShowDeploymentsCompose = ({ composeId }: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data } = api.compose.one.useQuery({ composeId });
const { data: deployments } = api.deployment.allByCompose.useQuery(
{ composeId },
{
enabled: !!composeId,
refetchInterval: 5000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
setUrl(document.location.origin);
}, []);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this compose
</CardDescription>
</div>
<CancelQueuesCompose composeId={composeId} />
{/* <CancelQueues applicationId={applicationId} /> */}
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="text-muted-foreground">
{`${url}/api/deploy/compose/${data?.refreshToken}`}
</span>
<RefreshTokenCompose composeId={composeId} />
</div>
</div>
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
</span>
</div>
) : (
<div className="flex flex-col gap-4">
{deployments?.map((deployment) => (
<div
key={deployment.deploymentId}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex flex-col">
<span className="flex items-center gap-4 font-medium capitalize text-foreground">
{deployment.status}
<StatusTooltip
status={deployment?.status}
className="size-2.5"
/>
</span>
<span className="text-sm text-muted-foreground">
{deployment.title}
</span>
{deployment.description && (
<span className="text-sm text-muted-foreground">
{deployment.description}
</span>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeploymentCompose
serverId={data?.serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}
errorMessage={activeLog?.errorMessage || ""}
/>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,508 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input, NumberInput } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { domainCompose } from "@/server/db/validations/domain";
import { zodResolver } from "@hookform/resolvers/zod";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import Link from "next/link";
import type z from "zod";
type Domain = z.infer<typeof domainCompose>;
export type CacheType = "fetch" | "cache";
interface Props {
composeId: string;
domainId?: string;
children: React.ReactNode;
}
export const AddDomainCompose = ({
composeId,
domainId = "",
children,
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const utils = api.useUtils();
const { data, refetch } = api.domain.one.useQuery(
{
domainId,
},
{
enabled: !!domainId,
},
);
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
},
);
const { mutateAsync: generateDomain, isLoading: isLoadingGenerate } =
api.domain.generateDomain.useMutation();
const { mutateAsync, isError, error, isLoading } = domainId
? api.domain.update.useMutation()
: api.domain.create.useMutation();
const { data: canGenerateTraefikMeDomains } =
api.domain.canGenerateTraefikMeDomains.useQuery({
serverId: compose?.serverId || "",
});
const form = useForm<Domain>({
resolver: zodResolver(domainCompose),
defaultValues: {
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
},
});
const https = form.watch("https");
useEffect(() => {
if (data) {
form.reset({
...data,
/* Convert null to undefined */
path: data?.path || undefined,
port: data?.port || undefined,
serviceName: data?.serviceName || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
});
}
if (!domainId) {
form.reset({
host: "",
path: undefined,
port: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: "",
});
}
}, [form, form.reset, data, isLoading]);
const dictionary = {
success: domainId ? "Domain Updated" : "Domain Created",
error: domainId ? "Error updating the domain" : "Error creating the domain",
submit: domainId ? "Update" : "Create",
dialogDescription: domainId
? "In this section you can edit a domain"
: "In this section you can add domains",
};
const onSubmit = async (data: Domain) => {
await mutateAsync({
domainId,
composeId,
domainType: "compose",
...data,
})
.then(async () => {
await utils.domain.byComposeId.invalidate({
composeId,
});
toast.success(dictionary.success);
if (domainId) {
refetch();
}
setIsOpen(false);
})
.catch(() => {
toast.error(dictionary.error);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger className="" asChild>
{children}
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Domain</DialogTitle>
<DialogDescription>{dictionary.dialogDescription}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
Deploy is required to apply changes after creating or updating a
domain.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
</div>
<Form {...form}>
<form
id="hook-form"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-8 "
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{errorServices && (
<AlertBlock
type="warning"
className="[overflow-wrap:anywhere]"
>
{errorServices?.message}
</AlertBlock>
)}
<div className="flex flex-row items-end w-full gap-4">
<FormField
control={form.control}
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
<SelectItem value="none" disabled>
Empty
</SelectItem>
</SelectContent>
</Select>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "fetch") {
refetchServices();
} else {
setCacheType("fetch");
}
}}
>
<RefreshCw className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Fetch: Will clone the repository and load the
services
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingServices}
onClick={() => {
if (cacheType === "cache") {
refetchServices();
} else {
setCacheType("cache");
}
}}
>
<DatabaseZap className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>
Cache: If you previously deployed this
compose, it will read the services from the
last deployment/fetch from the repository
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="host"
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
href="/dashboard/settings/server"
className="text-primary"
>
{compose?.serverId
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
</AlertBlock>
)}
<FormLabel>Host</FormLabel>
<div className="flex gap-2">
<FormControl>
<Input placeholder="api.dokploy.com" {...field} />
</FormControl>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="secondary"
type="button"
isLoading={isLoadingGenerate}
onClick={() => {
generateDomain({
serverId: compose?.serverId || "",
appName: compose?.appName || "",
})
.then((domain) => {
field.onChange(domain);
})
.catch((err) => {
toast.error(err.message);
});
}}
>
<Dices className="size-4 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="path"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Path</FormLabel>
<FormControl>
<Input placeholder={"/"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="port"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Container Port</FormLabel>
<FormDescription>
The port where your application is running inside the
container (e.g., 3000 for Node.js, 80 for Nginx, 8080
for Java)
</FormDescription>
<FormControl>
<NumberInput placeholder={"3000"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="https"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>HTTPS</FormLabel>
<FormDescription>
Automatically provision SSL Certificate.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{https && (
<>
<FormField
control={form.control}
name="certificateType"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Certificate Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a certificate provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="none">None</SelectItem>
<SelectItem value={"letsencrypt"}>
Let's Encrypt
</SelectItem>
<SelectItem value={"custom"}>Custom</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{form.getValues().certificateType === "custom" && (
<FormField
control={form.control}
name="customCertResolver"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Custom Certificate Resolver</FormLabel>
<FormControl>
<Input
placeholder="Enter your custom certificate resolver"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</div>
</div>
</form>
<DialogFooter>
<Button
isLoading={form.formState.isSubmitting}
form="hook-form"
type="submit"
>
{dictionary.submit}
</Button>
</DialogFooter>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,383 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import {
ExternalLink,
GlobeIcon,
PenBoxIcon,
Trash2,
InfoIcon,
Server,
CheckCircle2,
XCircle,
Loader2,
RefreshCw,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomainCompose } from "./add-domain";
import { Badge } from "@/components/ui/badge";
import { DnsHelperModal } from "./dns-helper-modal";
import { useState } from "react";
interface Props {
composeId: string;
}
export type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
message?: string;
};
export type ValidationStates = {
[key: string]: ValidationState;
};
export const ShowDomainsCompose = ({ composeId }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const { data, refetch } = api.domain.byComposeId.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const { data: compose } = api.compose.one.useQuery(
{
composeId,
},
{
enabled: !!composeId,
},
);
const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
api.domain.delete.useMutation();
const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({
...prev,
[host]: { isLoading: true },
}));
try {
const result = await validateDomain({
domain: host,
serverIp:
compose?.server?.ipAddress?.toString() || ip?.toString() || "",
});
setValidationStates((prev) => ({
...prev,
[host]: {
isLoading: false,
isValid: result.isValid,
error: result.error,
resolvedIp: result.resolvedIp,
message: result.error && result.isValid ? result.error : undefined,
},
}));
} catch (err) {
const error = err as Error;
setValidationStates((prev) => ({
...prev,
[host]: {
isLoading: false,
isValid: false,
error: error.message || "Failed to validate domain",
},
}));
}
};
return (
<div className="flex w-full flex-col gap-5">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center flex-wrap gap-4 justify-between">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Domains</CardTitle>
<CardDescription>
Domains are used to access to the application
</CardDescription>
</div>
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent>
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 py-8">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
To access to the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<Card
key={item.domainId}
className="relative overflow-hidden border bg-card transition-all hover:shadow-md bg-transparent"
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
<Link
className="flex items-center gap-2 text-base font-medium hover:underline"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
{item.host}
<ExternalLink className="size-4" />
</Link>
</div>
<div className="flex gap-2">
{!item.host.includes("traefik.me") && (
<DnsHelperModal
domain={{
host: item.host,
https: item.https,
path: item.path || undefined,
}}
serverIp={
compose?.server?.ipAddress?.toString() ||
ip?.toString()
}
/>
)}
<AddDomainCompose
composeId={composeId}
domainId={item.domainId}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomainCompose>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then((_data) => {
refetch();
toast.success(
"Domain deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting domain");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
{/* Domain Details */}
<div className="flex flex-wrap gap-3">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Path: {item.path || "/"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>URL path for this service</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Port: {item.port}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Container port exposed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant={item.https ? "outline" : "secondary"}
>
{item.https ? "HTTPS" : "HTTP"}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>
{item.https
? "Secure HTTPS connection"
: "Standard HTTP connection"}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{item.certificateType && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline">
Cert: {item.certificateType}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>SSL Certificate Provider</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() =>
handleValidateDomain(item.host)
}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking DNS...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message
? "Behind Cloudflare"
: "DNS Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
{validationState.error}
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate DNS
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error &&
!validationState.isValid ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : validationState?.isValid ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-green-500">
{validationState.message
? "Info:"
: "Valid Configuration:"}
</p>
<p>
{validationState.message ||
`Domain points to ${validationState.resolvedIp}`}
</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -8,7 +8,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
import { CodeIcon, GitBranch } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ComposeFileEditor } from "../compose-file-editor";
@@ -25,49 +25,15 @@ interface Props {
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
if (isLoading) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -142,7 +108,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -162,7 +128,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -182,7 +148,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -202,7 +168,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

@@ -49,19 +49,12 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import {
DatabaseZap,
Info,
PenBoxIcon,
PlusIcon,
RefreshCw,
} from "lucide-react";
import { DatabaseZap, PenBoxIcon, PlusIcon, RefreshCw } from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { commonCronExpressions } from "../../application/schedules/handle-schedules";
type CacheType = "cache" | "fetch";
@@ -115,15 +108,6 @@ const Schema = z
path: ["databaseType"],
});
}
if (data.backupType === "compose" && !data.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required for compose backups",
path: ["serviceName"],
});
}
if (data.backupType === "compose" && data.databaseType) {
if (data.databaseType === "postgres") {
if (!data.metadata?.postgres?.databaseUser) {
@@ -584,55 +568,10 @@ export const HandleBackup = ({
render={({ field }) => {
return (
<FormItem>
<FormLabel className="flex items-center gap-2">
Schedule
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-4 h-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<p>
Cron expression format: minute hour day month
weekday
</p>
<p>Example: 0 0 * * * (daily at midnight)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</FormLabel>
<div className="flex flex-col gap-2">
<Select
onValueChange={(value) => {
field.onChange(value);
}}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a predefined schedule" />
</SelectTrigger>
</FormControl>
<SelectContent>
{commonCronExpressions.map((expr) => (
<SelectItem key={expr.value} value={expr.value}>
{expr.label} ({expr.value})
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative">
<FormControl>
<Input
placeholder="Custom cron expression (e.g., 0 0 * * *)"
{...field}
/>
</FormControl>
</div>
</div>
<FormDescription>
Choose a predefined schedule or enter a custom cron
expression
</FormDescription>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);

View File

@@ -103,6 +103,7 @@ const RestoreBackupSchema = z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
backupType: z.enum(["database", "compose"]).default("database"),
serviceName: z.string().nullable().optional(),
metadata: z
.object({
postgres: z
@@ -127,7 +128,6 @@ const RestoreBackupSchema = z
databaseRootPassword: z.string(),
})
.optional(),
serviceName: z.string().optional(),
})
.optional(),
})
@@ -139,15 +139,6 @@ const RestoreBackupSchema = z
path: ["databaseType"],
});
}
if (data.backupType === "compose" && !data.metadata?.serviceName) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Service name is required for compose backups",
path: ["metadata", "serviceName"],
});
}
if (data.backupType === "compose" && data.databaseType) {
if (data.databaseType === "postgres") {
if (!data.metadata?.postgres?.databaseUser) {
@@ -226,7 +217,6 @@ export const RestoreBackup = ({
databaseName: databaseType === "web-server" ? "dokploy" : "",
databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
backupType: backupType,
metadata: {},
},
resolver: zodResolver(RestoreBackupSchema),
@@ -530,11 +520,7 @@ export const RestoreBackup = ({
<FormItem>
<FormLabel>Database Name</FormLabel>
<FormControl>
<Input
placeholder="Enter database name"
{...field}
disabled={databaseType === "web-server"}
/>
<Input placeholder="Enter database name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -573,7 +559,7 @@ export const RestoreBackup = ({
<FormField
control={form.control}
name="metadata.serviceName"
name="serviceName"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Service Name</FormLabel>
@@ -791,10 +777,10 @@ export const RestoreBackup = ({
isLoading={isDeploying}
form="hook-form-restore-backup"
type="submit"
// disabled={
// !form.watch("backupFile") ||
// (backupType === "compose" && !form.watch("databaseType"))
// }
disabled={
!form.watch("backupFile") ||
(backupType === "compose" && !form.watch("databaseType"))
}
>
Restore
</Button>

View File

@@ -14,13 +14,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import {
ClipboardList,
Database,
DatabaseBackup,
Play,
Trash2,
} from "lucide-react";
import { Database, DatabaseBackup, Play, Trash2 } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
@@ -35,7 +29,6 @@ import {
PostgresqlIcon,
} from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
import { ShowDeploymentsModal } from "../../application/deployments/show-deployments-modal";
interface Props {
id: string;
@@ -131,7 +124,7 @@ export const ShowBackups = ({
</CardHeader>
<CardContent className="flex flex-col gap-4">
{data?.length === 0 ? (
<div className="flex flex-col items-center gap-3 min-h-[35vh] justify-center">
<div className="flex flex-col items-center gap-3">
<DatabaseBackup className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground text-center">
To create a backup it is required to set at least 1 provider.
@@ -163,7 +156,6 @@ export const ShowBackups = ({
<RestoreBackup
id={id}
databaseType={databaseType}
backupType={backupType}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
@@ -178,198 +170,175 @@ export const ShowBackups = ({
</AlertBlock>
)}
<div className="flex flex-col gap-6">
{postgres?.backups.map((backup) => {
const serverId =
"serverId" in postgres ? postgres.serverId : undefined;
return (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-start justify-between gap-4 border rounded-lg p-4 hover:bg-muted/50 transition-colors">
<div className="flex flex-col w-full gap-4">
<div className="flex items-center gap-3">
{backup.backupType === "compose" && (
<div className="flex items-center justify-center size-10 rounded-lg">
{backup.databaseType === "postgres" && (
<PostgresqlIcon className="size-7" />
)}
{backup.databaseType === "mysql" && (
<MysqlIcon className="size-7" />
)}
{backup.databaseType === "mariadb" && (
<MariadbIcon className="size-7" />
)}
{backup.databaseType === "mongo" && (
<MongodbIcon className="size-7" />
)}
</div>
)}
<div className="flex flex-col gap-1">
{backup.backupType === "compose" && (
<div className="flex items-center gap-2">
<h3 className="font-medium">
{backup.serviceName}
</h3>
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
{backup.databaseType}
</span>
</div>
{postgres?.backups.map((backup) => (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-start justify-between gap-4 border rounded-lg p-4 hover:bg-muted/50 transition-colors">
<div className="flex flex-col w-full gap-4">
<div className="flex items-center gap-3">
{backup.backupType === "compose" && (
<div className="flex items-center justify-center size-10 rounded-lg">
{backup.databaseType === "postgres" && (
<PostgresqlIcon className="size-7" />
)}
{backup.databaseType === "mysql" && (
<MysqlIcon className="size-7" />
)}
{backup.databaseType === "mariadb" && (
<MariadbIcon className="size-7" />
)}
{backup.databaseType === "mongo" && (
<MongodbIcon className="size-7" />
)}
</div>
)}
<div className="flex flex-col gap-1">
{backup.backupType === "compose" && (
<div className="flex items-center gap-2">
<div
className={cn(
"size-1.5 rounded-full",
backup.enabled
? "bg-green-500"
: "bg-red-500",
)}
/>
<span className="text-xs text-muted-foreground">
{backup.enabled ? "Active" : "Inactive"}
<h3 className="font-medium">
{backup.serviceName}
</h3>
<span className="px-1.5 py-0.5 rounded-full bg-muted text-xs font-medium capitalize">
{backup.databaseType}
</span>
</div>
</div>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div className="min-w-[200px]">
<span className="text-sm font-medium text-muted-foreground">
Destination
)}
<div className="flex items-center gap-2">
<div
className={cn(
"size-1.5 rounded-full",
backup.enabled
? "bg-green-500"
: "bg-red-500",
)}
/>
<span className="text-xs text-muted-foreground">
{backup.enabled ? "Active" : "Inactive"}
</span>
<p className="font-medium text-sm mt-0.5">
{backup.destination.name}
</p>
</div>
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Database
</span>
<p className="font-medium text-sm mt-0.5">
{backup.database}
</p>
</div>
<div className="min-w-[120px]">
<span className="text-sm font-medium text-muted-foreground">
Schedule
</span>
<p className="font-medium text-sm mt-0.5">
{backup.schedule}
</p>
</div>
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Prefix Storage
</span>
<p className="font-medium text-sm mt-0.5">
{backup.prefix}
</p>
</div>
<div className="min-w-[100px]">
<span className="text-sm font-medium text-muted-foreground">
Keep Latest
</span>
<p className="font-medium text-sm mt-0.5">
{backup.keepLatestCount || "All"}
</p>
</div>
</div>
</div>
<div className="flex flex-row md:flex-col gap-1.5">
<ShowDeploymentsModal
id={backup.backupId}
type="backup"
serverId={serverId || undefined}
>
<Button
variant="ghost"
size="icon"
className="size-8"
>
<ClipboardList className="size-4 transition-colors " />
</Button>
</ShowDeploymentsModal>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
isLoading={
isManualBackup &&
activeManualBackup === backup.backupId
}
onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({
backupId: backup.backupId as string,
})
.then(async () => {
toast.success(
"Manual Backup Successful",
);
})
.catch(() => {
toast.error(
"Error creating the manual backup",
);
});
setActiveManualBackup(undefined);
}}
>
<Play className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>
Run Manual Backup
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="flex flex-wrap gap-x-8 gap-y-2">
<div className="min-w-[200px]">
<span className="text-sm font-medium text-muted-foreground">
Destination
</span>
<p className="font-medium text-sm mt-0.5">
{backup.destination.name}
</p>
</div>
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
databaseType={backup.databaseType}
refetch={refetch}
/>
<DialogAction
title="Delete Backup"
description="Are you sure you want to delete this backup?"
type="destructive"
onClick={async () => {
await deleteBackup({
backupId: backup.backupId,
})
.then(() => {
refetch();
toast.success(
"Backup deleted successfully",
);
})
.catch(() => {
toast.error("Error deleting backup");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-8"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Database
</span>
<p className="font-medium text-sm mt-0.5">
{backup.database}
</p>
</div>
<div className="min-w-[120px]">
<span className="text-sm font-medium text-muted-foreground">
Schedule
</span>
<p className="font-medium text-sm mt-0.5">
{backup.schedule}
</p>
</div>
<div className="min-w-[150px]">
<span className="text-sm font-medium text-muted-foreground">
Prefix Storage
</span>
<p className="font-medium text-sm mt-0.5">
{backup.prefix}
</p>
</div>
<div className="min-w-[100px]">
<span className="text-sm font-medium text-muted-foreground">
Keep Latest
</span>
<p className="font-medium text-sm mt-0.5">
{backup.keepLatestCount || "All"}
</p>
</div>
</div>
</div>
<div className="flex flex-row md:flex-col gap-1.5">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-8"
isLoading={
isManualBackup &&
activeManualBackup === backup.backupId
}
onClick={async () => {
setActiveManualBackup(backup.backupId);
await manualBackup({
backupId: backup.backupId as string,
})
.then(async () => {
toast.success(
"Manual Backup Successful",
);
})
.catch(() => {
toast.error(
"Error creating the manual backup",
);
});
setActiveManualBackup(undefined);
}}
>
<Play className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Backup</TooltipContent>
</Tooltip>
</TooltipProvider>
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
refetch={refetch}
/>
<DialogAction
title="Delete Backup"
description="Are you sure you want to delete this backup?"
type="destructive"
onClick={async () => {
await deleteBackup({
backupId: backup.backupId,
})
.then(() => {
refetch();
toast.success("Backup deleted successfully");
})
.catch(() => {
toast.error("Error deleting backup");
});
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 size-8"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
</div>
</div>
);
})}
</div>
))}
</div>
</div>
)}

View File

@@ -140,14 +140,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.onmessage = (e) => {
if (!isCurrentConnection) return;
setRawLogs((prev) => {
const updated = prev + e.data;
const splitLines = updated.split("\n");
if (splitLines.length > lines) {
return splitLines.slice(-lines).join("\n");
}
return updated;
});
setRawLogs((prev) => prev + e.data);
setIsLoading(false);
if (noDataTimeout) clearTimeout(noDataTimeout);
};

View File

@@ -1,454 +0,0 @@
"use client";
import { authClient } from "@/lib/auth-client";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
CheckIcon,
ChevronsUpDown,
Settings2,
UserIcon,
XIcon,
Shield,
Calendar,
Key,
Copy,
Fingerprint,
Building2,
CreditCard,
Server,
} from "lucide-react";
import { toast } from "sonner";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/shared/logo";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from "@/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { format } from "date-fns";
import copy from "copy-to-clipboard";
import { api } from "@/utils/api";
type User = typeof authClient.$Infer.Session.user;
export const ImpersonationBar = () => {
const [users, setUsers] = useState<User[]>([]);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isImpersonating, setIsImpersonating] = useState(false);
const [open, setOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showBar, setShowBar] = useState(false);
const { data } = api.user.get.useQuery();
const fetchUsers = async (search?: string) => {
try {
const session = await authClient.getSession();
if (session?.data?.session?.impersonatedBy) {
return;
}
setIsLoading(true);
const response = await authClient.admin.listUsers({
query: {
limit: 30,
...(search && {
searchField: "email",
searchOperator: "contains",
searchValue: search,
}),
},
});
const filteredUsers = response.data?.users.filter(
// @ts-ignore
(user) => user.allowImpersonation && data?.user?.email !== user.email,
);
if (!response.error) {
// @ts-ignore
setUsers(filteredUsers || []);
}
} catch (error) {
console.error("Error fetching users:", error);
toast.error("Error loading users");
} finally {
setIsLoading(false);
}
};
const handleImpersonate = async () => {
if (!selectedUser) return;
try {
await authClient.admin.impersonateUser({
userId: selectedUser.id,
});
setIsImpersonating(true);
setOpen(false);
toast.success("Successfully impersonating user", {
description: `You are now viewing as ${selectedUser.name || selectedUser.email}`,
});
window.location.reload();
} catch (error) {
console.error("Error impersonating user:", error);
toast.error("Error impersonating user");
}
};
const handleStopImpersonating = async () => {
try {
await authClient.admin.stopImpersonating();
setIsImpersonating(false);
setSelectedUser(null);
setShowBar(false);
toast.success("Stopped impersonating user");
window.location.reload();
} catch (error) {
console.error("Error stopping impersonation:", error);
toast.error("Error stopping impersonation");
}
};
useEffect(() => {
const checkImpersonation = async () => {
try {
const session = await authClient.getSession();
if (session?.data?.session?.impersonatedBy) {
setIsImpersonating(true);
setShowBar(true);
// setSelectedUser(data);
}
} catch (error) {
console.error("Error checking impersonation status:", error);
}
};
checkImpersonation();
fetchUsers();
}, []);
return (
<TooltipProvider>
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"fixed bottom-4 right-4 z-50 rounded-full shadow-lg",
isImpersonating &&
!showBar &&
"bg-red-100 hover:bg-red-200 border-red-200",
)}
onClick={() => setShowBar(!showBar)}
>
<Settings2
className={cn(
"h-4 w-4",
isImpersonating && !showBar && "text-red-500",
)}
/>
</Button>
</TooltipTrigger>
<TooltipContent>
{isImpersonating ? "Impersonation Controls" : "User Impersonation"}
</TooltipContent>
</Tooltip>
<div
className={cn(
"fixed bottom-0 left-0 right-0 bg-background border-t border-border p-4 flex items-center justify-center gap-4 z-40 transition-all duration-200 ease-in-out",
showBar ? "translate-y-0" : "translate-y-full",
)}
>
<div className="flex items-center gap-4 px-4 md:px-20 w-full">
<Logo className="w-10 h-10" />
{!isImpersonating ? (
<div className="flex items-center gap-2 w-full">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-[300px] justify-between"
>
{selectedUser ? (
<div className="flex items-center gap-2">
<UserIcon className="mr-2 h-4 w-4 flex-shrink-0" />
<span className="truncate flex flex-col items-start">
<span className="text-sm font-medium">
{selectedUser.name || ""}
</span>
<span className="text-xs text-muted-foreground">
{selectedUser.email}
</span>
</span>
</div>
) : (
<>
<UserIcon className="mr-2 h-4 w-4" />
<span>Select user to impersonate</span>
</>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search users by email or name..."
onValueChange={(search) => {
fetchUsers(search);
}}
className="h-9"
/>
{isLoading ? (
<div className="py-6 text-center text-sm">
Loading users...
</div>
) : (
<>
<CommandEmpty>No users found.</CommandEmpty>
<CommandList>
<CommandGroup heading="All Users">
{users.map((user) => (
<CommandItem
key={user.id}
value={user.email}
onSelect={() => {
setSelectedUser(user);
setOpen(false);
}}
>
<span className="flex items-center gap-2 flex-1">
<UserIcon className="h-4 w-4 flex-shrink-0" />
<span className="flex flex-col items-start">
<span className="text-sm font-medium">
{user.name || ""}
</span>
<span className="text-xs text-muted-foreground">
{user.email} {user.role}
</span>
</span>
</span>
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
selectedUser?.id === user.id
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</>
)}
</Command>
</PopoverContent>
</Popover>
<Button
onClick={handleImpersonate}
disabled={!selectedUser}
variant="default"
className="gap-2"
>
<Shield className="h-4 w-4" />
Impersonate
</Button>
</div>
) : (
<div className="flex items-center gap-4 w-full flex-wrap">
<div className="flex items-center gap-4 flex-1 flex-wrap">
<Avatar className="h-10 w-10">
<AvatarImage
src={data?.user?.image || ""}
alt={data?.user?.name || ""}
/>
<AvatarFallback>
{data?.user?.name?.slice(0, 2).toUpperCase() || "U"}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="gap-1 py-1 text-yellow-500 bg-yellow-50/20"
>
<Shield className="h-3 w-3" />
Impersonating
</Badge>
<span className="font-medium">
{data?.user?.name || ""}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-muted-foreground flex-wrap">
<span className="flex items-center gap-1">
<UserIcon className="h-3 w-3" />
{data?.user?.email} {data?.role}
</span>
<span className="flex items-center gap-1">
<Key className="h-3 w-3" />
<span className="flex items-center gap-1">
ID: {data?.user?.id?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
if (data?.id) {
copy(data.id);
toast.success("ID copied to clipboard");
}
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
<span className="flex items-center gap-1">
<Building2 className="h-3 w-3" />
<span className="flex items-center gap-1">
Org: {data?.organizationId?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
if (data?.organizationId) {
copy(data.organizationId);
toast.success(
"Organization ID copied to clipboard",
);
}
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
{data?.user?.stripeCustomerId && (
<span className="flex items-center gap-1">
<CreditCard className="h-3 w-3" />
<span className="flex items-center gap-1">
Customer:
{data?.user?.stripeCustomerId?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
copy(data?.user?.stripeCustomerId || "");
toast.success(
"Stripe Customer ID copied to clipboard",
);
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
)}
{data?.user?.stripeSubscriptionId && (
<span className="flex items-center gap-1">
<CreditCard className="h-3 w-3" />
<span className="flex items-center gap-1">
Sub: {data?.user?.stripeSubscriptionId?.slice(0, 8)}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 hover:bg-muted/50"
onClick={() => {
copy(data.user.stripeSubscriptionId || "");
toast.success(
"Stripe Subscription ID copied to clipboard",
);
}}
>
<Copy className="h-3 w-3" />
</Button>
</span>
</span>
)}
{data?.user?.serversQuantity !== undefined && (
<span className="flex items-center gap-1">
<Server className="h-3 w-3" />
<span>Servers: {data.user.serversQuantity}</span>
</span>
)}
{data?.createdAt && (
<span className="flex items-center gap-1">
<Calendar className="h-3 w-3" />
Created:{" "}
{format(new Date(data.createdAt), "MMM d, yyyy")}
</span>
)}
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-default">
<Fingerprint
className={cn(
"h-3 w-3",
data?.user?.twoFactorEnabled
? "text-green-500"
: "text-muted-foreground",
)}
/>
<Badge
variant={
data?.user?.twoFactorEnabled
? "green"
: "secondary"
}
className="text-[10px] px-1 py-0"
>
2FA{" "}
{data?.user?.twoFactorEnabled
? "Enabled"
: "Disabled"}
</Badge>
</span>
</TooltipTrigger>
<TooltipContent>
Two-Factor Authentication Status
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
<Button
onClick={handleStopImpersonating}
variant="secondary"
className="gap-2"
size="sm"
>
<XIcon className="w-4 h-4" />
Stop Impersonating
</Button>
</div>
)}
</div>
</div>
</>
</TooltipProvider>
);
};

View File

@@ -145,8 +145,10 @@ export const AddApplication = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
form.setValue("appName", `${slug}-${serviceName}`);
form.setValue(
"appName",
`${slug}-${val.toLowerCase().replaceAll(" ", "-")}`,
);
field.onChange(val);
}}
/>

View File

@@ -152,8 +152,10 @@ export const AddCompose = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
form.setValue("appName", `${slug}-${serviceName}`);
form.setValue(
"appName",
`${slug}-${val.toLowerCase()}`,
);
field.onChange(val);
}}
/>

View File

@@ -363,8 +363,10 @@ export const AddDatabase = ({ projectId, projectName }: Props) => {
{...field}
onChange={(e) => {
const val = e.target.value?.trim() || "";
const serviceName = slugify(val);
form.setValue("appName", `${slug}-${serviceName}`);
form.setValue(
"appName",
`${slug}-${val.toLowerCase()}`,
);
field.onChange(val);
}}
/>

View File

@@ -33,23 +33,12 @@ import { z } from "zod";
const AddProjectSchema = z.object({
name: z
.string()
.min(1, "Project name is required")
.refine(
(name) => {
const trimmedName = name.trim();
const validNameRegex =
/^[\p{L}\p{N}_-][\p{L}\p{N}\s_-]*[\p{L}\p{N}_-]$/u;
return validNameRegex.test(trimmedName);
},
{
message:
"Project name must start and end with a letter, number, hyphen or underscore. Spaces are allowed in between.",
},
)
.refine((name) => !/^\d/.test(name.trim()), {
message: "Project name cannot start with a number",
.min(1, {
message: "Name is required",
})
.transform((name) => name.trim()),
.regex(/^[a-zA-Z]/, {
message: "Project name cannot start with a number",
}),
description: z.string().optional(),
});

View File

@@ -0,0 +1,191 @@
import { Card } from "@/components/ui/card";
import {
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { SparklesIcon } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { toast } from "sonner";
import { api } from "@/utils/api";
import { SetupMonitoring } from "./servers/setup-monitoring";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
export const EnablePaidFeatures = () => {
const { data, refetch } = api.user.get.useQuery();
const [isLoading, setIsLoading] = useState(false);
const { mutateAsync: saveLicense } = api.user.saveLicense.useMutation();
const { mutateAsync: deactivateLicense } =
api.user.deactivateLicense.useMutation();
const { mutateAsync: validateLicense } =
api.user.validateLicense.useMutation();
const { mutateAsync: update } = api.user.update.useMutation();
const [licenseKey, setLicenseKey] = useState("");
useEffect(() => {
if (data?.user?.enablePaidFeatures) {
setLicenseKey(data.user.licenseKey || "");
}
}, [data?.user?.enablePaidFeatures]);
const handleSaveLicense = async () => {
if (!licenseKey) {
toast.error("Please enter a license key");
return;
}
setIsLoading(true);
await saveLicense({
licenseKey,
})
.then(() => {
toast.success("License validated successfully");
})
.catch((e) => {
toast.error("Error validating license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
});
refetch();
};
const handleValidateLicense = async () => {
if (!licenseKey) {
toast.error("Please enter a license key");
return;
}
setIsLoading(true);
await validateLicense({
licenseKey,
})
.then(() => {
toast.success("License validated successfully");
})
.catch((e) => {
toast.error("Error validating license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
});
};
return (
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl flex flex-row items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10">
<SparklesIcon className="size-5 text-primary" />
</div>
Paid Features
</CardTitle>
<CardDescription className="mt-2">
Unlock advanced capabilities like monitoring and enhanced
performance tracking
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="flex flex-col gap-6">
<div className="flex flex-row items-center justify-between p-4 border rounded-lg bg-card/50 hover:bg-card/80 transition-colors">
<div className="space-y-1">
<h3 className="font-medium">Enable Premium Features</h3>
<p className="text-sm text-muted-foreground">
Access advanced monitoring tools and premium capabilities
</p>
</div>
<Switch
className="ml-4"
checked={data?.user?.enablePaidFeatures}
onCheckedChange={() => {
update({
enablePaidFeatures: !data?.user?.enablePaidFeatures,
})
.then(() => {
toast.success(
`Premium features ${
data?.user?.enablePaidFeatures
? "disabled"
: "enabled"
} successfully`,
);
refetch();
})
.catch(() => {
toast.error("Error updating premium features");
});
}}
/>
</div>
{data?.user?.enablePaidFeatures && (
<div className="flex flex-row items-center gap-4 p-4 border rounded-lg bg-card/50">
<div className="flex-grow">
<Input
placeholder="Enter your license key"
value={licenseKey}
disabled={data?.user?.licenseKey !== null}
onChange={(e) => setLicenseKey(e.target.value)}
className="w-full"
/>
</div>
{!data?.user?.licenseKey ? (
<Button
onClick={handleSaveLicense}
variant="secondary"
disabled={isLoading}
>
{isLoading ? "Saving..." : "Save License"}
</Button>
) : (
<div className="flex gap-2">
<Button
onClick={handleValidateLicense}
variant="secondary"
disabled={isLoading}
>
{isLoading ? "Validating..." : "Validate"}
</Button>
<Button
onClick={() => {
setIsLoading(true);
deactivateLicense({
licenseKey,
})
.then(() => {
setLicenseKey("");
toast.success("License removed successfully");
refetch();
})
.catch((e) => {
toast.error("Error removing license", {
description: e.message,
});
})
.finally(() => {
setIsLoading(false);
});
}}
variant="destructive"
disabled={isLoading}
>
Remove
</Button>
</div>
)}
</div>
)}
</div>
</CardContent>
{data?.user?.enablePaidFeatures && <SetupMonitoring />}
</div>
</Card>
);
};

View File

@@ -10,7 +10,6 @@ import {
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
@@ -29,14 +28,12 @@ import { toast } from "sonner";
import { z } from "zod";
import { Disable2FA } from "./disable-2fa";
import { Enable2FA } from "./enable-2fa";
import { Switch } from "@/components/ui/switch";
const profileSchema = z.object({
email: z.string(),
password: z.string().nullable(),
currentPassword: z.string().nullable(),
image: z.string().optional(),
allowImpersonation: z.boolean().optional().default(false),
});
type Profile = z.infer<typeof profileSchema>;
@@ -59,7 +56,6 @@ const randomImages = [
export const ProfileForm = () => {
const _utils = api.useUtils();
const { data, refetch, isLoading } = api.user.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery();
const {
mutateAsync,
@@ -83,7 +79,6 @@ export const ProfileForm = () => {
password: "",
image: data?.user?.image || "",
currentPassword: "",
allowImpersonation: data?.user?.allowImpersonation || false,
},
resolver: zodResolver(profileSchema),
});
@@ -96,13 +91,11 @@ export const ProfileForm = () => {
password: form.getValues("password") || "",
image: data?.user?.image || "",
currentPassword: form.getValues("currentPassword") || "",
allowImpersonation: data?.user?.allowImpersonation,
},
{
keepValues: true,
},
);
form.setValue("allowImpersonation", data?.user?.allowImpersonation);
if (data.user.email) {
generateSHA256Hash(data.user.email).then((hash) => {
@@ -118,7 +111,6 @@ export const ProfileForm = () => {
password: values.password || undefined,
image: values.image,
currentPassword: values.currentPassword || undefined,
allowImpersonation: values.allowImpersonation,
})
.then(async () => {
await refetch();
@@ -264,34 +256,7 @@ export const ProfileForm = () => {
</FormItem>
)}
/>
{isCloud && (
<FormField
control={form.control}
name="allowImpersonation"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Allow Impersonation</FormLabel>
<FormDescription>
Enable this option to allow Dokploy Cloud
administrators to temporarily access your
account for troubleshooting and support
purposes. This helps them quickly identify and
resolve any issues you may encounter.
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
)}
</div>
<div className="flex items-center justify-end gap-2">
<Button type="submit" isLoading={isUpdating}>
{t("settings.common.save")}

View File

@@ -202,7 +202,7 @@ export const SetupMonitoring = ({ serverId }: Props) => {
const { mutateAsync } = serverId
? api.server.setupMonitoring.useMutation()
: api.admin.setupMonitoring.useMutation();
: api.user.setupMonitoring.useMutation();
const generateToken = () => {
const array = new Uint8Array(64);

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
interface Props {
serverId: string;
}
export const ShowSchedulesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Schedules
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-5xl overflow-y-auto max-h-screen ">
<ShowSchedules id={serverId} scheduleType="server" />
</DialogContent>
</Dialog>
);
};

View File

@@ -43,7 +43,6 @@ import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
import { ShowSchedulesModal } from "./show-schedules-modal";
export const ShowServers = () => {
const { t } = useTranslation("settings");
@@ -333,10 +332,6 @@ export const ShowServers = () => {
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>

View File

@@ -185,21 +185,24 @@ export const ShowInvitations = () => {
Cancel Invitation
</DropdownMenuItem>
)}
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={async (_e) => {
await removeInvitation({
invitationId: invitation.id,
}).then(() => {
refetch();
toast.success(
"Invitation removed",
);
});
}}
>
Remove Invitation
</DropdownMenuItem>
</>
)}
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={async (_e) => {
await removeInvitation({
invitationId: invitation.id,
}).then(() => {
refetch();
toast.success("Invitation removed");
});
}}
>
Remove Invitation
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@@ -1,19 +1,9 @@
import Page from "./side";
import { ImpersonationBar } from "../dashboard/impersonation/impersonation-bar";
import { api } from "@/utils/api";
interface Props {
children: React.ReactNode;
metaName?: string;
}
export const DashboardLayout = ({ children }: Props) => {
const { data: haveRootAccess } = api.user.haveRootAccess.useQuery();
return (
<>
<Page>{children}</Page>
{haveRootAccess === true && <ImpersonationBar />}
</>
);
return <Page>{children}</Page>;
};

View File

@@ -10,7 +10,6 @@ import {
ChevronRight,
ChevronsUpDown,
CircleHelp,
Clock,
CreditCard,
Database,
Folder,
@@ -159,14 +158,6 @@ const MENU: Menu = {
// Only enabled in non-cloud environments
isEnabled: ({ isCloud }) => !isCloud,
},
{
isSingle: true,
title: "Schedules",
url: "/dashboard/schedules",
icon: Clock,
// Only enabled in non-cloud environments
isEnabled: ({ isCloud, auth }) => !isCloud && auth?.role === "owner",
},
{
isSingle: true,
title: "Traefik File System",

View File

@@ -1,28 +0,0 @@
CREATE TYPE "public"."scheduleType" AS ENUM('application', 'compose', 'server', 'dokploy-server');--> statement-breakpoint
CREATE TYPE "public"."shellType" AS ENUM('bash', 'sh');--> statement-breakpoint
CREATE TABLE "schedule" (
"scheduleId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"cronExpression" text NOT NULL,
"appName" text NOT NULL,
"serviceName" text,
"shellType" "shellType" DEFAULT 'bash' NOT NULL,
"scheduleType" "scheduleType" DEFAULT 'application' NOT NULL,
"command" text NOT NULL,
"script" text,
"applicationId" text,
"composeId" text,
"serverId" text,
"userId" text,
"enabled" boolean DEFAULT true NOT NULL,
"createdAt" text NOT NULL
);
--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "startedAt" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "finishedAt" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "scheduleId" text;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_applicationId_application_applicationId_fk" FOREIGN KEY ("applicationId") REFERENCES "public"."application"("applicationId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "schedule" ADD CONSTRAINT "schedule_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_scheduleId_schedule_scheduleId_fk" FOREIGN KEY ("scheduleId") REFERENCES "public"."schedule"("scheduleId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,5 @@
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1 @@
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;

View File

@@ -1,29 +0,0 @@
CREATE TYPE "public"."backupType" AS ENUM('database', 'compose');--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "appName" text;
UPDATE "backup"
SET "appName" = 'backup-' ||
(
ARRAY['optimize', 'parse', 'quantify', 'bypass', 'override', 'generate',
'secure', 'hack', 'backup', 'connect', 'index', 'compress']::text[]
)[floor(random() * 12) + 1] || '-' ||
(
ARRAY['digital', 'virtual', 'mobile', 'neural', 'optical', 'auxiliary',
'primary', 'backup', 'wireless', 'haptic', 'solid-state']::text[]
)[floor(random() * 11) + 1] || '-' ||
(
ARRAY['driver', 'protocol', 'array', 'matrix', 'system', 'bandwidth',
'monitor', 'firewall', 'card', 'sensor', 'bus']::text[]
)[floor(random() * 11) + 1] || '-' ||
substr(md5(random()::text), 1, 6);
ALTER TABLE "backup" ALTER COLUMN "appName" SET NOT NULL;
ALTER TABLE "backup" ADD COLUMN "serviceName" text;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "backupType" "backupType" DEFAULT 'database' NOT NULL;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "composeId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD COLUMN "metadata" jsonb;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "backupId" text;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_composeId_compose_composeId_fk" FOREIGN KEY ("composeId") REFERENCES "public"."compose"("composeId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_backupId_backup_backupId_fk" FOREIGN KEY ("backupId") REFERENCES "public"."backup"("backupId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "backup" ADD CONSTRAINT "backup_appName_unique" UNIQUE("appName");

View File

@@ -1 +0,0 @@
ALTER TABLE "user_temp" ADD COLUMN "allowImpersonation" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "user_temp" ADD COLUMN "licenseKey" text;

View File

@@ -1 +0,0 @@
ALTER TABLE "user_temp" ADD COLUMN "role" text DEFAULT 'user' NOT NULL;

View File

@@ -1,5 +1,5 @@
{
"id": "c7eae4ce-5acc-439b-962f-bb2ef8922187",
"id": "7fea81ef-e2a7-4a8b-b755-e98903a08b57",
"prevId": "7fb3716c-3cc6-4b18-b8d1-762844da26be",
"version": "7",
"dialect": "postgresql",
@@ -1770,6 +1770,12 @@
"primaryKey": false,
"notNull": true
},
"serviceName": {
"name": "serviceName",
"type": "text",
"primaryKey": false,
"notNull": false
},
"destinationId": {
"name": "destinationId",
"type": "text",
@@ -1782,6 +1788,14 @@
"primaryKey": false,
"notNull": false
},
"backupType": {
"name": "backupType",
"type": "backupType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'database'"
},
"databaseType": {
"name": "databaseType",
"type": "databaseType",
@@ -1789,6 +1803,12 @@
"primaryKey": false,
"notNull": true
},
"composeId": {
"name": "composeId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"postgresId": {
"name": "postgresId",
"type": "text",
@@ -1835,6 +1855,19 @@
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_composeId_compose_composeId_fk": {
"name": "backup_composeId_compose_composeId_fk",
"tableFrom": "backup",
"tableTo": "compose",
"columnsFrom": [
"composeId"
],
"columnsTo": [
"composeId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"backup_postgresId_postgres_postgresId_fk": {
"name": "backup_postgresId_postgres_postgresId_fk",
"tableFrom": "backup",
@@ -2068,29 +2101,11 @@
"primaryKey": false,
"notNull": true
},
"startedAt": {
"name": "startedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"finishedAt": {
"name": "finishedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scheduleId": {
"name": "scheduleId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -2146,19 +2161,6 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deployment_scheduleId_schedule_scheduleId_fk": {
"name": "deployment_scheduleId_schedule_scheduleId_fk",
"tableFrom": "deployment",
"tableTo": "schedule",
"columnsFrom": [
"scheduleId"
],
"columnsTo": [
"scheduleId"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -5253,167 +5255,6 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.schedule": {
"name": "schedule",
"schema": "",
"columns": {
"scheduleId": {
"name": "scheduleId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cronExpression": {
"name": "cronExpression",
"type": "text",
"primaryKey": false,
"notNull": true
},
"appName": {
"name": "appName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceName": {
"name": "serviceName",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shellType": {
"name": "shellType",
"type": "shellType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'bash'"
},
"scheduleType": {
"name": "scheduleType",
"type": "scheduleType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'application'"
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": true
},
"script": {
"name": "script",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"composeId": {
"name": "composeId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serverId": {
"name": "serverId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"schedule_applicationId_application_applicationId_fk": {
"name": "schedule_applicationId_application_applicationId_fk",
"tableFrom": "schedule",
"tableTo": "application",
"columnsFrom": [
"applicationId"
],
"columnsTo": [
"applicationId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_composeId_compose_composeId_fk": {
"name": "schedule_composeId_compose_composeId_fk",
"tableFrom": "schedule",
"tableTo": "compose",
"columnsFrom": [
"composeId"
],
"columnsTo": [
"composeId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_serverId_server_serverId_fk": {
"name": "schedule_serverId_server_serverId_fk",
"tableFrom": "schedule",
"tableTo": "server",
"columnsFrom": [
"serverId"
],
"columnsTo": [
"serverId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_userId_user_temp_id_fk": {
"name": "schedule_userId_user_temp_id_fk",
"tableFrom": "schedule",
"tableTo": "user_temp",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
@@ -5451,6 +5292,14 @@
"preview"
]
},
"public.backupType": {
"name": "backupType",
"schema": "public",
"values": [
"database",
"compose"
]
},
"public.databaseType": {
"name": "databaseType",
"schema": "public",
@@ -5584,24 +5433,6 @@
"active",
"inactive"
]
},
"public.scheduleType": {
"name": "scheduleType",
"schema": "public",
"values": [
"application",
"compose",
"server",
"dokploy-server"
]
},
"public.shellType": {
"name": "shellType",
"schema": "public",
"values": [
"bash",
"sh"
]
}
},
"schemas": {},

View File

@@ -1,6 +1,6 @@
{
"id": "3ec09926-2da4-41c9-8eae-8ef6b023035e",
"prevId": "c7eae4ce-5acc-439b-962f-bb2ef8922187",
"id": "83185289-c593-420c-b8da-a90143aebf8b",
"prevId": "7fea81ef-e2a7-4a8b-b755-e98903a08b57",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -1746,12 +1746,6 @@
"primaryKey": true,
"notNull": true
},
"appName": {
"name": "appName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"schedule": {
"name": "schedule",
"type": "text",
@@ -1947,15 +1941,7 @@
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"backup_appName_unique": {
"name": "backup_appName_unique",
"nullsNotDistinct": false,
"columns": [
"appName"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
@@ -2121,35 +2107,11 @@
"primaryKey": false,
"notNull": true
},
"startedAt": {
"name": "startedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"finishedAt": {
"name": "finishedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scheduleId": {
"name": "scheduleId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"backupId": {
"name": "backupId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -2205,32 +2167,6 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deployment_scheduleId_schedule_scheduleId_fk": {
"name": "deployment_scheduleId_schedule_scheduleId_fk",
"tableFrom": "deployment",
"tableTo": "schedule",
"columnsFrom": [
"scheduleId"
],
"columnsTo": [
"scheduleId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deployment_backupId_backup_backupId_fk": {
"name": "deployment_backupId_backup_backupId_fk",
"tableFrom": "deployment",
"tableTo": "backup",
"columnsFrom": [
"backupId"
],
"columnsTo": [
"backupId"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -5325,167 +5261,6 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.schedule": {
"name": "schedule",
"schema": "",
"columns": {
"scheduleId": {
"name": "scheduleId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cronExpression": {
"name": "cronExpression",
"type": "text",
"primaryKey": false,
"notNull": true
},
"appName": {
"name": "appName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceName": {
"name": "serviceName",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shellType": {
"name": "shellType",
"type": "shellType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'bash'"
},
"scheduleType": {
"name": "scheduleType",
"type": "scheduleType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'application'"
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": true
},
"script": {
"name": "script",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"composeId": {
"name": "composeId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serverId": {
"name": "serverId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"schedule_applicationId_application_applicationId_fk": {
"name": "schedule_applicationId_application_applicationId_fk",
"tableFrom": "schedule",
"tableTo": "application",
"columnsFrom": [
"applicationId"
],
"columnsTo": [
"applicationId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_composeId_compose_composeId_fk": {
"name": "schedule_composeId_compose_composeId_fk",
"tableFrom": "schedule",
"tableTo": "compose",
"columnsFrom": [
"composeId"
],
"columnsTo": [
"composeId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_serverId_server_serverId_fk": {
"name": "schedule_serverId_server_serverId_fk",
"tableFrom": "schedule",
"tableTo": "server",
"columnsFrom": [
"serverId"
],
"columnsTo": [
"serverId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_userId_user_temp_id_fk": {
"name": "schedule_userId_user_temp_id_fk",
"tableFrom": "schedule",
"tableTo": "user_temp",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
@@ -5664,24 +5439,6 @@
"active",
"inactive"
]
},
"public.scheduleType": {
"name": "scheduleType",
"schema": "public",
"values": [
"application",
"compose",
"server",
"dokploy-server"
]
},
"public.shellType": {
"name": "shellType",
"schema": "public",
"values": [
"bash",
"sh"
]
}
},
"schemas": {},

View File

@@ -1,6 +1,6 @@
{
"id": "f8b568f0-83c1-4cfb-95c9-ab6b5b5bce18",
"prevId": "3ec09926-2da4-41c9-8eae-8ef6b023035e",
"id": "d5010e21-a520-4d05-aef3-c549b55456e5",
"prevId": "83185289-c593-420c-b8da-a90143aebf8b",
"version": "7",
"dialect": "postgresql",
"tables": {
@@ -959,12 +959,11 @@
"notNull": true,
"default": false
},
"allowImpersonation": {
"name": "allowImpersonation",
"type": "boolean",
"licenseKey": {
"name": "licenseKey",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": false
"notNull": false
},
"metricsConfig": {
"name": "metricsConfig",
@@ -1753,12 +1752,6 @@
"primaryKey": true,
"notNull": true
},
"appName": {
"name": "appName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"schedule": {
"name": "schedule",
"type": "text",
@@ -1954,15 +1947,7 @@
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"backup_appName_unique": {
"name": "backup_appName_unique",
"nullsNotDistinct": false,
"columns": [
"appName"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
@@ -2128,35 +2113,11 @@
"primaryKey": false,
"notNull": true
},
"startedAt": {
"name": "startedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"finishedAt": {
"name": "finishedAt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"errorMessage": {
"name": "errorMessage",
"type": "text",
"primaryKey": false,
"notNull": false
},
"scheduleId": {
"name": "scheduleId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"backupId": {
"name": "backupId",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
@@ -2212,32 +2173,6 @@
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deployment_scheduleId_schedule_scheduleId_fk": {
"name": "deployment_scheduleId_schedule_scheduleId_fk",
"tableFrom": "deployment",
"tableTo": "schedule",
"columnsFrom": [
"scheduleId"
],
"columnsTo": [
"scheduleId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"deployment_backupId_backup_backupId_fk": {
"name": "deployment_backupId_backup_backupId_fk",
"tableFrom": "deployment",
"tableTo": "backup",
"columnsFrom": [
"backupId"
],
"columnsTo": [
"backupId"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
@@ -5332,167 +5267,6 @@
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.schedule": {
"name": "schedule",
"schema": "",
"columns": {
"scheduleId": {
"name": "scheduleId",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"cronExpression": {
"name": "cronExpression",
"type": "text",
"primaryKey": false,
"notNull": true
},
"appName": {
"name": "appName",
"type": "text",
"primaryKey": false,
"notNull": true
},
"serviceName": {
"name": "serviceName",
"type": "text",
"primaryKey": false,
"notNull": false
},
"shellType": {
"name": "shellType",
"type": "shellType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'bash'"
},
"scheduleType": {
"name": "scheduleType",
"type": "scheduleType",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'application'"
},
"command": {
"name": "command",
"type": "text",
"primaryKey": false,
"notNull": true
},
"script": {
"name": "script",
"type": "text",
"primaryKey": false,
"notNull": false
},
"applicationId": {
"name": "applicationId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"composeId": {
"name": "composeId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"serverId": {
"name": "serverId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": false
},
"enabled": {
"name": "enabled",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"createdAt": {
"name": "createdAt",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"schedule_applicationId_application_applicationId_fk": {
"name": "schedule_applicationId_application_applicationId_fk",
"tableFrom": "schedule",
"tableTo": "application",
"columnsFrom": [
"applicationId"
],
"columnsTo": [
"applicationId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_composeId_compose_composeId_fk": {
"name": "schedule_composeId_compose_composeId_fk",
"tableFrom": "schedule",
"tableTo": "compose",
"columnsFrom": [
"composeId"
],
"columnsTo": [
"composeId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_serverId_server_serverId_fk": {
"name": "schedule_serverId_server_serverId_fk",
"tableFrom": "schedule",
"tableTo": "server",
"columnsFrom": [
"serverId"
],
"columnsTo": [
"serverId"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"schedule_userId_user_temp_id_fk": {
"name": "schedule_userId_user_temp_id_fk",
"tableFrom": "schedule",
"tableTo": "user_temp",
"columnsFrom": [
"userId"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
@@ -5671,24 +5445,6 @@
"active",
"inactive"
]
},
"public.scheduleType": {
"name": "scheduleType",
"schema": "public",
"values": [
"application",
"compose",
"server",
"dokploy-server"
]
},
"public.shellType": {
"name": "shellType",
"schema": "public",
"values": [
"bash",
"sh"
]
}
},
"schemas": {},

File diff suppressed because it is too large Load Diff

View File

@@ -621,29 +621,22 @@
{
"idx": 88,
"version": "7",
"when": 1746256928101,
"tag": "0088_illegal_ma_gnuci",
"when": 1745801614194,
"tag": "0088_same_ezekiel",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1746392564463,
"tag": "0089_noisy_sandman",
"when": 1745812150155,
"tag": "0089_dazzling_marrow",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1746509318678,
"tag": "0090_clean_wolf_cub",
"breakpoints": true
},
{
"idx": 91,
"version": "7",
"when": 1746518402168,
"tag": "0091_spotty_kulan_gath",
"when": 1746156076450,
"tag": "0090_tiny_phil_sheldon",
"breakpoints": true
}
]

View File

@@ -1,15 +1,9 @@
import { organizationClient } from "better-auth/client/plugins";
import { twoFactorClient } from "better-auth/client/plugins";
import { apiKeyClient } from "better-auth/client/plugins";
import { adminClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
// baseURL: "http://localhost:3000", // the base url of your auth server
plugins: [
organizationClient(),
twoFactorClient(),
apiKeyClient(),
adminClient(),
],
plugins: [organizationClient(), twoFactorClient(), apiKeyClient()],
});

View File

@@ -5,7 +5,7 @@ export const slugify = (text: string | undefined) => {
return "";
}
const cleanedText = text.trim().replace(/[^a-zA-Z0-9\s]/g, "") || "service";
const cleanedText = text.trim().replace(/[^a-zA-Z0-9\s]/g, "");
return slug(cleanedText, {
lower: true,

View File

@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.22.4",
"version": "v0.21.8",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -92,7 +92,7 @@
"adm-zip": "^0.5.14",
"ai": "^4.0.23",
"bcrypt": "5.1.1",
"better-auth": "v1.2.8-beta.7",
"better-auth": "1.2.6",
"bl": "6.0.11",
"boxen": "^7.1.1",
"bullmq": "5.4.2",

View File

@@ -1,7 +1,10 @@
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ShowPaidMonitoring } from "@/components/dashboard/monitoring/paid/servers/show-paid-monitoring";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import { Card } from "@/components/ui/card";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { useLocalStorage } from "@/hooks/useLocalStorage";
import { api } from "@/utils/api";
import { IS_CLOUD } from "@dokploy/server/constants";
@@ -23,7 +26,7 @@ const Dashboard = () => {
const { data: monitoring, isLoading } = api.user.getMetricsToken.useQuery();
return (
<div className="space-y-4 pb-10">
{/* <AlertBlock>
<AlertBlock>
You are watching the <strong>Free</strong> plan.{" "}
<a
href="https://dokploy.com#pricing"
@@ -34,7 +37,7 @@ const Dashboard = () => {
Upgrade
</a>{" "}
to get more features.
</AlertBlock> */}
</AlertBlock>
{isLoading ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto items-center">
<div className="rounded-xl bg-background flex shadow-md px-4 min-h-[50vh] justify-center items-center text-muted-foreground">
@@ -44,15 +47,15 @@ const Dashboard = () => {
</Card>
) : (
<>
{/* {monitoring?.enabledFeatures && (
{monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">Change Monitoring</Label>
<Switch
checked={toggleMonitoring}
onCheckedChange={setToggleMonitoring}
onCheckedChange={_setToggleMonitoring}
/>
</div>
)} */}
)}
{toggleMonitoring ? (
<Card className="bg-sidebar p-2.5 rounded-xl mx-auto">
<div className="rounded-xl bg-background shadow-md">

View File

@@ -80,7 +80,6 @@ import {
Loader2,
PlusIcon,
Search,
ServerIcon,
Trash2,
X,
} from "lucide-react";
@@ -969,11 +968,6 @@ const Project = (
}}
className="flex flex-col group relative cursor-pointer bg-transparent transition-colors hover:bg-border"
>
{service.serverId && (
<div className="absolute -left-1 -top-2">
<ServerIcon className="size-4 text-muted-foreground" />
</div>
)}
<div className="absolute -right-1 -top-2">
<StatusTooltip status={service.status} />
</div>

View File

@@ -12,7 +12,6 @@ import { ShowEnvironment } from "@/components/dashboard/application/environment/
import { ShowGeneralApplication } from "@/components/dashboard/application/general/show";
import { ShowDockerLogs } from "@/components/dashboard/application/logs/show";
import { ShowPreviewDeployments } from "@/components/dashboard/application/preview-deployments/show-preview-deployments";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { UpdateApplication } from "@/components/dashboard/application/update-application";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
@@ -29,6 +28,7 @@ import {
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
@@ -66,7 +66,7 @@ type TabState =
const Service = (
props: InferGetServerSidePropsType<typeof getServerSideProps>,
) => {
const [_toggleMonitoring, _setToggleMonitoring] = useState(false);
const [_toggleMonitoring, _setToggleMonitoring] = useState(true);
const { applicationId, activeTab } = props;
const router = useRouter();
const { projectId } = router.query;
@@ -87,6 +87,7 @@ const Service = (
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: auth } = api.user.get.useQuery();
const { data: monitoring } = api.user.getMetricsToken.useQuery();
return (
<div className="pb-10">
@@ -233,7 +234,6 @@ const Service = (
<TabsTrigger value="preview-deployments">
Preview Deployments
</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
@@ -267,21 +267,19 @@ const Service = (
/>
) : (
<>
{/* {monitoring?.enabledFeatures &&
isCloud &&
data?.serverId && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
</Label>
<Switch
checked={toggleMonitoring}
onCheckedChange={setToggleMonitoring}
/>
</div>
)} */}
{monitoring?.enabledFeatures && (
<div className="flex flex-row border w-fit p-4 rounded-lg items-center gap-2">
<Label className="text-muted-foreground">
Change Monitoring
</Label>
<Switch
checked={_toggleMonitoring}
onCheckedChange={_setToggleMonitoring}
/>
</div>
)}
{/* {toggleMonitoring ? (
{_toggleMonitoring ? (
<ContainerPaidMonitoring
appName={data?.appName || ""}
baseUrl={`http://${monitoring?.serverIp}:${monitoring?.metricsConfig?.server?.port}`}
@@ -289,13 +287,13 @@ const Service = (
monitoring?.metricsConfig?.server?.token || ""
}
/>
) : ( */}
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
{/* )} */}
) : (
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
)}
</>
)}
</div>
@@ -310,22 +308,9 @@ const Service = (
/>
</div>
</TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules
id={applicationId}
scheduleType="application"
/>
</div>
</TabsContent>
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={applicationId}
type="application"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
<TabsContent value="deployments" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDeployments applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="preview-deployments" className="w-full">
@@ -335,7 +320,7 @@ const Service = (
</TabsContent>
<TabsContent value="domains" className="w-full">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains id={applicationId} type="application" />
<ShowDomains applicationId={applicationId} />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -1,11 +1,10 @@
import { ShowImport } from "@/components/dashboard/application/advanced/import/show-import";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { ShowDeployments } from "@/components/dashboard/application/deployments/show-deployments";
import { ShowDomains } from "@/components/dashboard/application/domains/show-domains";
import { ShowEnvironment } from "@/components/dashboard/application/environment/show-enviroment";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { AddCommandCompose } from "@/components/dashboard/compose/advanced/add-command";
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ShowDeploymentsCompose } from "@/components/dashboard/compose/deployments/show-deployments-compose";
import { ShowDomainsCompose } from "@/components/dashboard/compose/domains/show-domains";
import { ShowGeneralCompose } from "@/components/dashboard/compose/general/show";
import { ShowDockerLogsCompose } from "@/components/dashboard/compose/logs/show";
import { ShowDockerLogsStack } from "@/components/dashboard/compose/logs/show-stack";
@@ -219,10 +218,10 @@ const Service = (
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-9"
? "lg:grid-cols-8"
: data?.serverId
? "lg:grid-cols-8"
: "lg:grid-cols-9",
? "lg:grid-cols-7"
: "lg:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
@@ -230,7 +229,6 @@ const Service = (
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="schedules">Schedules</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
@@ -255,12 +253,6 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="schedules">
<div className="flex flex-col gap-4 pt-2.5">
<ShowSchedules id={composeId} scheduleType="compose" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">
<div className="flex flex-col border rounded-lg ">
@@ -331,20 +323,15 @@ const Service = (
</div>
</TabsContent>
<TabsContent value="deployments" className="w-full pt-2.5">
<div className="flex flex-col gap-4 border rounded-lg">
<ShowDeployments
id={composeId}
type="compose"
serverId={data?.serverId || ""}
refreshToken={data?.refreshToken || ""}
/>
<TabsContent value="deployments">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDeploymentsCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="domains">
<div className="flex flex-col gap-4 pt-2.5">
<ShowDomains id={composeId} type="compose" />
<ShowDomainsCompose composeId={composeId} />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -1,54 +0,0 @@
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import type { ReactElement } from "react";
import type { GetServerSidePropsContext } from "next";
import { validateRequest } from "@dokploy/server/lib/auth";
import { IS_CLOUD } from "@dokploy/server/constants";
import { api } from "@/utils/api";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { Card } from "@/components/ui/card";
function SchedulesPage() {
const { data: user } = api.user.get.useQuery();
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">
<ShowSchedules
scheduleType="dokploy-server"
id={user?.user.id || ""}
/>
</div>
</Card>
</div>
);
}
export default SchedulesPage;
SchedulesPage.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user } = await validateRequest(ctx.req);
if (!user || user.role !== "owner") {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
return {
props: {},
};
}

View File

@@ -1,5 +1,6 @@
import { WebDomain } from "@/components/dashboard/settings/web-domain";
import { WebServer } from "@/components/dashboard/settings/web-server";
import { EnablePaidFeatures } from "@/components/dashboard/settings/enable-paid-features";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { appRouter } from "@/server/api/root";
import { getLocale, serverSideTranslations } from "@/utils/i18n";
@@ -12,6 +13,7 @@ import { api } from "@/utils/api";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { Card } from "@/components/ui/card";
const Page = () => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: user } = api.user.get.useQuery();
return (
<div className="w-full">
@@ -27,6 +29,7 @@ const Page = () => {
/>
</Card>
</div>
{!isCloud && <EnablePaidFeatures />}
</div>
</div>
);

View File

@@ -1,5 +1,4 @@
import { createTRPCRouter } from "../api/trpc";
import { adminRouter } from "./routers/admin";
import { aiRouter } from "./routers/ai";
import { applicationRouter } from "./routers/application";
import { backupRouter } from "./routers/backup";
@@ -35,7 +34,6 @@ import { sshRouter } from "./routers/ssh-key";
import { stripeRouter } from "./routers/stripe";
import { swarmRouter } from "./routers/swarm";
import { userRouter } from "./routers/user";
import { scheduleRouter } from "./routers/schedule";
/**
* This is the primary router for your server.
*
@@ -43,7 +41,6 @@ import { scheduleRouter } from "./routers/schedule";
*/
export const appRouter = createTRPCRouter({
admin: adminRouter,
docker: dockerRouter,
project: projectRouter,
application: applicationRouter,
@@ -79,7 +76,6 @@ export const appRouter = createTRPCRouter({
swarm: swarmRouter,
ai: aiRouter,
organization: organizationRouter,
schedule: scheduleRouter,
});
// export type definition of API

View File

@@ -1,61 +0,0 @@
import { apiUpdateWebServerMonitoring } from "@/server/db/schema";
import {
IS_CLOUD,
findUserById,
setupWebMonitoring,
updateUser,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { adminProcedure, createTRPCRouter } from "../trpc";
export const adminRouter = createTRPCRouter({
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input, ctx }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.ownerId);
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateUser(user.id, {
metricsConfig: {
server: {
type: "Dokploy",
refreshRate: input.metricsConfig.server.refreshRate,
port: input.metricsConfig.server.port,
token: input.metricsConfig.server.token,
cronJob: input.metricsConfig.server.cronJob,
urlCallback: input.metricsConfig.server.urlCallback,
retentionDays: input.metricsConfig.server.retentionDays,
thresholds: {
cpu: input.metricsConfig.server.thresholds.cpu,
memory: input.metricsConfig.server.thresholds.memory,
},
},
containers: {
refreshRate: input.metricsConfig.containers.refreshRate,
services: {
include: input.metricsConfig.containers.services.include || [],
exclude: input.metricsConfig.containers.services.exclude || [],
},
},
},
});
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
} catch (error) {
throw error;
}
}),
});

View File

@@ -163,7 +163,7 @@ export const aiRouter = createTRPCRouter({
deploy: protectedProcedure
.input(deploySuggestionSchema)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.session.activeOrganizationId,
input.projectId,
@@ -216,7 +216,7 @@ export const aiRouter = createTRPCRouter({
}
}
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.session.activeOrganizationId,
ctx.user.ownerId,

View File

@@ -62,7 +62,7 @@ export const applicationRouter = createTRPCRouter({
.input(apiCreateApplication)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -87,7 +87,7 @@ export const applicationRouter = createTRPCRouter({
}
const newApplication = await createApplication(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newApplication.applicationId,
@@ -109,7 +109,7 @@ export const applicationRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneApplication)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,
@@ -168,7 +168,7 @@ export const applicationRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiFindOneApplication)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.applicationId,

View File

@@ -3,7 +3,6 @@ import {
apiCreateBackup,
apiFindOneBackup,
apiRemoveBackup,
apiRestoreBackup,
apiUpdateBackup,
} from "@/server/db/schema";
import { removeJob, schedule, updateJob } from "@/server/utils/backup";
@@ -367,7 +366,23 @@ export const backupRouter = createTRPCRouter({
override: true,
},
})
.input(apiRestoreBackup)
.input(
z.object({
databaseId: z.string(),
databaseType: z.enum([
"postgres",
"mysql",
"mariadb",
"mongo",
"web-server",
]),
backupType: z.enum(["database", "compose"]),
databaseName: z.string().min(1),
backupFile: z.string().min(1),
destinationId: z.string().min(1),
metadata: z.any(),
}),
)
.subscription(async ({ input }) => {
const destination = await findDestinationById(input.destinationId);
if (input.backupType === "database") {
@@ -375,33 +390,57 @@ export const backupRouter = createTRPCRouter({
const postgres = await findPostgresById(input.databaseId);
return observable<string>((emit) => {
restorePostgresBackup(postgres, destination, input, (log) => {
emit.next(log);
});
restorePostgresBackup(
postgres,
destination,
input.databaseName,
input.backupFile,
(log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "mysql") {
const mysql = await findMySqlById(input.databaseId);
return observable<string>((emit) => {
restoreMySqlBackup(mysql, destination, input, (log) => {
emit.next(log);
});
restoreMySqlBackup(
mysql,
destination,
input.databaseName,
input.backupFile,
(log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "mariadb") {
const mariadb = await findMariadbById(input.databaseId);
return observable<string>((emit) => {
restoreMariadbBackup(mariadb, destination, input, (log) => {
emit.next(log);
});
restoreMariadbBackup(
mariadb,
destination,
input.databaseName,
input.backupFile,
(log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "mongo") {
const mongo = await findMongoById(input.databaseId);
return observable<string>((emit) => {
restoreMongoBackup(mongo, destination, input, (log) => {
emit.next(log);
});
restoreMongoBackup(
mongo,
destination,
input.databaseName,
input.backupFile,
(log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "web-server") {
@@ -415,9 +454,16 @@ export const backupRouter = createTRPCRouter({
if (input.backupType === "compose") {
const compose = await findComposeById(input.databaseId);
return observable<string>((emit) => {
restoreComposeBackup(compose, destination, input, (log) => {
emit.next(log);
});
restoreComposeBackup(
compose,
destination,
input.databaseName,
input.backupFile,
input.metadata,
(log) => {
emit.next(log);
},
);
});
}
return true;

View File

@@ -62,7 +62,7 @@ export const composeRouter = createTRPCRouter({
.input(apiCreateCompose)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -86,7 +86,7 @@ export const composeRouter = createTRPCRouter({
}
const newService = await createCompose(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newService.composeId,
@@ -103,7 +103,7 @@ export const composeRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindCompose)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -137,7 +137,7 @@ export const composeRouter = createTRPCRouter({
delete: protectedProcedure
.input(apiDeleteCompose)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.composeId,
@@ -408,7 +408,7 @@ export const composeRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -455,7 +455,7 @@ export const composeRouter = createTRPCRouter({
isolatedDeployment: true,
});
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
compose.composeId,

View File

@@ -2,8 +2,6 @@ import {
apiFindAllByApplication,
apiFindAllByCompose,
apiFindAllByServer,
apiFindAllByType,
deployments,
} from "@/server/db/schema";
import {
findAllDeploymentsByApplicationId,
@@ -14,9 +12,7 @@ import {
findServerById,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { db } from "@/server/db";
export const deploymentRouter = createTRPCRouter({
all: protectedProcedure
@@ -58,14 +54,4 @@ export const deploymentRouter = createTRPCRouter({
}
return await findAllDeploymentsByServerId(input.serverId);
}),
allByType: protectedProcedure
.input(apiFindAllByType)
.query(async ({ input }) => {
const deploymentsList = await db.query.deployments.findMany({
where: eq(deployments[`${input.type}Id`], input.id),
orderBy: desc(deployments.createdAt),
});
return deploymentsList;
}),
});

View File

@@ -57,10 +57,7 @@ export const domainRouter = createTRPCRouter({
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error creating the domain",
message: "Error creating the domain",
cause: error,
});
}

View File

@@ -41,7 +41,7 @@ export const mariadbRouter = createTRPCRouter({
.input(apiCreateMariaDB)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mariadbRouter = createTRPCRouter({
});
}
const newMariadb = await createMariadb(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newMariadb.mariadbId,
@@ -92,7 +92,7 @@ export const mariadbRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMariaDB)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,
@@ -219,7 +219,7 @@ export const mariadbRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMariaDB)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.mariadbId,

View File

@@ -41,7 +41,7 @@ export const mongoRouter = createTRPCRouter({
.input(apiCreateMongo)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const mongoRouter = createTRPCRouter({
});
}
const newMongo = await createMongo(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newMongo.mongoId,
@@ -96,7 +96,7 @@ export const mongoRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMongo)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.mongoId,
@@ -261,7 +261,7 @@ export const mongoRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMongo)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.mongoId,

View File

@@ -44,7 +44,7 @@ export const mysqlRouter = createTRPCRouter({
.input(apiCreateMySql)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -69,7 +69,7 @@ export const mysqlRouter = createTRPCRouter({
}
const newMysql = await createMysql(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newMysql.mysqlId,
@@ -100,7 +100,7 @@ export const mysqlRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneMySql)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,
@@ -260,7 +260,7 @@ export const mysqlRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneMySql)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.mysqlId,

View File

@@ -15,7 +15,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can create an organization",
@@ -86,7 +86,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can update it",
@@ -109,7 +109,7 @@ export const organizationRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
if (ctx.user.role !== "owner" && !IS_CLOUD) {
if (ctx.user.rol !== "owner" && !IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the organization owner can delete it",

View File

@@ -41,7 +41,7 @@ export const postgresRouter = createTRPCRouter({
.input(apiCreatePostgres)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const postgresRouter = createTRPCRouter({
});
}
const newPostgres = await createPostgres(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newPostgres.postgresId,
@@ -96,7 +96,7 @@ export const postgresRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOnePostgres)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.postgresId,
@@ -244,7 +244,7 @@ export const postgresRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOnePostgres)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.postgresId,

View File

@@ -57,7 +57,7 @@ export const projectRouter = createTRPCRouter({
.input(apiCreateProject)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -78,7 +78,7 @@ export const projectRouter = createTRPCRouter({
input,
ctx.session.activeOrganizationId,
);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewProject(
ctx.user.id,
project.projectId,
@@ -99,7 +99,7 @@ export const projectRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneProject)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
const { accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -118,15 +118,15 @@ export const projectRouter = createTRPCRouter({
eq(projects.organizationId, ctx.session.activeOrganizationId),
),
with: {
compose: {
where: buildServiceFilter(compose.composeId, accessedServices),
},
applications: {
where: buildServiceFilter(
applications.applicationId,
accessedServices,
),
},
compose: {
where: buildServiceFilter(compose.composeId, accessedServices),
},
mariadb: {
where: buildServiceFilter(mariadb.mariadbId, accessedServices),
},
@@ -164,7 +164,8 @@ export const projectRouter = createTRPCRouter({
return project;
}),
all: protectedProcedure.query(async ({ ctx }) => {
if (ctx.user.role === "member") {
// console.log(ctx.user);
if (ctx.user.rol === "member") {
const { accessedProjects, accessedServices } = await findMemberById(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -174,7 +175,7 @@ export const projectRouter = createTRPCRouter({
return [];
}
return await db.query.projects.findMany({
const query = await db.query.projects.findMany({
where: and(
sql`${projects.projectId} IN (${sql.join(
accessedProjects.map((projectId) => sql`${projectId}`),
@@ -212,6 +213,8 @@ export const projectRouter = createTRPCRouter({
},
orderBy: desc(projects.createdAt),
});
return query;
}
return await db.query.projects.findMany({
@@ -241,7 +244,7 @@ export const projectRouter = createTRPCRouter({
.input(apiRemoveProject)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkProjectAccess(
ctx.user.id,
"delete",
@@ -313,7 +316,7 @@ export const projectRouter = createTRPCRouter({
)
.mutation(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkProjectAccess(
ctx.user.id,
"create",
@@ -578,7 +581,7 @@ export const projectRouter = createTRPCRouter({
}
}
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewProject(
ctx.user.id,
newProject.projectId,
@@ -601,10 +604,10 @@ function buildServiceFilter(
fieldName: AnyPgColumn,
accessedServices: string[],
) {
return accessedServices.length === 0
? sql`false`
: sql`${fieldName} IN (${sql.join(
return accessedServices.length > 0
? sql`${fieldName} IN (${sql.join(
accessedServices.map((serviceId) => sql`${serviceId}`),
sql`, `,
)})`;
)})`
: sql`1 = 0`;
}

View File

@@ -41,7 +41,7 @@ export const redisRouter = createTRPCRouter({
.input(apiCreateRedis)
.mutation(async ({ input, ctx }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.projectId,
@@ -65,7 +65,7 @@ export const redisRouter = createTRPCRouter({
});
}
const newRedis = await createRedis(input);
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await addNewService(
ctx.user.id,
newRedis.redisId,
@@ -89,7 +89,7 @@ export const redisRouter = createTRPCRouter({
one: protectedProcedure
.input(apiFindOneRedis)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.redisId,
@@ -251,7 +251,7 @@ export const redisRouter = createTRPCRouter({
remove: protectedProcedure
.input(apiFindOneRedis)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
await checkServiceAccess(
ctx.user.id,
input.redisId,

View File

@@ -1,142 +0,0 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod";
import {
createScheduleSchema,
schedules,
updateScheduleSchema,
} from "@dokploy/server/db/schema/schedule";
import { desc, eq } from "drizzle-orm";
import { db } from "@dokploy/server/db";
import { createTRPCRouter, protectedProcedure } from "../trpc";
import { runCommand } from "@dokploy/server/index";
import { deployments } from "@dokploy/server/db/schema/deployment";
import {
deleteSchedule,
findScheduleById,
createSchedule,
updateSchedule,
} from "@dokploy/server/services/schedule";
import { IS_CLOUD, scheduleJob } from "@dokploy/server";
import { removeJob, schedule } from "@/server/utils/backup";
import { removeScheduleJob } from "@dokploy/server";
export const scheduleRouter = createTRPCRouter({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ input }) => {
const newSchedule = await createSchedule(input);
if (newSchedule?.enabled) {
if (IS_CLOUD) {
schedule({
scheduleId: newSchedule.scheduleId,
type: "schedule",
cronSchedule: newSchedule.cronExpression,
});
} else {
scheduleJob(newSchedule);
}
}
return newSchedule;
}),
update: protectedProcedure
.input(updateScheduleSchema)
.mutation(async ({ input }) => {
const updatedSchedule = await updateSchedule(input);
if (IS_CLOUD) {
if (updatedSchedule?.enabled) {
schedule({
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
cronSchedule: updatedSchedule.cronExpression,
});
} else {
await removeJob({
cronSchedule: updatedSchedule.cronExpression,
scheduleId: updatedSchedule.scheduleId,
type: "schedule",
});
}
} else {
if (updatedSchedule?.enabled) {
removeScheduleJob(updatedSchedule.scheduleId);
scheduleJob(updatedSchedule);
} else {
removeScheduleJob(updatedSchedule.scheduleId);
}
}
return updatedSchedule;
}),
delete: protectedProcedure
.input(z.object({ scheduleId: z.string() }))
.mutation(async ({ input }) => {
const schedule = await findScheduleById(input.scheduleId);
await deleteSchedule(input.scheduleId);
if (IS_CLOUD) {
await removeJob({
cronSchedule: schedule.cronExpression,
scheduleId: schedule.scheduleId,
type: "schedule",
});
} else {
removeScheduleJob(schedule.scheduleId);
}
return true;
}),
list: protectedProcedure
.input(
z.object({
id: z.string(),
scheduleType: z.enum([
"application",
"compose",
"server",
"dokploy-server",
]),
}),
)
.query(async ({ input }) => {
const where = {
application: eq(schedules.applicationId, input.id),
compose: eq(schedules.composeId, input.id),
server: eq(schedules.serverId, input.id),
"dokploy-server": eq(schedules.userId, input.id),
};
return db.query.schedules.findMany({
where: where[input.scheduleType],
with: {
application: true,
server: true,
compose: true,
deployments: {
orderBy: [desc(deployments.createdAt)],
},
},
});
}),
one: protectedProcedure
.input(z.object({ scheduleId: z.string() }))
.query(async ({ input }) => {
return await findScheduleById(input.scheduleId);
}),
runManually: protectedProcedure
.input(z.object({ scheduleId: z.string().min(1) }))
.mutation(async ({ input }) => {
try {
await runCommand(input.scheduleId);
return true;
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message:
error instanceof Error ? error.message : "Error running schedule",
});
}
}),
});

View File

@@ -407,7 +407,7 @@ export const settingsRouter = createTRPCRouter({
.input(apiServerSchema)
.query(async ({ ctx, input }) => {
try {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
const canAccess = await canAccessToTraefikFiles(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -428,7 +428,7 @@ export const settingsRouter = createTRPCRouter({
updateTraefikFile: protectedProcedure
.input(apiModifyTraefikConfig)
.mutation(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
const canAccess = await canAccessToTraefikFiles(
ctx.user.id,
ctx.session.activeOrganizationId,
@@ -449,7 +449,7 @@ export const settingsRouter = createTRPCRouter({
readTraefikFile: protectedProcedure
.input(apiReadTraefikConfig)
.query(async ({ input, ctx }) => {
if (ctx.user.role === "member") {
if (ctx.user.rol === "member") {
const canAccess = await canAccessToTraefikFiles(
ctx.user.id,
ctx.session.activeOrganizationId,

View File

@@ -5,6 +5,7 @@ import {
findUserById,
getUserByToken,
removeUserById,
setupWebMonitoring,
updateUser,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -13,6 +14,7 @@ import {
apiAssignPermissions,
apiFindOneToken,
apiUpdateUser,
apiUpdateWebServerMonitoring,
apikey,
invitation,
member,
@@ -27,7 +29,11 @@ import {
protectedProcedure,
publicProcedure,
} from "../trpc";
import {
validateLicense,
activateLicense,
deactivateLicense,
} from "@/server/utils/validate-license";
const apiCreateApiKey = z.object({
name: z.string().min(1),
prefix: z.string().optional(),
@@ -91,18 +97,6 @@ export const userRouter = createTRPCRouter({
return memberResult;
}),
haveRootAccess: protectedProcedure.query(async ({ ctx }) => {
if (!IS_CLOUD) {
return false;
}
if (
process.env.USER_ADMIN_ID === ctx.user.id ||
ctx.session?.impersonatedBy === process.env.USER_ADMIN_ID
) {
return true;
}
return false;
}),
getBackups: adminProcedure.query(async ({ ctx }) => {
const memberResult = await db.query.member.findFirst({
where: and(
@@ -115,7 +109,6 @@ export const userRouter = createTRPCRouter({
backups: {
with: {
destination: true,
deployments: true,
},
},
apiKeys: true,
@@ -173,6 +166,73 @@ export const userRouter = createTRPCRouter({
}
return await updateUser(ctx.user.id, input);
}),
saveLicense: adminProcedure
.input(
z.object({
licenseKey: z.string(),
}),
)
.mutation(async ({ input, ctx }) => {
const owner = await findUserById(ctx.user.ownerId);
const result = await activateLicense(
input.licenseKey,
owner?.serverIp || "",
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error,
});
}
await updateUser(ctx.user.id, {
licenseKey: input.licenseKey,
});
return result;
}),
deactivateLicense: adminProcedure
.input(z.object({ licenseKey: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const owner = await findUserById(ctx.user.ownerId);
const result = await deactivateLicense(
input.licenseKey,
owner?.serverIp || "",
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error,
});
}
await updateUser(ctx.user.id, {
licenseKey: null,
});
return result;
}),
validateLicense: adminProcedure
.input(z.object({ licenseKey: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const owner = await findUserById(ctx.user.ownerId);
const result = await validateLicense(
input.licenseKey,
owner?.serverIp || "",
);
if (!result.success) {
throw new TRPCError({
code: "BAD_REQUEST",
message: result.error,
});
}
return result;
}),
getUserByToken: publicProcedure
.input(apiFindOneToken)
.query(async ({ input }) => {
@@ -362,4 +422,62 @@ export const userRouter = createTRPCRouter({
return organizations.length;
}),
setupMonitoring: adminProcedure
.input(apiUpdateWebServerMonitoring)
.mutation(async ({ input, ctx }) => {
try {
if (IS_CLOUD) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Feature disabled on cloud",
});
}
const user = await findUserById(ctx.user.id);
if (!validateLicense(user?.licenseKey || "", user?.serverIp || "")) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Invalid license key",
});
}
if (user.id !== ctx.user.ownerId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to setup the monitoring",
});
}
await updateUser(user.id, {
metricsConfig: {
server: {
type: "Dokploy",
refreshRate: input.metricsConfig.server.refreshRate,
port: input.metricsConfig.server.port,
token: input.metricsConfig.server.token,
cronJob: input.metricsConfig.server.cronJob,
urlCallback: input.metricsConfig.server.urlCallback,
retentionDays: input.metricsConfig.server.retentionDays,
thresholds: {
cpu: input.metricsConfig.server.thresholds.cpu,
memory: input.metricsConfig.server.thresholds.memory,
},
},
containers: {
refreshRate: input.metricsConfig.containers.refreshRate,
services: {
include: input.metricsConfig.containers.services.include || [],
exclude: input.metricsConfig.containers.services.exclude || [],
},
},
},
});
const currentServer = await setupWebMonitoring(user.id);
return currentServer;
} catch (error) {
throw error;
}
}),
});

View File

@@ -30,10 +30,8 @@ import { ZodError } from "zod";
*/
interface CreateContextOptions {
user: (User & { role: "member" | "admin" | "owner"; ownerId: string }) | null;
session:
| (Session & { activeOrganizationId: string; impersonatedBy?: string })
| null;
user: (User & { rol: "member" | "admin" | "owner"; ownerId: string }) | null;
session: (Session & { activeOrganizationId: string }) | null;
req: CreateNextContextOptions["req"];
res: CreateNextContextOptions["res"];
}
@@ -85,7 +83,7 @@ export const createTRPCContext = async (opts: CreateNextContextOptions) => {
? {
...user,
email: user.email,
role: user.role as "owner" | "member" | "admin",
rol: user.role as "owner" | "member" | "admin",
id: user.id,
ownerId: user.ownerId,
}
@@ -182,7 +180,7 @@ export const uploadProcedure = async (opts: any) => {
};
export const cliProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
if (!ctx.session || !ctx.user || ctx.user.rol !== "owner") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({
@@ -196,7 +194,7 @@ export const cliProcedure = t.procedure.use(({ ctx, next }) => {
});
export const adminProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.session || !ctx.user || ctx.user.role !== "owner") {
if (!ctx.session || !ctx.user || ctx.user.rol !== "owner") {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({

View File

@@ -6,7 +6,6 @@ import {
createDefaultServerTraefikConfig,
createDefaultTraefikConfig,
initCronJobs,
initSchedules,
initializeNetwork,
sendDokployRestartNotifications,
setupDirectories,
@@ -50,7 +49,6 @@ void app.prepare().then(async () => {
createDefaultServerTraefikConfig();
await migration();
await initCronJobs();
await initSchedules();
await sendDokployRestartNotifications();
}

View File

@@ -14,11 +14,6 @@ type QueueJob =
type: "server";
cronSchedule: string;
serverId: string;
}
| {
type: "schedule";
cronSchedule: string;
scheduleId: string;
};
export const schedule = async (job: QueueJob) => {
try {

View File

@@ -0,0 +1,47 @@
const licensesUrl = process.env.LICENSES_URL || "http://localhost:4002";
export const validateLicense = async (licenseKey: string, serverIp: string) => {
const response = await fetch(`${licensesUrl}/api/license/validate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, serverIp }),
});
const data = await response.json();
if (!response.ok && data.error?.issues) {
console.log("Validation errors:", data.error.issues);
}
return data;
};
export const activateLicense = async (licenseKey: string, serverIp: string) => {
const response = await fetch(`${licensesUrl}/api/license/activate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, serverIp }),
});
const data = await response.json();
return data;
};
export const deactivateLicense = async (
licenseKey: string,
serverIp: string,
) => {
const response = await fetch(`${licensesUrl}/api/license/deactivate`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ licenseKey, serverIp }),
});
const data = await response.json();
return data;
};

View File

@@ -1,15 +0,0 @@
import { useEffect, useState } from "react";
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}

View File

@@ -0,0 +1,2 @@
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""

28
apps/licenses/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf
# deps
node_modules/
# env
.env
.env.production
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# misc
.DS_Store

8
apps/licenses/README.md Normal file
View File

@@ -0,0 +1,8 @@
```
npm install
npm run dev
```
```
open http://localhost:4002/api/health
```

View File

@@ -0,0 +1,15 @@
import type { Config } from "drizzle-kit";
import "dotenv/config";
const connectionString = process.env.DATABASE_URL!;
export default {
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: {
url: connectionString,
},
verbose: true,
strict: true,
} satisfies Config;

View File

@@ -0,0 +1,28 @@
CREATE TABLE "licenses" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"product_id" text NOT NULL,
"license_key" text NOT NULL,
"server_ips" text[],
"activated_at" timestamp,
"last_verified_at" timestamp,
"stripeCustomerId" text NOT NULL,
"stripeSubscriptionId" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"metadata" text,
"user_id" uuid,
CONSTRAINT "licenses_license_key_unique" UNIQUE("license_key")
);
--> statement-breakpoint
CREATE TABLE "user" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"updated_at" timestamp DEFAULT CURRENT_TIMESTAMP,
"otp_code" text,
"otp_code_expires_at" timestamp,
"temporal_id" uuid DEFAULT gen_random_uuid(),
CONSTRAINT "user_email_unique" UNIQUE("email")
);
--> statement-breakpoint
ALTER TABLE "licenses" ADD CONSTRAINT "licenses_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,196 @@
{
"id": "553c7c08-f9c6-4090-8372-8d27a389eaa7",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.licenses": {
"name": "licenses",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"product_id": {
"name": "product_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"license_key": {
"name": "license_key",
"type": "text",
"primaryKey": false,
"notNull": true
},
"server_ips": {
"name": "server_ips",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"activated_at": {
"name": "activated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"last_verified_at": {
"name": "last_verified_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"stripeCustomerId": {
"name": "stripeCustomerId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"stripeSubscriptionId": {
"name": "stripeSubscriptionId",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"metadata": {
"name": "metadata",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"licenses_user_id_user_id_fk": {
"name": "licenses_user_id_user_id_fk",
"tableFrom": "licenses",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "no action",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"licenses_license_key_unique": {
"name": "licenses_license_key_unique",
"nullsNotDistinct": false,
"columns": [
"license_key"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"default": "CURRENT_TIMESTAMP"
},
"otp_code": {
"name": "otp_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"otp_code_expires_at": {
"name": "otp_code_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"temporal_id": {
"name": "temporal_id",
"type": "uuid",
"primaryKey": false,
"notNull": false,
"default": "gen_random_uuid()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1742773711509,
"tag": "0000_famous_vermin",
"breakpoints": true
}
]
}

22
apps/licenses/migrate.ts Normal file
View File

@@ -0,0 +1,22 @@
import { drizzle } from "drizzle-orm/postgres-js";
import { migrate } from "drizzle-orm/postgres-js/migrator";
import postgres from "postgres";
import "dotenv/config";
import * as schema from "./src/schema";
const connectionString = process.env.DATABASE_URL!;
const sql = postgres(connectionString, { max: 1 });
const db = drizzle(sql, { schema });
await migrate(db, { migrationsFolder: "drizzle" })
.then(() => {
console.log("Migration complete");
sql.end();
})
.catch((error) => {
console.error("Migration failed", error);
process.exit(1);
})
.finally(() => {
sql.end();
});

View File

@@ -0,0 +1,48 @@
{
"name": "@dokploy/licenses",
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "PORT=4002 tsx watch -r dotenv/config src/index.ts",
"build": "tsc --project tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"generate": "drizzle-kit generate",
"drop": "drizzle-kit drop",
"push": "drizzle-kit push",
"migrate": "tsx ./migrate.ts",
"truncate": "tsx ./truncate.ts",
"reset:all": "tsx ./truncate.ts && tsx ./migrate.ts",
"studio": "drizzle-kit studio"
},
"dependencies": {
"nanoid": "5.1.5",
"@react-email/components": "^0.0.21",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",
"@react-email/render": "^1.0.5",
"@types/pg": "^8.11.11",
"dotenv": "^16.3.1",
"drizzle-orm": "^0.39.1",
"hono": "^4.5.8",
"nodemailer": "6.9.14",
"pg": "^8.14.1",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"postgres": "3.4.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"stripe": "17.2.0",
"zod": "^3.23.4"
},
"devDependencies": {
"@types/node": "^20.11.17",
"@types/nodemailer": "^6.4.16",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"drizzle-kit": "^0.30.4",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
},
"packageManager": "pnpm@9.5.0"
}

View File

@@ -0,0 +1,265 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { z } from "zod";
import {
activateLicense,
deactivateLicense,
validateLicense,
cleanLicense,
} from "../utils/license";
import { logger } from "../logger";
import { eq, desc } from "drizzle-orm";
import { users, licenses } from "../schema";
import { db } from "../db";
import { transporter } from "../email";
import { nanoid } from "nanoid";
import { stripe } from "../stripe";
import type Stripe from "stripe";
import { getLicenseTypeFromPriceId } from "../utils";
const validateSchema = z.object({
licenseKey: z.string(),
serverIp: z.string(),
});
export const licenseRouter = new Hono();
licenseRouter.post(
"/validate",
zValidator("json", validateSchema),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const result = await validateLicense(licenseKey, serverIp);
return c.json(result);
} catch (error) {
logger.error("Error validating license:", { error });
return c.json({ success: false, error: "Error validating license" }, 500);
}
},
);
licenseRouter.post(
"/activate",
zValidator("json", validateSchema),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await activateLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error activating license:", error);
if (error instanceof Error) {
return c.json({ success: false, error: error.message }, 400);
}
return c.json({ success: false, error: "Unknown error occurred" }, 400);
}
},
);
licenseRouter.post(
"/deactivate",
zValidator("json", validateSchema),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await deactivateLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error deactivating license:", error);
return c.json(
{ success: false, error: "Error deactivating license" },
500,
);
}
},
);
licenseRouter.post(
"/remove-server",
zValidator(
"json",
z.object({ licenseKey: z.string().min(1), serverIp: z.string().min(1) }),
),
async (c) => {
const { licenseKey, serverIp } = c.req.valid("json");
try {
const license = await cleanLicense(licenseKey, serverIp);
return c.json({ success: true, license });
} catch (error) {
logger.error("Error cleaning license:", error);
return c.json({ success: false, error: "Error cleaning license" }, 500);
}
},
);
// router.post("/resend-license", zValidator("json", resendSchema), async (c) => {
// const { licenseKey } = c.req.valid("json");
// try {
// const license = await db.query.licenses.findFirst({
// where: eq(licenses.licenseKey, licenseKey),
// });
// if (!license) {
// return c.json({ success: false, error: "License not found" }, 404);
// }
// const suscription = await stripe.subscriptions.retrieve(
// license.stripeSubscriptionId,
// );
// const priceId = suscription.items.data[0].price.id;
// const { type } = getLicenseTypeFromPriceId(priceId);
// const emailHtml = await render(
// ResendLicenseEmail({
// licenseKey: license.licenseKey,
// productName: `Dokploy Self Hosted ${type}`,
// requestDate: new Date(),
// customerName: license.email,
// }),
// );
// await transporter.sendMail({
// from: process.env.SMTP_FROM_ADDRESS,
// to: license.email,
// subject: "Your Dokploy License Key",
// html: emailHtml,
// });
// return c.json({ success: true });
// } catch (error) {
// logger.error("Error resending license:", error);
// return c.json({ success: false, error: "Error resending license" }, 500);
// }
// });
licenseRouter.post(
"/send-otp",
zValidator("json", z.object({ email: z.string().email() })),
async (c) => {
const { email } = c.req.valid("json");
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
});
if (!user) {
return c.json({ success: false, error: "User not found" }, 404);
}
const generateOtpCode = Math.floor(100000 + Math.random() * 900000);
const otpCodeExpiresAt = new Date(Date.now() + 10 * 60 * 1000);
await db
.update(users)
.set({ otpCode: generateOtpCode.toString(), otpCodeExpiresAt })
.where(eq(users.id, user.id));
await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS,
to: user.email,
subject: "Your Dokploy License Key ",
html: `Your OTP code is ${generateOtpCode}, it will expire in 10 minutes`,
});
return c.json({ success: true });
},
);
licenseRouter.post(
"/verify-otp",
zValidator(
"json",
z.object({ email: z.string().email(), otpCode: z.string().length(6) }),
),
async (c) => {
const { email, otpCode } = c.req.valid("json");
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
});
if (!user) {
return c.json({ success: false, error: "User not found" }, 404);
}
if (user.otpCode !== otpCode) {
return c.json({ success: false, error: "Invalid code" }, 400);
}
if (user.otpCodeExpiresAt && user.otpCodeExpiresAt < new Date()) {
return c.json({ success: false, error: "Code expired" }, 400);
}
const result = await db
.update(users)
.set({
otpCode: null,
otpCodeExpiresAt: null,
temporalId: nanoid(),
temporalIdExpiresAt: new Date(Date.now() + 20 * 60 * 1000),
})
.where(eq(users.id, user.id))
.returning();
return c.json({ success: true, temporalId: result[0].temporalId });
},
);
licenseRouter.get(
"/all",
zValidator("query", z.object({ temporalId: z.string() })),
async (c) => {
const { temporalId } = c.req.valid("query");
const user = await db.query.users.findFirst({
where: eq(users.temporalId, temporalId),
with: {
licenses: true,
},
orderBy: desc(licenses.createdAt),
});
if (!user) {
return c.json({ success: false, error: "User not found" }, 404);
}
if (user.temporalIdExpiresAt && user.temporalIdExpiresAt < new Date()) {
return c.json({ success: false, error: "Session expired" }, 400);
}
const suscriptions: Stripe.Subscription[] = [];
for (const license of user.licenses) {
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
suscriptions.push(suscription);
}
const formated = user.licenses.map((license) => {
const suscription = suscriptions.find(
(suscription) => suscription.id === license.stripeSubscriptionId,
);
const { type } = getLicenseTypeFromPriceId(
suscription?.items.data[0].price.id || "",
);
return {
license: license,
stripeSuscription: {
quantity: suscription?.items.data[0].quantity,
billingType: suscription?.items.data[0].price.recurring?.interval,
type: type,
},
};
});
return c.json({ success: true, licenses: formated });
},
);

View File

@@ -0,0 +1,220 @@
import { zValidator } from "@hono/zod-validator";
import { Hono } from "hono";
import { createCheckoutSessionSchema } from "../validators/stripe";
import { getStripeItems } from "../utils/license";
import { stripe } from "../stripe";
import { WEBSITE_URL } from "../constants";
import { logger } from "../logger";
import { eq } from "drizzle-orm";
import { licenses } from "../schema";
import { db } from "../db";
import { getLicenseFeatures, getLicenseTypeFromPriceId } from "../utils";
import { z } from "zod";
import type Stripe from "stripe";
import { createLicense } from "../utils/license";
import { render } from "@react-email/render";
import { LicenseEmail } from "../../templates/emails/license-email";
import { transporter } from "../email";
export const stripeRouter = new Hono();
stripeRouter.post(
"/create-checkout-session",
zValidator("json", createCheckoutSessionSchema),
async (c) => {
const { type, serverQuantity, isAnnual } = c.req.valid("json");
const items = getStripeItems(type, serverQuantity, isAnnual);
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: items,
allow_promotion_codes: true,
success_url: `${WEBSITE_URL}/license/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${WEBSITE_URL}#pricing`,
});
return c.json({ sessionId: session.id });
},
);
stripeRouter.get(
"/get-license-from-session",
zValidator("query", z.object({ sessionId: z.string().min(1) })),
async (c) => {
const { sessionId } = c.req.valid("query");
console.log("Session ID", sessionId);
if (!sessionId) {
return c.json({ error: "Session ID is required" }, 400);
}
try {
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.status !== "complete") {
return c.json({ error: "Session is not complete" }, 400);
}
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
const license = await db.query.licenses.findFirst({
where: eq(licenses.stripeSubscriptionId, subscription.id),
});
const priceId = subscription.items.data[0].price.id;
const { type, billingType } = getLicenseTypeFromPriceId(priceId);
return c.json({ type, billingType, key: license?.licenseKey });
} catch (error) {
logger.error("Error retrieving session:", error);
return c.json({ error: "Error retrieving session" }, 400);
}
},
);
stripeRouter.post("/webhook", async (c) => {
const rawBody = await c.req.raw.text();
const sig = c.req.header("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
sig!,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
logger.error("Webhook signature verification failed:", err);
return c.json({ error: "Webhook signature verification failed" }, 400);
}
const allowedEvents = ["invoice.paid"];
if (!allowedEvents.includes(event.type)) {
return c.json({ error: "Event not allowed" }, 400);
}
try {
switch (event.type) {
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
if (!invoice.subscription) break;
if (invoice.billing_reason === "subscription_create") {
const customerResponse = await stripe.customers.retrieve(
invoice.customer as string,
);
if (customerResponse.deleted) {
throw new Error("Customer was deleted");
}
const subscriptionId = invoice.subscription as string;
const subscription =
await stripe.subscriptions.retrieve(subscriptionId);
const priceId = subscription.items.data[0].price.id;
const { type } = getLicenseTypeFromPriceId(priceId);
const { license, user } = await createLicense({
productId: subscriptionId,
email: customerResponse.email!.toLowerCase(),
stripeCustomerId: customerResponse.id,
stripeSubscriptionId: subscriptionId,
});
const features = getLicenseFeatures(type);
const emailHtml = await render(
LicenseEmail({
customerName: customerResponse.name || "Customer",
licenseKey: license.licenseKey,
productName: `Dokploy Self Hosted ${type}`,
features: features,
}),
);
await transporter.sendMail({
from: process.env.SMTP_FROM_ADDRESS,
to: user.email,
subject: "Your Dokploy License Key ",
html: emailHtml,
});
}
break;
}
}
return c.json({ received: true });
} catch (error) {
console.error("Error processing webhook:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 500);
}
return c.json({ error: "Unknown error occurred" }, 500);
}
});
stripeRouter.post(
"/create-customer-portal-session",
zValidator("json", z.object({ customerId: z.string().min(1) })),
async (c) => {
try {
const { customerId } = c.req.valid("json");
const session = await stripe.billingPortal.sessions.create({
customer: customerId,
configuration: process.env.STRIPE_PORTAL_CONFIGURATION_ID,
return_url: `${WEBSITE_URL}/dashboard/settings/billing`,
});
return c.json({ url: session.url });
} catch (error) {
logger.error("Error creating customer portal session:", error);
return c.json({ error: "Error creating customer portal session" }, 500);
}
},
);
// Execute 1 time to create the configuration
// const configuration = await stripe.billingPortal.configurations.create({
// business_profile: {
// headline: "Manage your Dokploy subscription",
// },
// features: {
// subscription_update: {
// enabled: true,
// products: [
// {
// product: "prod_RxSOzNrwlfVWmv",
// prices: [
// "price_1R3XTqF3cxQuHeOzZjgaG262",
// "price_1R3XmmF3cxQuHeOzCEjz0HFz",
// ],
// },
// {
// product: "prod_RxSVDolGpUy97g",
// prices: [
// "price_1R3XanF3cxQuHeOzh7VdbbUs",
// "price_1R3Xp4F3cxQuHeOzUTNYDC9I",
// ],
// },
// {
// product: "prod_RxSaEQpbsiPFgv",
// prices: [
// "price_1R3XfOF3cxQuHeOzYFUa0eNy",
// "price_1R3XptF3cxQuHeOzZEBGMsEm",
// ],
// },
// ],
// default_allowed_updates: ["price", "quantity"],
// },
// subscription_cancel: {
// enabled: true,
// },
// payment_method_update: {
// enabled: true,
// },
// },
// });

View File

@@ -0,0 +1,4 @@
export const WEBSITE_URL =
process.env.NODE_ENV === "development"
? "http://localhost:3001"
: process.env.SITE_URL;

Some files were not shown because too many files have changed in this diff Show More