Compare commits

..

61 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
d5c77fded3 Update Props interface in RestoreBackup and ShowBackups components to make databaseType optional, enhancing flexibility in component usage. 2025-05-01 20:41:06 -06:00
Mauricio Siu
d853b1d326 Enhance server package exports to support ES module and CommonJS formats. Update main entry point and include compose backup utility in utils for improved backup functionality. 2025-05-01 20:37:54 -06:00
Mauricio Siu
d57e347fdc Refactor runJobs and initializeJobs functions to incorporate backupType handling and improve server status checks. Enhance database backup logic for better clarity and maintainability, ensuring only active servers are initialized. 2025-05-01 20:34:16 -06:00
Mauricio Siu
25f3980492 Remove unnecessary console logs from backup and restore compose functions to improve code cleanliness and maintainability. 2025-05-01 20:28:20 -06:00
Mauricio Siu
c8e2f4bfdc Refactor RestoreBackup component to enhance validation schema and improve database type handling. Update metadata requirements for different database types and streamline form initialization. Add alert for compose backups in ShowBackups to inform users about running services. 2025-05-01 19:48:47 -06:00
Mauricio Siu
4afc6ac250 Update HandleBackup component to increase dialog content width for improved user experience during backup creation and updates. 2025-04-30 01:42:51 -06:00
Mauricio Siu
52a660add3 Update validation error messaging in ShowDomainsCompose and refine button styling in ShowBackups for improved UI consistency. 2025-04-30 01:32:38 -06:00
Mauricio Siu
3ad5982f39 Add removeInvitation mutation and UI integration in ShowInvitations component 2025-04-30 01:28:56 -06:00
Mauricio Siu
8ba4ac22cc Enhance domain validation feedback in ShowDomains and AddDomain components. Add descriptive tooltips for container port input and improve validation state messaging to indicate Cloudflare status. Remove unnecessary console logs for cleaner code. 2025-04-30 01:23:54 -06:00
Mauricio Siu
bcebcfdfdf Refactor ShowDomains component to enhance domain validation functionality with real-time feedback and improved UI. Integrate tooltips for domain details and validation status, and update API queries for better data handling. 2025-04-30 01:13:30 -06:00
Mauricio Siu
77b1ec4733 Add DnsHelperModal component for DNS configuration guidance and integrate it into ShowDomainsCompose. Enhance domain validation functionality with server IP checks and improve UI with tooltips for better user experience. 2025-04-30 01:08:08 -06:00
Mauricio Siu
cfae5f7e6c Remove console log from HandleBackup component and update button class for consistent styling. This improves code cleanliness and maintains UI consistency. 2025-04-29 23:56:28 -06:00
Mauricio Siu
0f67e9e222 Refactor HandleBackup component to improve database selection logic. Simplify the initialization of the database field in the form by using a conditional expression for better readability and maintainability. 2025-04-29 22:56:11 -06:00
Mauricio Siu
c4045795ee Refactor ShowBackups component to improve UI and enhance backup information display. Introduce database type icons for better visual representation and reorganize backup details layout for clarity. Update styles for hover effects and button sizes to enhance user experience. 2025-04-29 22:01:30 -06:00
Mauricio Siu
24f3be3c00 Add HandleBackup component to manage backup creation and updates, replacing the deprecated UpdateBackup component. Integrate dynamic form handling for various database types and metadata requirements. Update ShowBackups to utilize HandleBackup for both creating and updating backups, enhancing the user interface for better backup management. 2025-04-29 21:47:55 -06:00
Mauricio Siu
5055994bd3 Enhance RestoreBackup component to support compose backups by adding a database type selection and metadata handling. Update related API routes and schemas to accommodate new backup types, ensuring flexibility for various database configurations. Modify UI components to allow dynamic input for service names and database credentials based on the selected database type. 2025-04-28 02:17:42 -06:00
Mauricio Siu
ddcb22dff9 Implement support for compose backups in runJobs function. Enhance backup handling by checking server status and executing runComposeBackup for compose database types. Update backup structure to include compose details. 2025-04-27 23:12:11 -06:00
Mauricio Siu
77d7dc1f22 Enhance backup functionality by adding support for compose backups in various components. Update ShowBackups to clarify backup update requirements, modify initCronJobs to include compose backups, and improve logging for backup scheduling. Additionally, ensure that the scheduleBackup function retains the latest backups for compose types and document the addition of domains and backups in the Docker compose process. 2025-04-27 23:09:39 -06:00
Mauricio Siu
19bf4f27b6 Update UpdateBackup component to enforce DatabaseType casting for backup databaseType. Modify backups schema to allow optional metadata field, enhancing flexibility for various database types. 2025-04-27 23:06:38 -06:00
Mauricio Siu
6b9765a26c Refactor backup components to utilize a unified DatabaseType type. Update AddBackup and UpdateBackup components to handle database type selection for compose backups. Enhance UI to allow users to select database types dynamically. Update backup schema to include databaseType field for better metadata management. 2025-04-27 23:00:26 -06:00
Mauricio Siu
8d91053c3a Refactor backup mutation selection logic in ShowBackups component. Simplify key determination for mutation mapping based on backup type. 2025-04-27 22:45:06 -06:00
Mauricio Siu
7c2eb63625 Implement metadata handling for database and compose backups. Update backup schemas to include metadata fields for various database types. Enhance backup creation and update processes to accommodate new metadata requirements. Modify UI components to support metadata input for different database types during backup operations. 2025-04-27 22:14:06 -06:00
Mauricio Siu
2ea2605ab1 Enhance backup functionality by introducing support for compose backups. Update backup schema to include serviceName and backupType fields. Modify related components to handle new backup types and integrate service selection for compose backups. Update API routes and database schema accordingly. 2025-04-27 20:17:49 -06:00
Mauricio Siu
7ae3ff22ee Merge pull request #1613 from TheoD02/feat/github-triggerType
Some checks failed
Auto PR to main when version changes / create-pr (push) Has been cancelled
Build Docker images / build-and-push-cloud-image (push) Has been cancelled
Build Docker images / build-and-push-schedule-image (push) Has been cancelled
Build Docker images / build-and-push-server-image (push) Has been cancelled
Dokploy Docker Build / docker-amd (push) Has been cancelled
Dokploy Docker Build / docker-arm (push) Has been cancelled
autofix.ci / format (push) Has been cancelled
Dokploy Monitoring Build / docker-amd (push) Has been cancelled
Dokploy Monitoring Build / docker-arm (push) Has been cancelled
Dokploy Docker Build / combine-manifests (push) Has been cancelled
Dokploy Docker Build / generate-release (push) Has been cancelled
Dokploy Monitoring Build / combine-manifests (push) Has been cancelled
feat(github): add triggerType field to GitHub provider and handle tag creation events
2025-04-27 18:43:28 -06:00
Mauricio Siu
bb5c6bebff Add conditional rendering for watchPaths field based on triggerType in SaveGithubProvider and SaveGithubProviderCompose components. 2025-04-26 22:20:37 -06:00
Mauricio Siu
144d48057c Add triggerType field to baseApp configuration in drop and traefik test files 2025-04-26 22:18:43 -06:00
Mauricio Siu
55dc08b6ba Refactor GitHub deployment handler: simplify owner extraction logic by removing fallback to login name. 2025-04-26 22:17:09 -06:00
Mauricio Siu
56f525803b Add triggerType field to GitHub provider forms: implement selection for deployment triggers (push/tag) and update form handling in SaveGithubProvider and SaveGithubProviderCompose components. 2025-04-26 21:58:34 -06:00
Mauricio Siu
91bcd1238f Refactor triggerType implementation: remove old SQL triggerType column definitions and replace with ENUM type in application and compose tables. Update shared schema to include triggerType enum. 2025-04-26 21:14:30 -06:00
Mauricio Siu
120646c77b Merge branch 'canary' into feat/github-triggerType 2025-04-26 21:09:23 -06:00
Theo D
459c94929a fix GitHub event handling for tag deployments. 2025-04-21 02:25:41 +02:00
autofix-ci[bot]
8c36e48fe7 [autofix.ci] apply automated fixes 2025-04-13 03:27:40 +00:00
Mauricio Siu
4b15177260 Merge branch 'canary' into feat/github-triggerType 2025-04-06 15:09:45 -06:00
autofix-ci[bot]
f5cffca37c [autofix.ci] apply automated fixes (attempt 2/3) 2025-04-06 10:56:32 +00:00
autofix-ci[bot]
e5ee06b67e [autofix.ci] apply automated fixes 2025-04-06 08:59:02 +00: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
Theo D
fcb8a2bded feat(github): add triggerType field to GitHub provider and handle tag creation events 2025-04-02 22:28:12 +02: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
93 changed files with 31047 additions and 1291 deletions

View File

@@ -36,6 +36,7 @@ const baseApp: ApplicationNested = {
watchPaths: [],
enableSubmodules: false,
applicationStatus: "done",
triggerType: "push",
appName: "",
autoDeploy: true,
serverId: "",

View File

@@ -25,6 +25,7 @@ const baseApp: ApplicationNested = {
buildArgs: null,
isPreviewDeploymentsActive: false,
previewBuildArgs: null,
triggerType: "push",
previewCertificateType: "none",
previewEnv: null,
previewHttps: false,

View File

@@ -89,8 +89,6 @@ export const AddDomain = ({
serverId: application?.serverId || "",
});
console.log("canGenerateTraefikMeDomains", canGenerateTraefikMeDomains);
const form = useForm<Domain>({
resolver: zodResolver(domain),
defaultValues: {
@@ -276,6 +274,11 @@ export const AddDomain = ({
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>

View File

@@ -8,16 +8,49 @@ import {
CardTitle,
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { ExternalLink, GlobeIcon, PenBoxIcon, Trash2 } from "lucide-react";
import {
CheckCircle2,
ExternalLink,
GlobeIcon,
InfoIcon,
Loader2,
PenBoxIcon,
RefreshCw,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { AddDomain } from "./add-domain";
import { useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { ValidationStates } from "../../compose/domains/show-domains";
import { DnsHelperModal } from "../../compose/domains/dns-helper-modal";
import { Badge } from "@/components/ui/badge";
interface Props {
applicationId: string;
}
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 } = api.domain.byApplicationId.useQuery(
{
applicationId,
@@ -26,10 +59,47 @@ export const ShowDomains = ({ applicationId }: Props) => {
enabled: !!applicationId,
},
);
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:
application?.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">
@@ -68,73 +138,208 @@ export const ShowDomains = ({ applicationId }: Props) => {
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<div
<Card
key={item.domainId}
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent"
>
<Link
className="md:basis-1/2 flex gap-2 items-center hover:underline transition-all w-full"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<span className="truncate max-w-full text-sm">
{item.host}
</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
<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">
<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={
application?.server?.ipAddress?.toString() ||
ip?.toString()
}
/>
)}
<AddDomain
applicationId={applicationId}
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>
</AddDomain>
<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>
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
</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>
<div className="flex gap-2">
<AddDomain
applicationId={applicationId}
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>
</AddDomain>
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await deleteDomain({
domainId: item.domainId,
})
.then(() => {
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>
<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 ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">
Error:
</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>

View File

@@ -58,6 +58,7 @@ const GithubProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
enableSubmodules: z.boolean().default(false),
});
@@ -83,6 +84,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
},
githubId: "",
branch: "",
triggerType: "push",
enableSubmodules: false,
},
resolver: zodResolver(GithubProviderSchema),
@@ -90,6 +92,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
@@ -127,6 +130,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
buildPath: data.buildPath || "/",
githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
triggerType: data.triggerType || "push",
enableSubmodules: data.enableSubmodules ?? false,
});
}
@@ -141,6 +145,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
buildPath: data.buildPath,
githubId: data.githubId,
watchPaths: data.watchPaths || [],
triggerType: data.triggerType,
enableSubmodules: data.enableSubmodules,
})
.then(async () => {
@@ -386,11 +391,11 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
/>
<FormField
control={form.control}
name="watchPaths"
name="triggerType"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<div className="flex items-center gap-2 ">
<FormLabel>Trigger Type</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -398,71 +403,114 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
Choose when to trigger deployments: on push to the
selected branch or when a new tag is created.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
/>
<SelectTrigger>
<SelectValue placeholder="Select a trigger type" />
</SelectTrigger>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<SelectContent>
<SelectItem value="push">On Push</SelectItem>
<SelectItem value="tag">On Tag</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{triggerType === "push" && (
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in
these paths change, a new deployment will be
triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge
key={index}
variant="secondary"
className="flex items-center gap-1"
>
{path}
<X
className="size-3 cursor-pointer hover:text-destructive"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
field.onChange(newPaths);
}}
/>
</Badge>
))}
</div>
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}
}}
/>
</FormControl>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => {
const input = document.querySelector(
'input[placeholder*="Enter a path"]',
) as HTMLInputElement;
const path = input.value.trim();
if (path) {
field.onChange([...(field.value || []), path]);
input.value = "";
}
}}
>
<Plus className="size-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}

View File

@@ -401,6 +401,11 @@ export const AddDomainCompose = ({
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>

View File

@@ -0,0 +1,109 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { AlertBlock } from "@/components/shared/alert-block";
import { Copy, HelpCircle, Server } from "lucide-react";
import { toast } from "sonner";
interface Props {
domain: {
host: string;
https: boolean;
path?: string;
};
serverIp?: string;
}
export const DnsHelperModal = ({ domain, serverIp }: Props) => {
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success("Copied to clipboard!");
};
return (
<Dialog>
<DialogTrigger>
<Button variant="ghost" size="icon" className="group">
<HelpCircle className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Server className="size-5" />
DNS Configuration Guide
</DialogTitle>
<DialogDescription>
Follow these steps to configure your DNS records for {domain.host}
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4">
<AlertBlock type="info">
To make your domain accessible, you need to configure your DNS
records with your domain provider (e.g., Cloudflare, GoDaddy,
NameCheap).
</AlertBlock>
<div className="flex flex-col gap-6">
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">1. Add A Record</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create an A record that points your domain to the server's IP
address:
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-2 bg-muted p-3 rounded-md">
<div>
<p className="text-sm font-medium">Type: A</p>
<p className="text-sm">
Name: @ or {domain.host.split(".")[0]}
</p>
<p className="text-sm">
Value: {serverIp || "Your server IP"}
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => copyToClipboard(serverIp || "")}
disabled={!serverIp}
>
<Copy className="size-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border p-4">
<h3 className="font-medium mb-2">2. Verify Configuration</h3>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
After configuring your DNS records:
</p>
<ul className="list-disc list-inside space-y-1 text-sm">
<li>Wait for DNS propagation (usually 15-30 minutes)</li>
<li>
Test your domain by visiting:{" "}
{domain.https ? "https://" : "http://"}
{domain.host}
{domain.path || "/"}
</li>
<li>Use a DNS lookup tool to verify your records</li>
</ul>
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -7,17 +7,55 @@ import {
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 } from "lucide-react";
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,
@@ -27,11 +65,58 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
},
);
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 ">
<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">
@@ -45,100 +130,248 @@ export const ShowDomainsCompose = ({ composeId }: Props) => {
{data && data?.length > 0 && (
<AddDomainCompose composeId={composeId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
)}
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
<CardContent>
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<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">
<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" /> Add Domain
<GlobeIcon className="size-4 mr-2" /> Add Domain
</Button>
</AddDomainCompose>
</div>
</div>
) : (
<div className="flex w-full flex-col gap-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<div
<Card
key={item.domainId}
className="flex w-full items-center justify-between gap-4 border p-4 md:px-6 rounded-lg flex-wrap"
className="relative overflow-hidden border bg-card transition-all hover:shadow-md bg-transparent"
>
<div className="md:basis-1/2 flex gap-6 w-full items-center">
<span className="opacity-50 text-center font-medium text-sm whitespace-nowrap">
{item.serviceName}
</span>
<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>
<Link
className="flex gap-2 items-center hover:underline transition-all w-full max-w-[calc(100%-4rem)]"
target="_blank"
href={`${item.https ? "https" : "http"}://${item.host}${item.path}`}
>
<span className="truncate text-sm">{item.host}</span>
<ExternalLink className="size-4 min-w-4" />
</Link>
</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>
<div className="flex gap-8">
<div className="flex gap-8 opacity-50 items-center h-10 text-center text-sm font-medium">
<span>{item.path}</span>
<span>{item.port}</span>
<span>{item.https ? "HTTPS" : "HTTP"}</span>
<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>
<div className="flex gap-2">
<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>
</div>
</CardContent>
</Card>
);
})}
</div>

View File

@@ -40,7 +40,7 @@ import {
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -58,6 +58,7 @@ const GithubProviderSchema = z.object({
branch: z.string().min(1, "Branch is required"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
enableSubmodules: z.boolean().default(false),
});
@@ -84,6 +85,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
githubId: "",
branch: "",
watchPaths: [],
triggerType: "push",
enableSubmodules: false,
},
resolver: zodResolver(GithubProviderSchema),
@@ -91,7 +93,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
const repository = form.watch("repository");
const githubId = form.watch("githubId");
const triggerType = form.watch("triggerType");
const { data: repositories, isLoading: isLoadingRepositories } =
api.github.getGithubRepositories.useQuery(
{
@@ -128,6 +130,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composePath: data.composePath,
githubId: data.githubId || "",
watchPaths: data.watchPaths || [],
triggerType: data.triggerType || "push",
enableSubmodules: data.enableSubmodules ?? false,
});
}
@@ -145,6 +148,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
composeStatus: "idle",
watchPaths: data.watchPaths,
enableSubmodules: data.enableSubmodules,
triggerType: data.triggerType,
})
.then(async () => {
toast.success("Service Provided Saved");
@@ -389,82 +393,128 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
/>
<FormField
control={form.control}
name="watchPaths"
name="triggerType"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<FormLabel>Trigger Type</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in these
paths change, a new deployment will be triggered.
Choose when to trigger deployments: on push to the
selected branch or when a new tag is created.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
<Select
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a trigger type" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="push">On Push</SelectItem>
<SelectItem value="tag">On Tag</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{triggerType === "push" && (
<FormField
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent>
<p>
Add paths to watch for changes. When files in
these paths change, a new deployment will be
triggered.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((path, index) => (
<Badge key={index} variant="secondary">
{path}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newPaths = [...(field.value || [])];
newPaths.splice(index, 1);
form.setValue("watchPaths", newPaths);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value) {
const newPaths = [
...(field.value || []),
value,
];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {
const newPaths = [...(field.value || []), value];
form.setValue("watchPaths", newPaths);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="enableSubmodules"

View File

@@ -1,347 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
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 } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { PlusIcon } from "lucide-react";
import { CheckIcon, ChevronsUpDown } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const AddPostgresBackup1Schema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
// .regex(
// new RegExp(
// /^(\*|([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])|\*\/([0-9]|1[0-9]|2[0-9]|3[0-9]|4[0-9]|5[0-9])) (\*|([0-9]|1[0-9]|2[0-3])|\*\/([0-9]|1[0-9]|2[0-3])) (\*|([1-9]|1[0-9]|2[0-9]|3[0-1])|\*\/([1-9]|1[0-9]|2[0-9]|3[0-1])) (\*|([1-9]|1[0-2])|\*\/([1-9]|1[0-2])) (\*|([0-6])|\*\/([0-6]))$/,
// ),
// "Invalid Cron",
// ),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
});
type AddPostgresBackup = z.infer<typeof AddPostgresBackup1Schema>;
interface Props {
databaseId: string;
databaseType: "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
refetch: () => void;
}
export const AddBackup = ({ databaseId, databaseType, refetch }: Props) => {
const { data, isLoading } = api.destination.all.useQuery();
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
api.backup.create.useMutation();
const form = useForm<AddPostgresBackup>({
defaultValues: {
database: "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(AddPostgresBackup1Schema),
});
useEffect(() => {
form.reset({
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
});
}, [form, form.reset, form.formState.isSubmitSuccessful]);
const onSubmit = async (data: AddPostgresBackup) => {
const getDatabaseId =
databaseType === "postgres"
? {
postgresId: databaseId,
}
: databaseType === "mariadb"
? {
mariadbId: databaseId,
}
: databaseType === "mysql"
? {
mysqlId: databaseId,
}
: databaseType === "mongo"
? {
mongoId: databaseId,
}
: databaseType === "web-server"
? {
userId: databaseId,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount,
databaseType,
...getDatabaseId,
})
.then(async () => {
toast.success("Backup Created");
refetch();
})
.catch(() => {
toast.error("Error creating a backup");
});
};
return (
<Dialog>
<DialogTrigger asChild>
<Button>
<PlusIcon className="h-4 w-4" />
Create Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg max-h-screen overflow-y-auto">
<DialogHeader>
<DialogTitle>Create a backup</DialogTitle>
<DialogDescription>Add a new backup</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-add-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
isLoading={isCreatingPostgresBackup}
form="hook-form-add-backup"
type="submit"
>
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,767 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
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 } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
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";
type CacheType = "cache" | "fetch";
type DatabaseType = "postgres" | "mariadb" | "mysql" | "mongo" | "web-server";
const Schema = z
.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
serviceName: z.string().nullable(),
databaseType: z
.enum(["postgres", "mariadb", "mysql", "mongo", "web-server"])
.optional(),
backupType: z.enum(["database", "compose"]),
metadata: z
.object({
postgres: z
.object({
databaseUser: z.string(),
})
.optional(),
mariadb: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mongo: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mysql: z
.object({
databaseRootPassword: z.string(),
})
.optional(),
})
.optional(),
})
.superRefine((data, ctx) => {
if (data.backupType === "compose" && !data.databaseType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database type is required for compose backups",
path: ["databaseType"],
});
}
if (data.backupType === "compose" && data.databaseType) {
if (data.databaseType === "postgres") {
if (!data.metadata?.postgres?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for PostgreSQL",
path: ["metadata", "postgres", "databaseUser"],
});
}
} else if (data.databaseType === "mariadb") {
if (!data.metadata?.mariadb?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MariaDB",
path: ["metadata", "mariadb", "databaseUser"],
});
}
if (!data.metadata?.mariadb?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MariaDB",
path: ["metadata", "mariadb", "databasePassword"],
});
}
} else if (data.databaseType === "mongo") {
if (!data.metadata?.mongo?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MongoDB",
path: ["metadata", "mongo", "databaseUser"],
});
}
if (!data.metadata?.mongo?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MongoDB",
path: ["metadata", "mongo", "databasePassword"],
});
}
} else if (data.databaseType === "mysql") {
if (!data.metadata?.mysql?.databaseRootPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Root password is required for MySQL",
path: ["metadata", "mysql", "databaseRootPassword"],
});
}
}
}
});
interface Props {
id?: string;
backupId?: string;
databaseType?: DatabaseType;
refetch: () => void;
backupType: "database" | "compose";
}
export const HandleBackup = ({
id,
backupId,
databaseType = "postgres",
refetch,
backupType = "database",
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
backupId: backupId ?? "",
},
{
enabled: !!backupId,
},
);
const [cacheType, setCacheType] = useState<CacheType>("cache");
const { mutateAsync: createBackup, isLoading: isCreatingPostgresBackup } =
backupId
? api.backup.update.useMutation()
: api.backup.create.useMutation();
const form = useForm<z.infer<typeof Schema>>({
defaultValues: {
database: databaseType === "web-server" ? "dokploy" : "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
serviceName: null,
databaseType: backupType === "compose" ? undefined : databaseType,
backupType: backupType,
metadata: {},
},
resolver: zodResolver(Schema),
});
const {
data: services,
isFetching: isLoadingServices,
error: errorServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: backup?.composeId ?? id ?? "",
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: backupType === "compose" && !!backup?.composeId && !!id,
},
);
useEffect(() => {
form.reset({
database: backup?.database
? backup?.database
: databaseType === "web-server"
? "dokploy"
: "",
destinationId: backup?.destinationId ?? "",
enabled: backup?.enabled ?? true,
prefix: backup?.prefix ?? "/",
schedule: backup?.schedule ?? "",
keepLatestCount: backup?.keepLatestCount ?? undefined,
serviceName: backup?.serviceName ?? null,
databaseType: backup?.databaseType ?? databaseType,
backupType: backup?.backupType ?? backupType,
metadata: backup?.metadata ?? {},
});
}, [form, form.reset, backupId, backup]);
const onSubmit = async (data: z.infer<typeof Schema>) => {
const getDatabaseId =
backupType === "compose"
? {
composeId: id,
}
: databaseType === "postgres"
? {
postgresId: id,
}
: databaseType === "mariadb"
? {
mariadbId: id,
}
: databaseType === "mysql"
? {
mysqlId: id,
}
: databaseType === "mongo"
? {
mongoId: id,
}
: databaseType === "web-server"
? {
userId: id,
}
: undefined;
await createBackup({
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount ?? null,
databaseType: data.databaseType || databaseType,
serviceName: data.serviceName,
...getDatabaseId,
backupId: backupId ?? "",
backupType,
metadata: data.metadata,
})
.then(async () => {
toast.success(`Backup ${backupId ? "Updated" : "Created"}`);
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error(`Error ${backupId ? "updating" : "creating"} a backup`);
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
{backupId ? (
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 size-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
) : (
<Button>
<PlusIcon className="h-4 w-4" />
{backupId ? "Update Backup" : "Create Backup"}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-screen overflow-y-auto">
<DialogHeader>
<DialogTitle>
{backupId ? "Update Backup" : "Create Backup"}
</DialogTitle>
<DialogDescription>
{backupId ? "Update a backup" : "Add a new backup"}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-add-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
{errorServices && (
<AlertBlock type="warning" className="[overflow-wrap:anywhere]">
{errorServices?.message}
</AlertBlock>
)}
{backupType === "compose" && (
<FormField
control={form.control}
name="databaseType"
render={({ field }) => (
<FormItem>
<FormLabel>Database Type</FormLabel>
<Select
value={field.value}
onValueChange={(value) => {
field.onChange(value as DatabaseType);
form.setValue("metadata", {});
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a database type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="mongo">MongoDB</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
{backupType === "compose" && (
<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}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<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="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
placeholder={"dokploy"}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
{backupType === "compose" && (
<>
{form.watch("databaseType") === "postgres" && (
<FormField
control={form.control}
name="metadata.postgres.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="postgres" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{form.watch("databaseType") === "mariadb" && (
<>
<FormField
control={form.control}
name="metadata.mariadb.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="mariadb" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mariadb.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{form.watch("databaseType") === "mongo" && (
<>
<FormField
control={form.control}
name="metadata.mongo.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="mongo" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mongo.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{form.watch("databaseType") === "mysql" && (
<FormField
control={form.control}
name="metadata.mysql.databaseRootPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Root Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
</div>
<DialogFooter>
<Button
isLoading={isCreatingPostgresBackup}
form="hook-form-add-backup"
type="submit"
>
{backupId ? "Update" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

@@ -32,50 +32,163 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import copy from "copy-to-clipboard";
import { debounce } from "lodash";
import { CheckIcon, ChevronsUpDown, Copy, RotateCcw } from "lucide-react";
import {
CheckIcon,
ChevronsUpDown,
Copy,
RotateCcw,
RefreshCw,
DatabaseZap,
} from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import type { ServiceType } from "../../application/advanced/show-resources";
import { type LogLine, parseLogs } from "../../docker/logs/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
type DatabaseType =
| Exclude<ServiceType, "application" | "redis">
| "web-server";
interface Props {
databaseId: string;
databaseType: Exclude<ServiceType, "application" | "redis"> | "web-server";
id: string;
databaseType?: DatabaseType;
serverId?: string | null;
backupType?: "database" | "compose";
}
const RestoreBackupSchema = z.object({
destinationId: z
.string({
required_error: "Please select a destination",
})
.min(1, {
message: "Destination is required",
}),
backupFile: z
.string({
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
databaseName: z
.string({
required_error: "Please enter a database name",
})
.min(1, {
message: "Database name is required",
}),
});
type RestoreBackup = z.infer<typeof RestoreBackupSchema>;
const RestoreBackupSchema = z
.object({
destinationId: z
.string({
required_error: "Please select a destination",
})
.min(1, {
message: "Destination is required",
}),
backupFile: z
.string({
required_error: "Please select a backup file",
})
.min(1, {
message: "Backup file is required",
}),
databaseName: z
.string({
required_error: "Please enter a database name",
})
.min(1, {
message: "Database name is required",
}),
databaseType: 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
.object({
databaseUser: z.string(),
})
.optional(),
mariadb: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mongo: z
.object({
databaseUser: z.string(),
databasePassword: z.string(),
})
.optional(),
mysql: z
.object({
databaseRootPassword: z.string(),
})
.optional(),
})
.optional(),
})
.superRefine((data, ctx) => {
if (data.backupType === "compose" && !data.databaseType) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database type is required for compose backups",
path: ["databaseType"],
});
}
if (data.backupType === "compose" && data.databaseType) {
if (data.databaseType === "postgres") {
if (!data.metadata?.postgres?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for PostgreSQL",
path: ["metadata", "postgres", "databaseUser"],
});
}
} else if (data.databaseType === "mariadb") {
if (!data.metadata?.mariadb?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MariaDB",
path: ["metadata", "mariadb", "databaseUser"],
});
}
if (!data.metadata?.mariadb?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MariaDB",
path: ["metadata", "mariadb", "databasePassword"],
});
}
} else if (data.databaseType === "mongo") {
if (!data.metadata?.mongo?.databaseUser) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database user is required for MongoDB",
path: ["metadata", "mongo", "databaseUser"],
});
}
if (!data.metadata?.mongo?.databasePassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Database password is required for MongoDB",
path: ["metadata", "mongo", "databasePassword"],
});
}
} else if (data.databaseType === "mysql") {
if (!data.metadata?.mysql?.databaseRootPassword) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Root password is required for MySQL",
path: ["metadata", "mysql", "databaseRootPassword"],
});
}
}
}
});
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 Bytes";
@@ -86,9 +199,10 @@ const formatBytes = (bytes: number): string => {
};
export const RestoreBackup = ({
databaseId,
id,
databaseType,
serverId,
backupType = "database",
}: Props) => {
const [isOpen, setIsOpen] = useState(false);
const [search, setSearch] = useState("");
@@ -96,16 +210,21 @@ export const RestoreBackup = ({
const { data: destinations = [] } = api.destination.all.useQuery();
const form = useForm<RestoreBackup>({
const form = useForm<z.infer<typeof RestoreBackupSchema>>({
defaultValues: {
destinationId: "",
backupFile: "",
databaseName: databaseType === "web-server" ? "dokploy" : "",
databaseType:
backupType === "compose" ? ("postgres" as DatabaseType) : databaseType,
metadata: {},
},
resolver: zodResolver(RestoreBackupSchema),
});
const destionationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
const debouncedSetSearch = debounce((value: string) => {
setDebouncedSearchTerm(value);
@@ -131,16 +250,15 @@ export const RestoreBackup = ({
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
// const { mutateAsync: restore, isLoading: isRestoring } =
// api.backup.restoreBackup.useMutation();
api.backup.restoreBackupWithLogs.useSubscription(
{
databaseId,
databaseType,
databaseId: id,
databaseType: currentDatabaseType as DatabaseType,
databaseName: form.watch("databaseName"),
backupFile: form.watch("backupFile"),
destinationId: form.watch("destinationId"),
backupType: backupType,
metadata: metadata,
},
{
enabled: isDeploying,
@@ -162,10 +280,32 @@ export const RestoreBackup = ({
},
);
const onSubmit = async (_data: RestoreBackup) => {
const onSubmit = async (data: z.infer<typeof RestoreBackupSchema>) => {
if (backupType === "compose" && !data.databaseType) {
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};
const [cacheType, setCacheType] = useState<"fetch" | "cache">("cache");
const {
data: services = [],
isLoading: isLoadingServices,
refetch: refetchServices,
} = api.compose.loadServices.useQuery(
{
composeId: id,
type: cacheType,
},
{
retry: false,
refetchOnWindowFocus: false,
enabled: backupType === "compose",
},
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
@@ -174,7 +314,7 @@ export const RestoreBackup = ({
Restore Backup
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center">
<RotateCcw className="mr-2 size-4" />
@@ -377,25 +517,270 @@ export const RestoreBackup = ({
control={form.control}
name="databaseName"
render={({ field }) => (
<FormItem className="">
<FormItem>
<FormLabel>Database Name</FormLabel>
<FormControl>
<Input
disabled={databaseType === "web-server"}
{...field}
placeholder="Enter database name"
/>
<Input placeholder="Enter database name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{backupType === "compose" && (
<>
<FormField
control={form.control}
name="databaseType"
render={({ field }) => (
<FormItem>
<FormLabel>Database Type</FormLabel>
<Select
value={field.value}
onValueChange={(value: DatabaseType) => {
field.onChange(value);
form.setValue("metadata", {});
}}
>
<SelectTrigger>
<SelectValue placeholder="Select database type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mariadb">MariaDB</SelectItem>
<SelectItem value="mongo">MongoDB</SelectItem>
<SelectItem value="mysql">MySQL</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<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}
value={field.value || undefined}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a service name" />
</SelectTrigger>
</FormControl>
<SelectContent>
{services?.map((service, index) => (
<SelectItem
value={service}
key={`${service}-${index}`}
>
{service}
</SelectItem>
))}
{(!services || services.length === 0) && (
<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>
)}
/>
{currentDatabaseType === "postgres" && (
<FormField
control={form.control}
name="metadata.postgres.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input placeholder="Enter database user" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{currentDatabaseType === "mariadb" && (
<>
<FormField
control={form.control}
name="metadata.mariadb.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input
placeholder="Enter database user"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mariadb.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter database password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{currentDatabaseType === "mongo" && (
<>
<FormField
control={form.control}
name="metadata.mongo.databaseUser"
render={({ field }) => (
<FormItem>
<FormLabel>Database User</FormLabel>
<FormControl>
<Input
placeholder="Enter database user"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="metadata.mongo.databasePassword"
render={({ field }) => (
<FormItem>
<FormLabel>Database Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter database password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</>
)}
{currentDatabaseType === "mysql" && (
<FormField
control={form.control}
name="metadata.mysql.databaseRootPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Root Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter root password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</>
)}
<DialogFooter>
<Button
isLoading={isDeploying}
form="hook-form-restore-backup"
type="submit"
disabled={!form.watch("backupFile")}
disabled={
!form.watch("backupFile") ||
(backupType === "compose" && !form.watch("databaseType"))
}
>
Restore
</Button>

View File

@@ -19,44 +19,71 @@ import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import type { ServiceType } from "../../application/advanced/show-resources";
import { AddBackup } from "./add-backup";
import { RestoreBackup } from "./restore-backup";
import { UpdateBackup } from "./update-backup";
import { HandleBackup } from "./handle-backup";
import { cn } from "@/lib/utils";
import {
MariadbIcon,
MongodbIcon,
MysqlIcon,
PostgresqlIcon,
} from "@/components/icons/data-tools-icons";
import { AlertBlock } from "@/components/shared/alert-block";
interface Props {
id: string;
type: Exclude<ServiceType, "application" | "redis"> | "web-server";
databaseType?: Exclude<ServiceType, "application" | "redis"> | "web-server";
backupType?: "database" | "compose";
}
export const ShowBackups = ({ id, type }: Props) => {
export const ShowBackups = ({
id,
databaseType,
backupType = "database",
}: Props) => {
const [activeManualBackup, setActiveManualBackup] = useState<
string | undefined
>();
const queryMap = {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () => api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () => api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
};
const queryMap =
backupType === "database"
? {
postgres: () =>
api.postgres.one.useQuery({ postgresId: id }, { enabled: !!id }),
mysql: () =>
api.mysql.one.useQuery({ mysqlId: id }, { enabled: !!id }),
mariadb: () =>
api.mariadb.one.useQuery({ mariadbId: id }, { enabled: !!id }),
mongo: () =>
api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id }),
"web-server": () => api.user.getBackups.useQuery(),
}
: {
compose: () =>
api.compose.one.useQuery({ composeId: id }, { enabled: !!id }),
};
const { data } = api.destination.all.useQuery();
const { data: postgres, refetch } = queryMap[type]
? queryMap[type]()
const key = backupType === "database" ? databaseType : "compose";
const query = queryMap[key as keyof typeof queryMap];
const { data: postgres, refetch } = query
? query()
: api.mongo.one.useQuery({ mongoId: id }, { enabled: !!id });
const mutationMap = {
postgres: () => api.backup.manualBackupPostgres.useMutation(),
mysql: () => api.backup.manualBackupMySql.useMutation(),
mariadb: () => api.backup.manualBackupMariadb.useMutation(),
mongo: () => api.backup.manualBackupMongo.useMutation(),
"web-server": () => api.backup.manualBackupWebServer.useMutation(),
};
const mutationMap =
backupType === "database"
? {
postgres: api.backup.manualBackupPostgres.useMutation(),
mysql: api.backup.manualBackupMySql.useMutation(),
mariadb: api.backup.manualBackupMariadb.useMutation(),
mongo: api.backup.manualBackupMongo.useMutation(),
"web-server": api.backup.manualBackupWebServer.useMutation(),
}
: {
compose: api.backup.manualBackupCompose.useMutation(),
};
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutationMap[
type
]
? mutationMap[type]()
const mutation = mutationMap[key as keyof typeof mutationMap];
const { mutateAsync: manualBackup, isLoading: isManualBackup } = mutation
? mutation
: api.backup.manualBackupMongo.useMutation();
const { mutateAsync: deleteBackup, isLoading: isRemoving } =
@@ -78,16 +105,18 @@ export const ShowBackups = ({ id, type }: Props) => {
{postgres && postgres?.backups?.length > 0 && (
<div className="flex flex-col lg:flex-row gap-4 w-full lg:w-auto">
{type !== "web-server" && (
<AddBackup
databaseId={id}
databaseType={type}
{databaseType !== "web-server" && (
<HandleBackup
id={id}
databaseType={databaseType}
backupType={backupType}
refetch={refetch}
/>
)}
<RestoreBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
backupType={backupType}
serverId={"serverId" in postgres ? postgres.serverId : undefined}
/>
</div>
@@ -110,7 +139,7 @@ export const ShowBackups = ({ id, type }: Props) => {
</span>
</div>
) : (
<div>
<div className="flex flex-col gap-4 w-full">
{postgres?.backups.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<DatabaseBackup className="size-8 text-muted-foreground" />
@@ -118,14 +147,15 @@ export const ShowBackups = ({ id, type }: Props) => {
No backups configured
</span>
<div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
<AddBackup
databaseId={id}
databaseType={type}
<HandleBackup
id={id}
databaseType={databaseType}
backupType={backupType}
refetch={refetch}
/>
<RestoreBackup
databaseId={id}
databaseType={type}
id={id}
databaseType={databaseType}
serverId={
"serverId" in postgres ? postgres.serverId : undefined
}
@@ -133,56 +163,118 @@ export const ShowBackups = ({ id, type }: Props) => {
</div>
</div>
) : (
<div className="flex flex-col pt-2">
<div className="flex flex-col pt-2 gap-4">
{backupType === "compose" && (
<AlertBlock title="Compose Backups">
Make sure the compose is running before creating a backup.
</AlertBlock>
)}
<div className="flex flex-col gap-6">
{postgres?.backups.map((backup) => (
<div key={backup.backupId}>
<div className="flex w-full flex-col md:flex-row md:items-center justify-between gap-4 md:gap-10 border rounded-lg p-4">
<div className="grid grid-cols-1 md:grid-cols-3 xl:grid-cols-6 flex-col gap-8">
<div className="flex flex-col gap-1">
<span className="font-medium">Destination</span>
<span className="text-sm text-muted-foreground">
{backup.destination.name}
</span>
<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>
)}
<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>
</div>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Database</span>
<span className="text-sm text-muted-foreground">
{backup.database}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Scheduled</span>
<span className="text-sm text-muted-foreground">
{backup.schedule}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Prefix Storage</span>
<span className="text-sm text-muted-foreground">
{backup.prefix}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Enabled</span>
<span className="text-sm text-muted-foreground">
{backup.enabled ? "Yes" : "No"}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="font-medium">Keep Latest</span>
<span className="text-sm text-muted-foreground">
{backup.keepLatestCount || "All"}
</span>
<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>
<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 gap-4">
<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
@@ -205,14 +297,15 @@ export const ShowBackups = ({ id, type }: Props) => {
setActiveManualBackup(undefined);
}}
>
<Play className="size-5 text-muted-foreground" />
<Play className="size-4 " />
</Button>
</TooltipTrigger>
<TooltipContent>Run Manual Backup</TooltipContent>
</Tooltip>
</TooltipProvider>
<UpdateBackup
<HandleBackup
backupType={backup.backupType}
backupId={backup.backupId}
refetch={refetch}
/>
@@ -236,7 +329,7 @@ export const ShowBackups = ({ id, type }: Props) => {
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
className="group hover:bg-red-500/10 size-8"
isLoading={isRemoving}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />

View File

@@ -1,329 +0,0 @@
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "@/components/ui/command";
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 } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { CheckIcon, ChevronsUpDown, PenBoxIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const UpdateBackupSchema = z.object({
destinationId: z.string().min(1, "Destination required"),
schedule: z.string().min(1, "Schedule (Cron) required"),
prefix: z.string().min(1, "Prefix required"),
enabled: z.boolean(),
database: z.string().min(1, "Database required"),
keepLatestCount: z.coerce.number().optional(),
});
type UpdateBackup = z.infer<typeof UpdateBackupSchema>;
interface Props {
backupId: string;
refetch: () => void;
}
export const UpdateBackup = ({ backupId, refetch }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data, isLoading } = api.destination.all.useQuery();
const { data: backup } = api.backup.one.useQuery(
{
backupId,
},
{
enabled: !!backupId,
},
);
const { mutateAsync, isLoading: isLoadingUpdate } =
api.backup.update.useMutation();
const form = useForm<UpdateBackup>({
defaultValues: {
database: "",
destinationId: "",
enabled: true,
prefix: "/",
schedule: "",
keepLatestCount: undefined,
},
resolver: zodResolver(UpdateBackupSchema),
});
useEffect(() => {
if (backup) {
form.reset({
database: backup.database,
destinationId: backup.destinationId,
enabled: backup.enabled || false,
prefix: backup.prefix,
schedule: backup.schedule,
keepLatestCount: backup.keepLatestCount
? Number(backup.keepLatestCount)
: undefined,
});
}
}, [form, form.reset, backup]);
const onSubmit = async (data: UpdateBackup) => {
await mutateAsync({
backupId,
destinationId: data.destinationId,
prefix: data.prefix,
schedule: data.schedule,
enabled: data.enabled,
database: data.database,
keepLatestCount: data.keepLatestCount as number | null,
})
.then(async () => {
toast.success("Backup Updated");
refetch();
setIsOpen(false);
})
.catch(() => {
toast.error("Error updating the Backup");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<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>
</DialogTrigger>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Update Backup</DialogTitle>
<DialogDescription>Update the backup</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-update-backup"
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<div className="grid grid-cols-1 gap-4">
<FormField
control={form.control}
name="destinationId"
render={({ field }) => (
<FormItem className="">
<FormLabel>Destination</FormLabel>
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between !bg-input",
!field.value && "text-muted-foreground",
)}
>
{isLoading
? "Loading...."
: field.value
? data?.find(
(destination) =>
destination.destinationId === field.value,
)?.name
: "Select Destination"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<CommandInput
placeholder="Search Destination..."
className="h-9"
/>
{isLoading && (
<span className="py-6 text-center text-sm">
Loading Destinations....
</span>
)}
<CommandEmpty>No destinations found.</CommandEmpty>
<ScrollArea className="h-64">
<CommandGroup>
{data?.map((destination) => (
<CommandItem
value={destination.destinationId}
key={destination.destinationId}
onSelect={() => {
form.setValue(
"destinationId",
destination.destinationId,
);
}}
>
{destination.name}
<CheckIcon
className={cn(
"ml-auto h-4 w-4",
destination.destinationId === field.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="database"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Database</FormLabel>
<FormControl>
<Input placeholder={"dokploy"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="schedule"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Schedule (Cron)</FormLabel>
<FormControl>
<Input placeholder={"0 0 * * *"} {...field} />
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="prefix"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Prefix Destination</FormLabel>
<FormControl>
<Input placeholder={"dokploy/"} {...field} />
</FormControl>
<FormDescription>
Use if you want to back up in a specific path of your
destination/bucket
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="keepLatestCount"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Keep the latest</FormLabel>
<FormControl>
<Input
type="number"
placeholder={"keeps all the backups if left empty"}
{...field}
/>
</FormControl>
<FormDescription>
Optional. If provided, only keeps the latest N backups
in the cloud.
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 ">
<div className="space-y-0.5">
<FormLabel>Enabled</FormLabel>
<FormDescription>
Enable or disable the backup
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
</div>
<DialogFooter>
<Button
isLoading={isLoadingUpdate}
form="hook-form-update-backup"
type="submit"
>
Update
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

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

@@ -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

@@ -36,6 +36,9 @@ export const ShowInvitations = () => {
const { data, isLoading, refetch } =
api.organization.allInvitations.useQuery();
const { mutateAsync: removeInvitation } =
api.organization.removeInvitation.useMutation();
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-5xl mx-auto">
@@ -182,6 +185,22 @@ 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>
</>
)}
</DropdownMenuContent>

View File

@@ -0,0 +1,3 @@
CREATE TYPE "public"."triggerType" AS ENUM('push', 'tag');--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "triggerType" "triggerType" DEFAULT 'push';

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -610,6 +610,34 @@
"when": 1745706676004,
"tag": "0086_rainy_gertrude_yorkes",
"breakpoints": true
},
{
"idx": 87,
"version": "7",
"when": 1745723563822,
"tag": "0087_lively_risque",
"breakpoints": true
},
{
"idx": 88,
"version": "7",
"when": 1745801614194,
"tag": "0088_same_ezekiel",
"breakpoints": true
},
{
"idx": 89,
"version": "7",
"when": 1745812150155,
"tag": "0089_dazzling_marrow",
"breakpoints": true
},
{
"idx": 90,
"version": "7",
"when": 1746156076450,
"tag": "0090_tiny_phil_sheldon",
"breakpoints": true
}
]
}

View File

@@ -89,6 +89,115 @@ export default async function handler(
return;
}
// Handle tag creation event
if (
req.headers["x-github-event"] === "push" &&
githubBody?.ref?.startsWith("refs/tags/")
) {
try {
const tagName = githubBody?.ref.replace("refs/tags/", "");
const repository = githubBody?.repository?.name;
const owner = githubBody?.repository?.owner?.name;
const deploymentTitle = `Tag created: ${tagName}`;
const deploymentHash = extractHash(req.headers, githubBody);
// Find applications configured to deploy on tag
const apps = await db.query.applications.findMany({
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.triggerType, "tag"),
eq(applications.repository, repository),
eq(applications.owner, owner),
eq(applications.githubId, githubResult.githubId),
),
});
for (const app of apps) {
const jobData: DeploymentJob = {
applicationId: app.applicationId as string,
titleLog: deploymentTitle,
descriptionLog: `Hash: ${deploymentHash}`,
type: "deploy",
applicationType: "application",
server: !!app.serverId,
};
if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
await deploy(jobData);
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
// Find compose apps configured to deploy on tag
const composeApps = await db.query.compose.findMany({
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.triggerType, "tag"),
eq(compose.repository, repository),
eq(compose.owner, owner),
eq(compose.githubId, githubResult.githubId),
),
});
for (const composeApp of composeApps) {
const jobData: DeploymentJob = {
composeId: composeApp.composeId as string,
titleLog: deploymentTitle,
type: "deploy",
applicationType: "compose",
descriptionLog: `Hash: ${deploymentHash}`,
server: !!composeApp.serverId,
};
if (IS_CLOUD && composeApp.serverId) {
jobData.serverId = composeApp.serverId;
await deploy(jobData);
continue;
}
await myQueue.add(
"deployments",
{ ...jobData },
{
removeOnComplete: true,
removeOnFail: true,
},
);
}
const totalApps = apps.length + composeApps.length;
if (totalApps === 0) {
res
.status(200)
.json({ message: "No apps configured to deploy on tag" });
return;
}
res.status(200).json({
message: `Deployed ${totalApps} apps based on tag ${tagName}`,
});
return;
} catch (error) {
console.error("Error deploying applications on tag:", error);
res
.status(400)
.json({ message: "Error deploying applications on tag", error });
return;
}
}
if (req.headers["x-github-event"] === "push") {
try {
const branchName = githubBody?.ref?.replace("refs/heads/", "");
@@ -105,6 +214,7 @@ export default async function handler(
where: and(
eq(applications.sourceType, "github"),
eq(applications.autoDeploy, true),
eq(applications.triggerType, "push"),
eq(applications.branch, branchName),
eq(applications.repository, repository),
eq(applications.owner, owner),
@@ -150,6 +260,7 @@ export default async function handler(
where: and(
eq(compose.sourceType, "github"),
eq(compose.autoDeploy, true),
eq(compose.triggerType, "push"),
eq(compose.branch, branchName),
eq(compose.repository, repository),
eq(compose.owner, owner),

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

@@ -28,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,
@@ -65,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;
@@ -86,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">
@@ -265,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}`}
@@ -287,13 +287,13 @@ const Service = (
monitoring?.metricsConfig?.server?.token || ""
}
/>
) : ( */}
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
{/* )} */}
) : (
<div>
<ContainerFreeMonitoring
appName={data?.appName || ""}
/>
</div>
)}
</>
)}
</div>

View File

@@ -9,6 +9,7 @@ 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";
import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { ProjectLayout } from "@/components/layouts/project-layout";
@@ -217,16 +218,17 @@ const Service = (
className={cn(
"lg:grid lg:w-fit max-md:overflow-y-scroll justify-start",
isCloud && data?.serverId
? "lg:grid-cols-7"
? "lg:grid-cols-8"
: data?.serverId
? "lg:grid-cols-6"
: "lg:grid-cols-7",
? "lg:grid-cols-7"
: "lg:grid-cols-8",
)}
>
<TabsTrigger value="general">General</TabsTrigger>
<TabsTrigger value="environment">Environment</TabsTrigger>
<TabsTrigger value="domains">Domains</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="backups">Backups</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
{((data?.serverId && isCloud) || !data?.server) && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
@@ -245,6 +247,11 @@ const Service = (
<ShowEnvironment id={composeId} type="compose" />
</div>
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={composeId} backupType="compose" />
</div>
</TabsContent>
<TabsContent value="monitoring">
<div className="pt-2.5">

View File

@@ -271,7 +271,7 @@ const Mariadb = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mariadbId} type="mariadb" />
<ShowBackups id={mariadbId} databaseType="mariadb" />
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -272,7 +272,11 @@ const Mongo = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mongoId} type="mongo" />
<ShowBackups
id={mongoId}
databaseType="mongo"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -252,7 +252,11 @@ const MySql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={mysqlId} type="mysql" />
<ShowBackups
id={mysqlId}
databaseType="mysql"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

View File

@@ -251,7 +251,11 @@ const Postgresql = (
</TabsContent>
<TabsContent value="backups">
<div className="flex flex-col gap-4 pt-2.5">
<ShowBackups id={postgresId} type="postgres" />
<ShowBackups
id={postgresId}
databaseType="postgres"
backupType="database"
/>
</div>
</TabsContent>
<TabsContent value="advanced">

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">
@@ -20,9 +22,14 @@ const Page = () => {
<WebServer />
<div className="w-full flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<ShowBackups id={user?.userId ?? ""} type="web-server" />
<ShowBackups
id={user?.userId ?? ""}
databaseType="web-server"
backupType="database"
/>
</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";
@@ -42,7 +41,6 @@ import { userRouter } from "./routers/user";
*/
export const appRouter = createTRPCRouter({
admin: adminRouter,
docker: dockerRouter,
project: projectRouter,
application: applicationRouter,

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

@@ -355,6 +355,7 @@ export const applicationRouter = createTRPCRouter({
applicationStatus: "idle",
githubId: input.githubId,
watchPaths: input.watchPaths,
triggerType: input.triggerType,
enableSubmodules: input.enableSubmodules,
});

View File

@@ -10,6 +10,8 @@ import {
IS_CLOUD,
createBackup,
findBackupById,
findComposeByBackupId,
findComposeById,
findMariadbByBackupId,
findMariadbById,
findMongoByBackupId,
@@ -31,6 +33,7 @@ import {
} from "@dokploy/server";
import { findDestinationById } from "@dokploy/server/services/destination";
import { runComposeBackup } from "@dokploy/server/utils/backups/compose";
import {
getS3Credentials,
normalizeS3Path,
@@ -40,6 +43,7 @@ import {
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
import {
restoreComposeBackup,
restoreMariadbBackup,
restoreMongoBackup,
restoreMySqlBackup,
@@ -82,6 +86,11 @@ export const backupRouter = createTRPCRouter({
serverId = backup.mongo.serverId;
} else if (databaseType === "mariadb" && backup.mariadb?.serverId) {
serverId = backup.mariadb.serverId;
} else if (
backup.backupType === "compose" &&
backup.compose?.serverId
) {
serverId = backup.compose.serverId;
}
const server = await findServerById(serverId);
@@ -232,6 +241,22 @@ export const backupRouter = createTRPCRouter({
});
}
}),
manualBackupCompose: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
try {
const backup = await findBackupById(input.backupId);
const compose = await findComposeByBackupId(backup.backupId);
await runComposeBackup(compose, backup);
return true;
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error running manual Compose backup ",
cause: error,
});
}
}),
manualBackupMongo: protectedProcedure
.input(apiFindOneBackup)
.mutation(async ({ input }) => {
@@ -351,78 +376,96 @@ export const backupRouter = createTRPCRouter({
"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.databaseType === "postgres") {
const postgres = await findPostgresById(input.databaseId);
if (input.backupType === "database") {
if (input.databaseType === "postgres") {
const postgres = await findPostgresById(input.databaseId);
return observable<string>((emit) => {
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.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.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.databaseName,
input.backupFile,
(log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "web-server") {
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
emit.next(log);
return observable<string>((emit) => {
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.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.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.databaseName,
input.backupFile,
(log) => {
emit.next(log);
},
);
});
}
if (input.databaseType === "web-server") {
return observable<string>((emit) => {
restoreWebServerBackup(destination, input.backupFile, (log) => {
emit.next(log);
});
});
}
}
if (input.backupType === "compose") {
const compose = await findComposeById(input.databaseId);
return observable<string>((emit) => {
restoreComposeBackup(
compose,
destination,
input.databaseName,
input.backupFile,
input.metadata,
(log) => {
emit.next(log);
},
);
});
}
return true;
}),
});

View File

@@ -21,6 +21,7 @@ import {
removeDomain,
removeDomainById,
updateDomainById,
validateDomain,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { z } from "zod";
@@ -224,4 +225,15 @@ export const domainRouter = createTRPCRouter({
return result;
}),
validateDomain: protectedProcedure
.input(
z.object({
domain: z.string(),
serverIp: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
return validateDomain(input.domain, input.serverIp);
}),
});

View File

@@ -157,4 +157,31 @@ export const organizationRouter = createTRPCRouter({
orderBy: [desc(invitation.status), desc(invitation.expiresAt)],
});
}),
removeInvitation: adminProcedure
.input(z.object({ invitationId: z.string() }))
.mutation(async ({ ctx, input }) => {
const invitationResult = await db.query.invitation.findFirst({
where: eq(invitation.id, input.invitationId),
});
if (!invitationResult) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invitation not found",
});
}
if (
invitationResult?.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You are not allowed to remove this invitation",
});
}
return await db
.delete(invitation)
.where(eq(invitation.id, input.invitationId));
}),
});

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(),
@@ -160,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 }) => {
@@ -349,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

@@ -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

@@ -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;

20
apps/licenses/src/db.ts Normal file
View File

@@ -0,0 +1,20 @@
import { type PostgresJsDatabase, drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
declare global {
var db: PostgresJsDatabase<typeof schema> | undefined;
}
export let db: PostgresJsDatabase<typeof schema>;
if (process.env.NODE_ENV === "production") {
db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});
} else {
if (!global.db)
global.db = drizzle(postgres(process.env.DATABASE_URL!), {
schema,
});
db = global.db;
}

View File

@@ -0,0 +1,10 @@
import { createTransport } from "nodemailer";
export const transporter = createTransport({
host: process.env.SMTP_SERVER,
port: Number(process.env.SMTP_PORT),
auth: {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD,
},
});

View File

@@ -0,0 +1,39 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { cors } from "hono/cors";
import { logger } from "./logger";
import { db } from "./db";
import { sql } from "drizzle-orm";
import "dotenv/config";
import { licenseRouter } from "./api/license";
import { stripeRouter } from "./api/stripe";
const app = new Hono();
const router = new Hono();
router.use(
"/*",
cors({
origin: ["http://localhost:3001"],
}),
);
router.get("/health", async (c) => {
try {
await db.execute(sql`SELECT 1`);
return c.json({ status: "ok" });
} catch (error) {
logger.error("Database connection error:", error);
return c.json({ status: "error" }, 500);
}
});
app.route("/api", router);
app.route("/api/license", licenseRouter);
app.route("/api/stripe", stripeRouter);
const port = process.env.PORT || 4002;
console.log(`Server is running on port http://localhost:${port}`);
serve({
fetch: app.fetch,
port: Number(port),
});

View File

@@ -0,0 +1,10 @@
import pino from "pino";
export const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});

View File

@@ -0,0 +1,43 @@
import { sql } from "drizzle-orm";
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
export const users = pgTable("user", {
id: uuid("id").defaultRandom().primaryKey(),
email: text("email").notNull().unique(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
otpCode: text("otp_code"),
otpCodeExpiresAt: timestamp("otp_code_expires_at"),
temporalId: text("temporal_id"),
temporalIdExpiresAt: timestamp("temporal_id_expires_at"),
});
export const usersRelations = relations(users, ({ many }) => ({
licenses: many(licenses),
}));
export const licenses = pgTable("licenses", {
id: uuid("id").defaultRandom().primaryKey(),
productId: text("product_id").notNull(),
licenseKey: text("license_key").notNull().unique(),
serverIps: text("server_ips").array(),
activatedAt: timestamp("activated_at"),
lastVerifiedAt: timestamp("last_verified_at"),
stripeCustomerId: text("stripeCustomerId").notNull(),
stripeSubscriptionId: text("stripeSubscriptionId").notNull(),
createdAt: timestamp("created_at").default(sql`CURRENT_TIMESTAMP`),
updatedAt: timestamp("updated_at").default(sql`CURRENT_TIMESTAMP`),
metadata: text("metadata"),
userId: uuid("user_id").references(() => users.id),
});
export const licensesRelations = relations(licenses, ({ one }) => ({
user: one(users, {
fields: [licenses.userId],
references: [users.id],
}),
}));
export type License = typeof licenses.$inferSelect;
export type NewLicense = typeof licenses.$inferInsert;

View File

@@ -0,0 +1,5 @@
import Stripe from "stripe";
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-09-30.acacia",
});

View File

@@ -0,0 +1,70 @@
export const getLicenseFeatures = (type: string): string[] => {
const baseFeatures = [
"Unlimited deployments",
"Basic monitoring",
"Email support",
];
const premiumFeatures = [
...baseFeatures,
"Priority support",
"Advanced monitoring",
"Custom domains",
"Team collaboration",
];
const businessFeatures = [
...premiumFeatures,
"24/7 support",
"Custom integrations",
"SLA guarantees",
"Dedicated account manager",
];
switch (type) {
case "basic":
return baseFeatures;
case "premium":
return premiumFeatures;
case "business":
return businessFeatures;
default:
return baseFeatures;
}
};
export const getLicenseTypeFromPriceId = (
priceId: string,
): {
type: "basic" | "premium" | "business";
billingType: "monthly" | "annual";
} => {
const priceMap = {
[process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID!]: {
type: "basic",
billingType: "monthly",
},
[process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID!]: {
type: "basic",
billingType: "annual",
},
[process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID!]: {
type: "premium",
billingType: "monthly",
},
[process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID!]: {
type: "premium",
billingType: "annual",
},
[process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID!]: {
type: "business",
billingType: "monthly",
},
[process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID!]: {
type: "business",
billingType: "annual",
},
} as const;
return priceMap[priceId] || { type: "basic", billingType: "monthly" };
};

View File

@@ -0,0 +1,270 @@
import { randomBytes } from "node:crypto";
import { db } from "../db";
import { licenses, users } from "../schema";
import { eq } from "drizzle-orm";
import { stripe } from "../stripe";
import type Stripe from "stripe";
export const generateLicenseKey = () => {
return randomBytes(32).toString("hex");
};
interface CreateLicenseProps {
productId: string;
email: string;
stripeCustomerId: string;
stripeSubscriptionId: string;
}
export const createLicense = async ({
productId,
email,
stripeCustomerId,
stripeSubscriptionId,
}: CreateLicenseProps) => {
const licenseKey = `dokploy-${generateLicenseKey()}`;
return await db.transaction(async (tx) => {
let user = await tx
.insert(users)
.values({ email })
.onConflictDoNothing()
.returning()
.then((res) => res[0]);
if (!user) {
const result = await tx.query.users.findFirst({
where: eq(users.email, email),
});
if (!result) {
throw new Error("User not found");
}
user = result;
}
const license = await tx
.insert(licenses)
.values({
productId,
licenseKey,
stripeCustomerId,
stripeSubscriptionId,
userId: user.id,
})
.returning()
.then((res) => res[0]);
return {
license,
user,
};
});
};
export const validateLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
return { success: false, error: "License not found" };
}
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
const currentServerQuantity = license.serverIps?.length || 0;
const serversQuantity = suscription.items.data[0].quantity || 0;
if (license.serverIps?.includes(serverIp)) {
return {
success: true,
license,
};
}
if (currentServerQuantity >= serversQuantity) {
return {
success: false,
error:
"You have reached the maximum number of servers, please upgrade your license to add more servers",
};
}
if (suscription.status !== "active") {
return {
success: false,
error: `License is ${getLicenseStatus(suscription)}`,
};
}
if (license.serverIps && !license.serverIps.includes(serverIp)) {
return {
success: false,
error:
"This server is not authorized to use this license, please remove the current license from the UI, and activate a new one",
};
}
await db
.update(licenses)
.set({ lastVerifiedAt: new Date() })
.where(eq(licenses.id, license.id));
return { success: true, license };
};
export const activateLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
const suscription = await stripe.subscriptions.retrieve(
license.stripeSubscriptionId,
);
if (suscription.status !== "active") {
throw new Error(`License is ${getLicenseStatus(suscription)}`);
}
const currentServerQuantity = license.serverIps?.length || 0;
const serversQuantity = suscription.items.data[0].quantity || 0;
if (currentServerQuantity >= serversQuantity) {
throw new Error(
"You have reached the maximum number of servers, please upgrade your license to add more servers",
);
}
if (license.serverIps?.includes(serverIp)) {
return license;
}
// Activate the license with the server IP
const updatedLicense = await db
.update(licenses)
.set({
serverIps: [...(license.serverIps || []), serverIp],
activatedAt: new Date(),
lastVerifiedAt: new Date(),
})
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};
export const deactivateLicense = async (
licenseKey: string,
serverIp: string,
) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
const updatedLicense = await db
.update(licenses)
.set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) })
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};
export const cleanLicense = async (licenseKey: string, serverIp: string) => {
const license = await db.query.licenses.findFirst({
where: eq(licenses.licenseKey, licenseKey),
});
if (!license) {
throw new Error("License not found");
}
const updatedLicense = await db
.update(licenses)
.set({ serverIps: license.serverIps?.filter((ip) => ip !== serverIp) })
.where(eq(licenses.id, license.id))
.returning();
return updatedLicense[0];
};
export const getLicenseStatus = async (license: Stripe.Subscription) => {
if (license.status === "active") {
return "active";
}
if (license.status === "canceled") {
return "canceled";
}
if (license.status === "incomplete") {
return "incomplete";
}
if (license.status === "incomplete_expired") {
return "incomplete expired";
}
if (license.status === "past_due") {
return "past due";
}
if (license.status === "paused") {
return "paused";
}
if (license.status === "trialing") {
return "trialing";
}
if (license.status === "unpaid") {
return "unpaid";
}
return "unknown";
};
export const getStripeItems = (
type: "basic" | "premium" | "business",
serverQuantity: number,
isAnnual: boolean,
) => {
const items = [];
if (type === "basic") {
items.push({
price: isAnnual
? process.env.SELF_HOSTED_BASIC_PRICE_ANNUAL_ID
: process.env.SELF_HOSTED_BASIC_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
} else if (type === "premium") {
items.push({
price: isAnnual
? process.env.SELF_HOSTED_PREMIUM_PRICE_ANNUAL_ID
: process.env.SELF_HOSTED_PREMIUM_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
} else if (type === "business") {
items.push({
price: isAnnual
? process.env.SELF_HOSTED_BUSINESS_PRICE_ANNUAL_ID
: process.env.SELF_HOSTED_BUSINESS_PRICE_MONTHLY_ID,
quantity: serverQuantity,
});
return items;
}
return items;
};

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const createCheckoutSessionSchema = z.object({
type: z.enum(["basic", "premium", "business"]),
serverQuantity: z.number().min(1),
isAnnual: z.boolean(),
});

View File

@@ -0,0 +1,289 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
Button,
} from "@react-email/components";
import * as React from "react";
interface LicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
features: string[];
}
const baseUrl = "https://dokploy.com";
export const LicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
features = ["Feature 1", "Feature 2", "Feature 3"],
}: LicenseEmailProps): React.ReactElement => {
return (
<Html>
<Head />
<Preview>Your Dokploy License Key is Here! 🚀</Preview>
<Body style={main}>
<Container style={container}>
<Section style={heroSection}>
<Img
src={`${baseUrl}/og.png`}
width="180"
height="auto"
alt="Dokploy"
style={{ borderRadius: "10px", margin: "0 auto" }}
/>
<Heading style={heroTitle}>
Welcome to the Future of Deployment
</Heading>
</Section>
<Section style={mainContent}>
<Text style={greeting}>Hi {customerName},</Text>
<Text style={text}>
Thank you for choosing {productName}! We're excited to have you on
board. Your premium license key is ready to unlock all the
powerful features:
</Text>
<Section style={licenseContainer}>
<Text style={licenseLabel}>Your License Key</Text>
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={featuresContainer}>
<Heading as="h2" style={h2}>
🎉 Your Premium Features
</Heading>
<div style={featureGrid}>
{features.map((feature, index) => (
<Text key={index} style={featureItem}>
<span style={checkmark}>✓</span> {feature}
</Text>
))}
</div>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Getting Started
</Heading>
<div style={stepsContainer}>
<Text style={steps}>
1. Go to your Dokploy dashboard
<br />
2. Navigate to Settings → License
<br />
3. Enter your license key
<br />
4. Click "Activate License"
</Text>
</div>
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
Activate Your License
</Button>
</Section>
<Hr style={hr} />
<Section style={supportSection}>
<Text style={supportText}>
Need help? Our support team is ready to assist you.
<br />
<Link style={link} href="mailto:support@dokploy.com">
support@dokploy.com
</Link>
</Text>
</Section>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
};
const heroSection = {
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 20px",
textAlign: "center" as const,
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const heroTitle = {
color: "#1a1a1a",
fontSize: "28px",
fontWeight: "800",
lineHeight: "1.3",
margin: "20px 0 0",
padding: "0",
};
const mainContent = {
backgroundColor: "#ffffff",
borderRadius: "8px",
marginTop: "24px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const greeting = {
fontSize: "20px",
lineHeight: "1.3",
fontWeight: "600",
color: "#1a1a1a",
margin: "0 0 20px",
};
const text = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.6",
margin: "0 0 24px",
};
const licenseContainer = {
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
borderRadius: "12px",
padding: "24px",
margin: "32px 0",
textAlign: "center" as const,
};
const licenseLabel = {
color: "#ffffff",
fontSize: "14px",
textTransform: "uppercase" as const,
letterSpacing: "1px",
margin: "0 0 12px",
};
const licenseKeyStyle = {
fontFamily: "monospace",
fontSize: "24px",
color: "#ffffff",
margin: "0",
wordBreak: "break-all" as const,
fontWeight: "600",
};
const validitySection = {
textAlign: "center" as const,
margin: "24px 0",
};
const validityText = {
color: "#4a5568",
fontSize: "16px",
};
const featuresContainer = {
margin: "40px 0",
};
const featureGrid = {
display: "grid",
gridTemplateColumns: "1fr",
gap: "12px",
};
const featureItem = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.5",
margin: "0",
display: "flex",
alignItems: "center",
};
const checkmark = {
color: "#2563eb",
fontWeight: "bold",
marginRight: "12px",
fontSize: "18px",
};
const h2 = {
color: "#1a1a1a",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 20px",
padding: "0",
};
const activationSection = {
backgroundColor: "#f8fafc",
borderRadius: "8px",
padding: "24px",
margin: "32px 0",
};
const stepsContainer = {
margin: "20px 0",
};
const steps = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.8",
margin: "0",
};
const ctaButton = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
margin: "20px 0 0",
};
const hr = {
borderColor: "#e2e8f0",
margin: "40px 0",
};
const supportSection = {
textAlign: "center" as const,
};
const supportText = {
color: "#64748b",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
};
const link = {
color: "#2563eb",
textDecoration: "none",
fontWeight: "500",
};
export default LicenseEmail;

View File

@@ -0,0 +1,278 @@
import {
Body,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
Button,
} from "@react-email/components";
import * as React from "react";
interface ResendLicenseEmailProps {
customerName: string;
licenseKey: string;
productName: string;
requestDate?: Date;
}
const baseUrl = "https://dokploy.com";
export const ResendLicenseEmail = ({
customerName = "John Doe",
licenseKey = "1234567890",
productName = "Dokploy",
requestDate = new Date(),
}: ResendLicenseEmailProps): React.ReactElement => {
const formattedRequestDate = requestDate.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
return (
<Html>
<Head />
<Preview>Your Requested Dokploy License Key 🔑</Preview>
<Body style={main}>
<Container style={container}>
<Section style={heroSection}>
<Img
src={`${baseUrl}/og.png`}
width="180"
height="auto"
alt="Dokploy"
style={{ borderRadius: "10px", margin: "0 auto" }}
/>
<Heading style={heroTitle}>Here's Your License Key</Heading>
</Section>
<Section style={mainContent}>
<Text style={greeting}>Hi {customerName},</Text>
<Text style={text}>
As requested on {formattedRequestDate}, here is your {productName}{" "}
license key. This is the same active license key associated with
your account:
</Text>
<Section style={licenseContainer}>
<Text style={licenseLabel}>Your Active License Key</Text>
<Text style={licenseKeyStyle}>{licenseKey}</Text>
</Section>
<Section style={activationSection}>
<Heading as="h2" style={h2}>
Quick Activation Guide
</Heading>
<div style={stepsContainer}>
<Text style={steps}>
1. Go to your Dokploy dashboard
<br />
2. Navigate to Settings → License
<br />
3. Enter your license key above
<br />
4. Click "Activate License"
</Text>
</div>
<Button href="https://dokploy.com/dashboard" style={ctaButton}>
Go to Dashboard
</Button>
</Section>
<Hr style={hr} />
<Section style={securitySection}>
<Text style={securityText}>
🔒 For security: If you didn't request this license key, please
contact our support team immediately.
</Text>
</Section>
<Section style={supportSection}>
<Text style={supportText}>
Need help? Our support team is ready to assist you.
<br />
<Link style={link} href="mailto:support@dokploy.com">
support@dokploy.com
</Link>
</Text>
</Section>
</Section>
</Container>
</Body>
</Html>
);
};
const main = {
backgroundColor: "#f6f9fc",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "40px 0",
maxWidth: "600px",
};
const heroSection = {
backgroundColor: "#ffffff",
borderRadius: "8px",
padding: "40px 20px",
textAlign: "center" as const,
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const heroTitle = {
color: "#1a1a1a",
fontSize: "28px",
fontWeight: "800",
lineHeight: "1.3",
margin: "20px 0 0",
padding: "0",
};
const mainContent = {
backgroundColor: "#ffffff",
borderRadius: "8px",
marginTop: "24px",
padding: "40px",
boxShadow: "0 2px 8px rgba(0,0,0,0.05)",
};
const greeting = {
fontSize: "20px",
lineHeight: "1.3",
fontWeight: "600",
color: "#1a1a1a",
margin: "0 0 20px",
};
const text = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.6",
margin: "0 0 24px",
};
const licenseContainer = {
background: "linear-gradient(135deg, #2563eb 0%, #1e40af 100%)",
borderRadius: "12px",
padding: "24px",
margin: "32px 0",
textAlign: "center" as const,
};
const licenseLabel = {
color: "#ffffff",
fontSize: "14px",
textTransform: "uppercase" as const,
letterSpacing: "1px",
margin: "0 0 12px",
};
const licenseKeyStyle = {
fontFamily: "monospace",
fontSize: "24px",
color: "#ffffff",
margin: "0",
wordBreak: "break-all" as const,
fontWeight: "600",
};
const validitySection = {
textAlign: "center" as const,
margin: "24px 0",
};
const validityText = {
color: "#4a5568",
fontSize: "16px",
};
const h2 = {
color: "#1a1a1a",
fontSize: "20px",
fontWeight: "600",
margin: "0 0 20px",
padding: "0",
};
const activationSection = {
backgroundColor: "#f8fafc",
borderRadius: "8px",
padding: "24px",
margin: "32px 0",
};
const stepsContainer = {
margin: "20px 0",
};
const steps = {
color: "#4a5568",
fontSize: "16px",
lineHeight: "1.8",
margin: "0",
};
const ctaButton = {
backgroundColor: "#2563eb",
borderRadius: "6px",
color: "#ffffff",
fontSize: "16px",
fontWeight: "600",
textDecoration: "none",
textAlign: "center" as const,
display: "inline-block",
padding: "12px 24px",
margin: "20px 0 0",
};
const hr = {
borderColor: "#e2e8f0",
margin: "40px 0",
};
const securitySection = {
backgroundColor: "#fff5f5",
borderRadius: "8px",
padding: "16px",
margin: "0 0 32px 0",
};
const securityText = {
color: "#e53e3e",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
textAlign: "center" as const,
};
const supportSection = {
textAlign: "center" as const,
};
const supportText = {
color: "#64748b",
fontSize: "14px",
lineHeight: "1.5",
margin: "0",
};
const link = {
color: "#2563eb",
textDecoration: "none",
fontWeight: "500",
};
export default ResendLicenseEmail;

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

3495
apps/licenses/templates/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,21 @@
{
"name": "react-email-starter",
"version": "0.1.10",
"private": true,
"type": "module",
"scripts": {
"build": "email build",
"dev": "email dev",
"export": "email export"
},
"dependencies": {
"@react-email/components": "0.0.34",
"react-dom": "18.3.1",
"react": "18.3.1"
},
"devDependencies": {
"@types/react": "18.2.33",
"@types/react-dom": "18.2.14",
"react-email": "3.0.7"
}
}

View File

@@ -0,0 +1,27 @@
# React Email Starter
A live preview right in your browser so you don't need to keep sending real emails during development.
## Getting Started
First, install the dependencies:
```sh
npm install
# or
yarn
```
Then, run the development server:
```sh
npm run dev
# or
yarn dev
```
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
## License
MIT License

24
apps/licenses/truncate.ts Normal file
View File

@@ -0,0 +1,24 @@
import { sql } from "drizzle-orm";
// Credits to Louistiti from Drizzle Discord: https://discord.com/channels/1043890932593987624/1130802621750448160/1143083373535973406
import { drizzle } from "drizzle-orm/postgres-js";
import "dotenv/config";
import postgres from "postgres";
const connectionString = process.env.DATABASE_URL!;
const pg = postgres(connectionString, { max: 1 });
const db = drizzle(pg);
const clearDb = async (): Promise<void> => {
try {
const tablesQuery = sql<string>`DROP SCHEMA public CASCADE; CREATE SCHEMA public; DROP schema drizzle CASCADE;`;
const tables = await db.execute(tablesQuery);
console.log(tables);
await pg.end();
} catch (error) {
console.error("Error cleaning database", error);
} finally {
}
};
clearDb();

View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "dist",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@dokploy/server/*": ["../../packages/server/src/*"]
}
},
"include": ["src/**/*", "truncate.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -9,10 +9,11 @@ import {
runMongoBackup,
runMySqlBackup,
runPostgresBackup,
runComposeBackup,
} from "@dokploy/server";
import { db } from "@dokploy/server/dist/db";
import { backups, server } from "@dokploy/server/dist/db/schema";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { logger } from "./logger.js";
import { scheduleJob } from "./queue.js";
import type { QueueJob } from "./schema.js";
@@ -22,40 +23,57 @@ export const runJobs = async (job: QueueJob) => {
if (job.type === "backup") {
const { backupId } = job;
const backup = await findBackupById(backupId);
const { databaseType, postgres, mysql, mongo, mariadb } = backup;
const {
databaseType,
postgres,
mysql,
mongo,
mariadb,
compose,
backupType,
} = backup;
if (databaseType === "postgres" && postgres) {
const server = await findServerById(postgres.serverId as string);
if (backupType === "database") {
if (databaseType === "postgres" && postgres) {
const server = await findServerById(postgres.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mysql" && mysql) {
const server = await findServerById(mysql.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mongo" && mongo) {
const server = await findServerById(mongo.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mariadb" && mariadb) {
const server = await findServerById(mariadb.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, server.serverId);
}
} else if (backupType === "compose" && compose) {
const server = await findServerById(compose.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mysql" && mysql) {
const server = await findServerById(mysql.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mongo" && mongo) {
const server = await findServerById(mongo.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, server.serverId);
} else if (databaseType === "mariadb" && mariadb) {
const server = await findServerById(mariadb.serverId as string);
if (server.serverStatus === "inactive") {
logger.info("Server is inactive");
return;
}
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, server.serverId);
await runComposeBackup(compose, backup);
}
}
if (job.type === "server") {
@@ -80,7 +98,10 @@ export const initializeJobs = async () => {
logger.info("Setting up Jobs....");
const servers = await db.query.server.findMany({
where: eq(server.enableDockerCleanup, true),
where: and(
eq(server.enableDockerCleanup, true),
eq(server.serverStatus, "active"),
),
});
for (const server of servers) {
@@ -92,7 +113,7 @@ export const initializeJobs = async () => {
});
}
logger.info({ Quantity: servers.length }, "Servers Initialized");
logger.info({ Quantity: servers.length }, "Active Servers Initialized");
const backupsResult = await db.query.backups.findMany({
where: eq(backups.enabled, true),
@@ -101,6 +122,7 @@ export const initializeJobs = async () => {
mysql: true,
postgres: true,
mongo: true,
compose: true,
},
});

View File

@@ -1,21 +1,32 @@
{
"name": "@dokploy/server",
"version": "1.0.0",
"main": "./src/index.ts",
"main": "./dist/index.js",
"type": "module",
"exports": {
".": "./src/index.ts",
".": {
"import": "./dist/index.js",
"require": "./dist/index.cjs.js"
},
"./db": {
"import": "./src/db/index.ts",
"import": "./dist/db/index.js",
"require": "./dist/db/index.cjs.js"
},
"./setup/*": {
"import": "./src/setup/*.ts",
"require": "./dist/setup/index.cjs.js"
"./*": {
"import": "./dist/*",
"require": "./dist/*.cjs"
},
"./constants": {
"import": "./src/constants/index.ts",
"require": "./dist/constants.cjs.js"
"./dist": {
"import": "./dist/index.js",
"require": "./dist/index.cjs.js"
},
"./dist/db": {
"import": "./dist/db/index.js",
"require": "./dist/db/index.cjs.js"
},
"./dist/db/schema": {
"import": "./dist/db/schema/index.js",
"require": "./dist/db/schema/index.cjs.js"
}
},
"scripts": {

View File

@@ -24,7 +24,7 @@ import { redirects } from "./redirects";
import { registry } from "./registry";
import { security } from "./security";
import { server } from "./server";
import { applicationStatus, certificateType } from "./shared";
import { applicationStatus, certificateType, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
@@ -149,6 +149,7 @@ export const applications = pgTable("application", {
owner: text("owner"),
branch: text("branch"),
buildPath: text("buildPath").default("/"),
triggerType: triggerType("triggerType").default("push"),
autoDeploy: boolean("autoDeploy").$defaultFn(() => true),
// Gitlab
gitlabProjectId: integer("gitlabProjectId"),
@@ -473,7 +474,10 @@ export const apiSaveGithubProvider = createSchema
watchPaths: true,
enableSubmodules: true,
})
.required();
.required()
.extend({
triggerType: z.enum(["push", "tag"]).default("push"),
});
export const apiSaveGitlabProvider = createSchema
.pick({

View File

@@ -3,6 +3,7 @@ import {
type AnyPgColumn,
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
@@ -16,6 +17,8 @@ import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { users_temp } from "./user";
import { compose } from "./compose";
export const databaseType = pgEnum("databaseType", [
"postgres",
"mariadb",
@@ -24,6 +27,8 @@ export const databaseType = pgEnum("databaseType", [
"web-server",
]);
export const backupType = pgEnum("backupType", ["database", "compose"]);
export const backups = pgTable("backup", {
backupId: text("backupId")
.notNull()
@@ -33,14 +38,19 @@ export const backups = pgTable("backup", {
enabled: boolean("enabled"),
database: text("database").notNull(),
prefix: text("prefix").notNull(),
serviceName: text("serviceName"),
destinationId: text("destinationId")
.notNull()
.references(() => destinations.destinationId, { onDelete: "cascade" }),
keepLatestCount: integer("keepLatestCount"),
backupType: backupType("backupType").notNull().default("database"),
databaseType: databaseType("databaseType").notNull(),
composeId: text("composeId").references(
(): AnyPgColumn => compose.composeId,
{
onDelete: "cascade",
},
),
postgresId: text("postgresId").references(
(): AnyPgColumn => postgres.postgresId,
{
@@ -60,6 +70,26 @@ export const backups = pgTable("backup", {
onDelete: "cascade",
}),
userId: text("userId").references(() => users_temp.id),
// Only for compose backups
metadata: jsonb("metadata").$type<
| {
postgres?: {
databaseUser: string;
};
mariadb?: {
databaseUser: string;
databasePassword: string;
};
mongo?: {
databaseUser: string;
databasePassword: string;
};
mysql?: {
databaseRootPassword: string;
};
}
| undefined
>(),
});
export const backupsRelations = relations(backups, ({ one }) => ({
@@ -87,6 +117,10 @@ export const backupsRelations = relations(backups, ({ one }) => ({
fields: [backups.userId],
references: [users_temp.id],
}),
compose: one(compose, {
fields: [backups.composeId],
references: [compose.composeId],
}),
}));
const createSchema = createInsertSchema(backups, {
@@ -103,6 +137,7 @@ const createSchema = createInsertSchema(backups, {
mysqlId: z.string().optional(),
mongoId: z.string().optional(),
userId: z.string().optional(),
metadata: z.any().optional(),
});
export const apiCreateBackup = createSchema.pick({
@@ -118,6 +153,10 @@ export const apiCreateBackup = createSchema.pick({
mongoId: true,
databaseType: true,
userId: true,
backupType: true,
composeId: true,
serviceName: true,
metadata: true,
});
export const apiFindOneBackup = createSchema
@@ -141,5 +180,8 @@ export const apiUpdateBackup = createSchema
destinationId: true,
database: true,
keepLatestCount: true,
serviceName: true,
metadata: true,
databaseType: true,
})
.required();

View File

@@ -12,9 +12,10 @@ import { gitlab } from "./gitlab";
import { mounts } from "./mount";
import { projects } from "./project";
import { server } from "./server";
import { applicationStatus } from "./shared";
import { applicationStatus, triggerType } from "./shared";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
import { backups } from "./backups";
export const sourceTypeCompose = pgEnum("sourceTypeCompose", [
"git",
@@ -77,6 +78,7 @@ export const compose = pgTable("compose", {
suffix: text("suffix").notNull().default(""),
randomize: boolean("randomize").notNull().default(false),
isolatedDeployment: boolean("isolatedDeployment").notNull().default(false),
triggerType: triggerType("triggerType").default("push"),
composeStatus: applicationStatus("composeStatus").notNull().default("idle"),
projectId: text("projectId")
.notNull()
@@ -134,6 +136,7 @@ export const composeRelations = relations(compose, ({ one, many }) => ({
fields: [compose.serverId],
references: [server.serverId],
}),
backups: many(backups),
}));
const createSchema = createInsertSchema(compose, {

View File

@@ -12,3 +12,5 @@ export const certificateType = pgEnum("certificateType", [
"none",
"custom",
]);
export const triggerType = pgEnum("triggerType", ["push", "tag"]);

View File

@@ -58,6 +58,7 @@ export const users_temp = pgTable("user_temp", {
logCleanupCron: text("logCleanupCron"),
// Metrics
enablePaidFeatures: boolean("enablePaidFeatures").notNull().default(false),
licenseKey: text("licenseKey"),
metricsConfig: jsonb("metricsConfig")
.$type<{
server: {

View File

@@ -49,6 +49,7 @@ export * from "./utils/backups/mysql";
export * from "./utils/backups/postgres";
export * from "./utils/backups/utils";
export * from "./utils/backups/web-server";
export * from "./utils/backups/compose";
export * from "./templates/processors";
export * from "./utils/notifications/build-error";

View File

@@ -35,6 +35,7 @@ export const findBackupById = async (backupId: string) => {
mariadb: true,
mongo: true,
destination: true,
compose: true,
},
});
if (!backup) {

View File

@@ -131,6 +131,11 @@ export const findComposeById = async (composeId: string) => {
bitbucket: true,
gitea: true,
server: true,
backups: {
with: {
destination: true,
},
},
},
});
if (!result) {

View File

@@ -7,6 +7,8 @@ import { type apiCreateDomain, domains } from "../db/schema";
import { findUserById } from "./admin";
import { findApplicationById } from "./application";
import { findServerById } from "./server";
import dns from "node:dns";
import { promisify } from "node:util";
export type Domain = typeof domains.$inferSelect;
@@ -137,3 +139,84 @@ export const removeDomainById = async (domainId: string) => {
export const getDomainHost = (domain: Domain) => {
return `${domain.https ? "https" : "http"}://${domain.host}`;
};
const resolveDns = promisify(dns.resolve4);
// Cloudflare IP ranges (simplified - these are some common ones)
const CLOUDFLARE_IPS = [
"172.67.",
"104.21.",
"104.16.",
"104.17.",
"104.18.",
"104.19.",
"104.20.",
"104.22.",
"104.23.",
"104.24.",
"104.25.",
"104.26.",
"104.27.",
"104.28.",
];
const isCloudflareIp = (ip: string) => {
return CLOUDFLARE_IPS.some((range) => ip.startsWith(range));
};
export const validateDomain = async (
domain: string,
expectedIp?: string,
): Promise<{
isValid: boolean;
resolvedIp?: string;
error?: string;
isCloudflare?: boolean;
}> => {
try {
// Remove protocol and path if present
const cleanDomain = domain.replace(/^https?:\/\//, "").split("/")[0];
// Resolve the domain to get its IP
const ips = await resolveDns(cleanDomain || "");
const resolvedIps = ips.map((ip) => ip.toString());
// Check if it's a Cloudflare IP
const behindCloudflare = ips.some((ip) => isCloudflareIp(ip));
// If behind Cloudflare, we consider it valid but inform the user
if (behindCloudflare) {
return {
isValid: true,
resolvedIp: resolvedIps.join(", "),
isCloudflare: true,
error:
"Domain is behind Cloudflare - actual IP is masked by Cloudflare proxy",
};
}
// If we have an expected IP, validate against it
if (expectedIp) {
return {
isValid: resolvedIps.includes(expectedIp),
resolvedIp: resolvedIps.join(", "),
error: !resolvedIps.includes(expectedIp)
? `Domain resolves to ${resolvedIps.join(", ")} but should point to ${expectedIp}`
: undefined,
};
}
// If no expected IP, just return the resolved IP
return {
isValid: true,
resolvedIp: resolvedIps.join(", "),
};
} catch (error) {
return {
isValid: false,
error:
error instanceof Error ? error.message : "Failed to resolve domain",
};
}
};

View File

@@ -1,5 +1,10 @@
import { db } from "@dokploy/server/db";
import { type apiCreateMongo, backups, mongo } from "@dokploy/server/db/schema";
import {
type apiCreateMongo,
backups,
compose,
mongo,
} from "@dokploy/server/db/schema";
import { buildAppName } from "@dokploy/server/db/schema";
import { generatePassword } from "@dokploy/server/templates";
import { buildMongo } from "@dokploy/server/utils/databases/mongo";
@@ -103,6 +108,25 @@ export const findMongoByBackupId = async (backupId: string) => {
return result[0];
};
export const findComposeByBackupId = async (backupId: string) => {
const result = await db
.select({
...getTableColumns(compose),
})
.from(compose)
.innerJoin(backups, eq(compose.composeId, backups.composeId))
.where(eq(backups.backupId, backupId))
.limit(1);
if (!result || !result[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Compose not found",
});
}
return result[0];
};
export const removeMongoById = async (mongoId: string) => {
const result = await db
.delete(mongo)

View File

@@ -0,0 +1,113 @@
import type { BackupSchedule } from "@dokploy/server/services/backup";
import type { Compose } from "@dokploy/server/services/compose";
import { findProjectById } from "@dokploy/server/services/project";
import { sendDatabaseBackupNotifications } from "../notifications/database-backup";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import { getS3Credentials, normalizeS3Path } from "./utils";
export const runComposeBackup = async (
compose: Compose,
backup: BackupSchedule,
) => {
const { projectId, name } = compose;
const project = await findProjectById(projectId);
const { prefix, database } = backup;
const destination = backup.destination;
const backupFileName = `${new Date().toISOString()}.dump.gz`;
const bucketDestination = `${normalizeS3Path(prefix)}${backupFileName}`;
try {
const rcloneFlags = getS3Credentials(destination);
const rcloneDestination = `:s3:${destination.bucket}/${bucketDestination}`;
const rcloneCommand = `rclone rcat ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const command = getFindContainerCommand(compose, backup.serviceName || "");
if (compose.serverId) {
const { stdout } = await execAsyncRemote(compose.serverId, command);
if (!stdout) {
throw new Error("Container not found");
}
const containerId = stdout.trim();
let backupCommand = "";
if (backup.databaseType === "postgres") {
backupCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${backup.metadata?.postgres?.databaseUser} --no-password '${database}' | gzip"`;
} else if (backup.databaseType === "mariadb") {
backupCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${backup.metadata?.mariadb?.databaseUser}' --password='${backup.metadata?.mariadb?.databasePassword}' --databases ${database} | gzip"`;
} else if (backup.databaseType === "mysql") {
backupCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${backup.metadata?.mysql?.databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
} else if (backup.databaseType === "mongo") {
backupCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${backup.metadata?.mongo?.databaseUser}' -p '${backup.metadata?.mongo?.databasePassword}' --archive --authenticationDatabase admin --gzip"`;
}
await execAsyncRemote(
compose.serverId,
`${backupCommand} | ${rcloneCommand}`,
);
} else {
const { stdout } = await execAsync(command);
if (!stdout) {
throw new Error("Container not found");
}
const containerId = stdout.trim();
let backupCommand = "";
if (backup.databaseType === "postgres") {
backupCommand = `docker exec ${containerId} sh -c "pg_dump -Fc --no-acl --no-owner -h localhost -U ${backup.metadata?.postgres?.databaseUser} --no-password '${database}' | gzip"`;
} else if (backup.databaseType === "mariadb") {
backupCommand = `docker exec ${containerId} sh -c "mariadb-dump --user='${backup.metadata?.mariadb?.databaseUser}' --password='${backup.metadata?.mariadb?.databasePassword}' --databases ${database} | gzip"`;
} else if (backup.databaseType === "mysql") {
backupCommand = `docker exec ${containerId} sh -c "mysqldump --default-character-set=utf8mb4 -u 'root' --password='${backup.metadata?.mysql?.databaseRootPassword}' --single-transaction --no-tablespaces --quick '${database}' | gzip"`;
} else if (backup.databaseType === "mongo") {
backupCommand = `docker exec ${containerId} sh -c "mongodump -d '${database}' -u '${backup.metadata?.mongo?.databaseUser}' -p '${backup.metadata?.mongo?.databasePassword}' --archive --authenticationDatabase admin --gzip"`;
}
await execAsync(`${backupCommand} | ${rcloneCommand}`);
}
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "mongodb",
type: "success",
organizationId: project.organizationId,
});
} catch (error) {
console.log(error);
await sendDatabaseBackupNotifications({
applicationName: name,
projectName: project.name,
databaseType: "mongodb",
type: "error",
// @ts-ignore
errorMessage: error?.message || "Error message not provided",
organizationId: project.organizationId,
});
throw error;
}
};
export const getFindContainerCommand = (
compose: Compose,
serviceName: string,
) => {
const { appName, composeType } = compose;
const labels =
composeType === "stack"
? {
namespace: `label=com.docker.stack.namespace=${appName}`,
service: `label=com.docker.swarm.service.name=${appName}_${serviceName}`,
}
: {
project: `label=com.docker.compose.project=${appName}`,
service: `label=com.docker.compose.service=${serviceName}`,
};
const command = `docker ps --filter "status=running" \
--filter "${Object.values(labels).join('" --filter "')}" \
--format "{{.ID}}" | head -n 1`;
return command.trim();
};

View File

@@ -70,6 +70,7 @@ export const initCronJobs = async () => {
mysql: true,
mongo: true,
user: true,
compose: true,
},
});
@@ -77,10 +78,10 @@ export const initCronJobs = async () => {
try {
if (backup.enabled) {
scheduleBackup(backup);
console.log(
`[Backup] ${backup.databaseType} Enabled with cron: [${backup.schedule}]`,
);
}
console.log(
`[Backup] ${backup.databaseType} Enabled with cron: [${backup.schedule}]`,
);
} catch (error) {
console.error(`[Backup] ${backup.databaseType} Error`, error);
}

View File

@@ -7,26 +7,40 @@ import { runMongoBackup } from "./mongo";
import { runMySqlBackup } from "./mysql";
import { runPostgresBackup } from "./postgres";
import { runWebServerBackup } from "./web-server";
import { runComposeBackup } from "./compose";
export const scheduleBackup = (backup: BackupSchedule) => {
const { schedule, backupId, databaseType, postgres, mysql, mongo, mariadb } =
backup;
const {
schedule,
backupId,
databaseType,
postgres,
mysql,
mongo,
mariadb,
compose,
} = backup;
scheduleJob(backupId, schedule, async () => {
if (databaseType === "postgres" && postgres) {
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres.serverId);
} else if (databaseType === "mysql" && mysql) {
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql.serverId);
} else if (databaseType === "mongo" && mongo) {
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo.serverId);
} else if (databaseType === "mariadb" && mariadb) {
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb.serverId);
} else if (databaseType === "web-server") {
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
if (backup.backupType === "database") {
if (databaseType === "postgres" && postgres) {
await runPostgresBackup(postgres, backup);
await keepLatestNBackups(backup, postgres.serverId);
} else if (databaseType === "mysql" && mysql) {
await runMySqlBackup(mysql, backup);
await keepLatestNBackups(backup, mysql.serverId);
} else if (databaseType === "mongo" && mongo) {
await runMongoBackup(mongo, backup);
await keepLatestNBackups(backup, mongo.serverId);
} else if (databaseType === "mariadb" && mariadb) {
await runMariadbBackup(mariadb, backup);
await keepLatestNBackups(backup, mariadb.serverId);
} else if (databaseType === "web-server") {
await runWebServerBackup(backup);
await keepLatestNBackups(backup);
}
} else if (backup.backupType === "compose" && compose) {
await runComposeBackup(compose, backup);
await keepLatestNBackups(backup, compose.serverId);
}
});
};

View File

@@ -0,0 +1,4 @@
export const createBackupLabels = (backupId: string) => {
const labels = [`dokploy.backup.id=${backupId}`];
return labels;
};

View File

@@ -0,0 +1,93 @@
import type { Destination } from "@dokploy/server/services/destination";
import type { Compose } from "@dokploy/server/services/compose";
import { getS3Credentials } from "../backups/utils";
import { execAsync, execAsyncRemote } from "../process/execAsync";
import type { Backup } from "@dokploy/server/services/backup";
import { getFindContainerCommand } from "../backups/compose";
export const restoreComposeBackup = async (
compose: Compose,
destination: Destination,
database: string,
backupFile: string,
metadata: Backup["metadata"] & { serviceName: string },
emit: (log: string) => void,
) => {
try {
const { serverId } = compose;
const rcloneFlags = getS3Credentials(destination);
const bucketPath = `:s3:${destination.bucket}`;
const backupPath = `${bucketPath}/${backupFile}`;
const command = getFindContainerCommand(compose, metadata.serviceName);
let containerId = "";
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(serverId, command);
emit(stdout);
emit(stderr);
containerId = stdout.trim();
} else {
const { stdout, stderr } = await execAsync(command);
emit(stdout);
emit(stderr);
containerId = stdout.trim();
}
let restoreCommand = "";
if (metadata.postgres) {
restoreCommand = `rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} pg_restore -U ${metadata.postgres.databaseUser} -d ${database} --clean --if-exists`;
} else if (metadata.mariadb) {
restoreCommand = `
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mariadb -u ${metadata.mariadb.databaseUser} -p${metadata.mariadb.databasePassword} ${database}
`;
} else if (metadata.mysql) {
restoreCommand = `
rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${containerId} mysql -u root -p${metadata.mysql.databaseRootPassword} ${database}
`;
} else if (metadata.mongo) {
const tempDir = "/tmp/dokploy-restore";
const fileName = backupFile.split("/").pop() || "backup.dump.gz";
const decompressedName = fileName.replace(".gz", "");
restoreCommand = `\
rm -rf ${tempDir} && \
mkdir -p ${tempDir} && \
rclone copy ${rcloneFlags.join(" ")} "${backupPath}" ${tempDir} && \
cd ${tempDir} && \
gunzip -f "${fileName}" && \
docker exec -i ${containerId} mongorestore --username ${metadata.mongo.databaseUser} --password ${metadata.mongo.databasePassword} --authenticationDatabase admin --db ${database} --archive < "${decompressedName}" && \
rm -rf ${tempDir}`;
}
emit("Starting restore...");
emit(`Backup path: ${backupPath}`);
emit(`Executing command: ${restoreCommand}`);
if (serverId) {
const { stdout, stderr } = await execAsyncRemote(
serverId,
restoreCommand,
);
emit(stdout);
emit(stderr);
} else {
const { stdout, stderr } = await execAsync(restoreCommand);
emit(stdout);
emit(stderr);
}
emit("Restore completed successfully!");
} catch (error) {
console.error(error);
emit(
`Error: ${
error instanceof Error ? error.message : "Error restoring mongo backup"
}`,
);
throw new Error(
error instanceof Error ? error.message : "Error restoring mongo backup",
);
}
};

View File

@@ -3,3 +3,4 @@ export { restoreMySqlBackup } from "./mysql";
export { restoreMariadbBackup } from "./mariadb";
export { restoreMongoBackup } from "./mongo";
export { restoreWebServerBackup } from "./web-server";
export { restoreComposeBackup } from "./compose";

View File

@@ -40,8 +40,6 @@ rclone cat ${rcloneFlags.join(" ")} "${backupPath}" | gunzip | docker exec -i ${
emit(stderr);
} else {
const { stdout, stderr } = await execAsync(command);
console.log("stdout", stdout);
console.log("stderr", stderr);
emit(stdout);
emit(stderr);
}

299
pnpm-lock.yaml generated
View File

@@ -300,10 +300,10 @@ importers:
version: 16.4.5
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
fancy-ansi:
specifier: ^0.1.3
version: 0.1.3
@@ -525,6 +525,85 @@ importers:
specifier: ^1.6.0
version: 1.6.0(@types/node@18.19.42)(terser@5.31.3)
apps/licenses:
dependencies:
'@hono/node-server':
specifier: ^1.12.1
version: 1.12.1
'@hono/zod-validator':
specifier: 0.3.0
version: 0.3.0(hono@4.5.8)(zod@3.24.1)
'@react-email/components':
specifier: ^0.0.21
version: 0.0.21(@types/react@18.3.5)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@react-email/render':
specifier: ^1.0.5
version: 1.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
'@types/pg':
specifier: ^8.11.11
version: 8.11.11
dotenv:
specifier: ^16.3.1
version: 16.4.5
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
hono:
specifier: ^4.5.8
version: 4.5.8
nanoid:
specifier: 5.1.5
version: 5.1.5
nodemailer:
specifier: 6.9.14
version: 6.9.14
pg:
specifier: ^8.14.1
version: 8.14.1
pino:
specifier: 9.4.0
version: 9.4.0
pino-pretty:
specifier: 11.2.2
version: 11.2.2
postgres:
specifier: 3.4.4
version: 3.4.4
react:
specifier: 18.2.0
version: 18.2.0
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
stripe:
specifier: 17.2.0
version: 17.2.0
zod:
specifier: ^3.23.4
version: 3.24.1
devDependencies:
'@types/node':
specifier: ^20.11.17
version: 20.14.10
'@types/nodemailer':
specifier: ^6.4.16
version: 6.4.16
'@types/react':
specifier: 18.3.5
version: 18.3.5
'@types/react-dom':
specifier: 18.3.0
version: 18.3.0
drizzle-kit:
specifier: ^0.30.4
version: 0.30.4
tsx:
specifier: ^4.7.1
version: 4.16.2
typescript:
specifier: ^5.4.2
version: 5.7.2
apps/schedules:
dependencies:
'@dokploy/server':
@@ -544,7 +623,7 @@ importers:
version: 16.4.5
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
hono:
specifier: ^4.5.8
version: 4.5.8
@@ -659,13 +738,13 @@ importers:
version: 16.4.5
drizzle-dbml-generator:
specifier: 0.10.0
version: 0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))
version: 0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))
drizzle-orm:
specifier: ^0.39.1
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
version: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-zod:
specifier: 0.5.1
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
version: 0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8)
hi-base32:
specifier: ^0.5.1
version: 0.5.1
@@ -3075,6 +3154,13 @@ packages:
react: ^18.2.0
react-dom: ^18.2.0
'@react-email/render@1.0.5':
resolution: {integrity: sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ==}
engines: {node: '>=18.0.0'}
peerDependencies:
react: ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
'@react-email/row@0.0.8':
resolution: {integrity: sha512-JsB6pxs/ZyjYpEML3nbwJRGAerjcN/Pa/QG48XUwnT/MioDWrUuyQuefw+CwCrSUZ2P1IDrv2tUD3/E3xzcoKw==}
engines: {node: '>=18.0.0'}
@@ -3533,6 +3619,9 @@ packages:
'@types/nodemailer@6.4.16':
resolution: {integrity: sha512-uz6hN6Pp0upXMcilM61CoKyjT7sskBoOWpptkjjJp8jIMlTdc3xG01U7proKkXzruMS4hS0zqtHNkNPFB20rKQ==}
'@types/pg@8.11.11':
resolution: {integrity: sha512-kGT1qKM8wJQ5qlawUrEkXgvMSXoV213KfMGXcwfDwUIfUHXqXYXOfS1nE1LINRJVVVx5wCm70XnFlMHaIcQAfw==}
'@types/prop-types@15.7.12':
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
@@ -5766,6 +5855,11 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
nanoid@5.1.5:
resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==}
engines: {node: ^18 || >=20}
hasBin: true
nanostores@0.11.3:
resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==}
engines: {node: ^18.0.0 || >=20.0.0}
@@ -5928,6 +6022,9 @@ packages:
resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==}
engines: {node: '>= 0.4'}
obuf@1.1.2:
resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==}
octokit@3.1.2:
resolution: {integrity: sha512-MG5qmrTL5y8KYwFgE1A4JWmgfQBaIETE/lOlfwNYx1QOtCQHGVxkRJmdUJltFc1HVn73d61TlMhMyNTOtMl+ng==}
engines: {node: '>= 18'}
@@ -6072,6 +6169,48 @@ packages:
peberminta@0.9.0:
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
pg-cloudflare@1.1.1:
resolution: {integrity: sha512-xWPagP/4B6BgFO+EKz3JONXv3YDgvkbVrGw2mTo3D6tVDQRh1e7cqVGvyR3BE+eQgAvx1XhW/iEASj4/jCWl3Q==}
pg-connection-string@2.7.0:
resolution: {integrity: sha512-PI2W9mv53rXJQEOb8xNR8lH7Hr+EKa6oJa38zsK0S/ky2er16ios1wLKhZyxzD7jUReiWokc9WK5nxSnC7W1TA==}
pg-int8@1.0.1:
resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
engines: {node: '>=4.0.0'}
pg-numeric@1.0.2:
resolution: {integrity: sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw==}
engines: {node: '>=4'}
pg-pool@3.8.0:
resolution: {integrity: sha512-VBw3jiVm6ZOdLBTIcXLNdSotb6Iy3uOCwDGFAksZCXmi10nyRvnP2v3jl4d+IsLYRyXf6o9hIm/ZtUzlByNUdw==}
peerDependencies:
pg: '>=8.0'
pg-protocol@1.8.0:
resolution: {integrity: sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==}
pg-types@2.2.0:
resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==}
engines: {node: '>=4'}
pg-types@4.0.2:
resolution: {integrity: sha512-cRL3JpS3lKMGsKaWndugWQoLOCoP+Cic8oseVcbr0qhPzYD5DWXK+RZ9LY9wxRf7RQia4SCwQlXk0q6FCPrVng==}
engines: {node: '>=10'}
pg@8.14.1:
resolution: {integrity: sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==}
engines: {node: '>= 8.0.0'}
peerDependencies:
pg-native: '>=3.0.1'
peerDependenciesMeta:
pg-native:
optional: true
pgpass@1.0.5:
resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==}
picocolors@1.0.1:
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
@@ -6166,6 +6305,41 @@ packages:
resolution: {integrity: sha512-YF2kKIUzAofPMpfH6hOi2cGnv/HrUlfucspc7pDyvv7kGdqXrfj8SCl/t8owkEgKEuu8ZcRjSOxFxVLqwChZ2Q==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
postgres-array@3.0.4:
resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==}
engines: {node: '>=12'}
postgres-bytea@1.0.0:
resolution: {integrity: sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==}
engines: {node: '>=0.10.0'}
postgres-bytea@3.0.0:
resolution: {integrity: sha512-CNd4jim9RFPkObHSjVHlVrxoVQXz7quwNFpz7RY1okNNme49+sVyiTvTRobiLV548Hx/hb1BG+iE7h9493WzFw==}
engines: {node: '>= 6'}
postgres-date@1.0.7:
resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==}
engines: {node: '>=0.10.0'}
postgres-date@2.1.0:
resolution: {integrity: sha512-K7Juri8gtgXVcDfZttFKVmhglp7epKb1K4pgrkLxehjqkrgPhfG6OO8LHLkfaqkbpjNRnra018XwAr1yQFWGcA==}
engines: {node: '>=12'}
postgres-interval@1.2.0:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
postgres-interval@3.0.0:
resolution: {integrity: sha512-BSNDnbyZCXSxgA+1f5UU2GmwhoI0aU5yMxRGO8CdFEcY2BQF9xm/7MqKnYoM1nJDk8nONNWDk9WeSmePFhQdlw==}
engines: {node: '>=12'}
postgres-range@1.1.4:
resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==}
postgres@3.4.4:
resolution: {integrity: sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==}
engines: {node: '>=12'}
@@ -6175,6 +6349,11 @@ packages:
engines: {node: '>=10'}
hasBin: true
prettier@3.4.2:
resolution: {integrity: sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ==}
engines: {node: '>=14'}
hasBin: true
pretty-format@29.7.0:
resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -8129,6 +8308,11 @@ snapshots:
hono: 4.5.8
zod: 3.23.8
'@hono/zod-validator@0.3.0(hono@4.5.8)(zod@3.24.1)':
dependencies:
hono: 4.5.8
zod: 3.24.1
'@hookform/resolvers@3.9.0(react-hook-form@7.52.1(react@18.2.0))':
dependencies:
react-hook-form: 7.52.1(react@18.2.0)
@@ -9539,6 +9723,14 @@ snapshots:
react-dom: 18.2.0(react@18.2.0)
react-promise-suspense: 0.3.4
'@react-email/render@1.0.5(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
dependencies:
html-to-text: 9.0.5
prettier: 3.4.2
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
react-promise-suspense: 0.3.4
'@react-email/row@0.0.8(react@18.2.0)':
dependencies:
react: 18.2.0
@@ -10208,6 +10400,12 @@ snapshots:
dependencies:
'@types/node': 20.14.10
'@types/pg@8.11.11':
dependencies:
'@types/node': 20.14.10
pg-protocol: 1.8.0
pg-types: 4.0.2
'@types/prop-types@15.7.12': {}
'@types/qrcode@1.5.5':
@@ -11169,9 +11367,9 @@ snapshots:
drange@1.1.1: {}
drizzle-dbml-generator@0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)):
drizzle-dbml-generator@0.10.0(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)):
dependencies:
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-kit@0.30.4:
dependencies:
@@ -11182,18 +11380,20 @@ snapshots:
transitivePeerDependencies:
- supports-color
drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7):
drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7):
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/pg': 8.11.11
'@types/react': 18.3.5
kysely: 0.27.6
pg: 8.14.1
postgres: 3.4.4
react: 18.2.0
sqlite3: 5.1.7
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
drizzle-zod@0.5.1(drizzle-orm@0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7))(zod@3.23.8):
dependencies:
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/react@18.3.5)(kysely@0.27.6)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
drizzle-orm: 0.39.1(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(@types/react@18.3.5)(kysely@0.27.6)(pg@8.14.1)(postgres@3.4.4)(react@18.2.0)(sqlite3@5.1.7)
zod: 3.23.8
eastasianwidth@0.2.0: {}
@@ -12673,6 +12873,8 @@ snapshots:
nanoid@3.3.8: {}
nanoid@5.1.5: {}
nanostores@0.11.3: {}
napi-build-utils@1.0.2:
@@ -12845,6 +13047,8 @@ snapshots:
object-inspect@1.13.2: {}
obuf@1.1.2: {}
octokit@3.1.2:
dependencies:
'@octokit/app': 14.1.0
@@ -12991,6 +13195,53 @@ snapshots:
peberminta@0.9.0: {}
pg-cloudflare@1.1.1:
optional: true
pg-connection-string@2.7.0: {}
pg-int8@1.0.1: {}
pg-numeric@1.0.2: {}
pg-pool@3.8.0(pg@8.14.1):
dependencies:
pg: 8.14.1
pg-protocol@1.8.0: {}
pg-types@2.2.0:
dependencies:
pg-int8: 1.0.1
postgres-array: 2.0.0
postgres-bytea: 1.0.0
postgres-date: 1.0.7
postgres-interval: 1.2.0
pg-types@4.0.2:
dependencies:
pg-int8: 1.0.1
pg-numeric: 1.0.2
postgres-array: 3.0.4
postgres-bytea: 3.0.0
postgres-date: 2.1.0
postgres-interval: 3.0.0
postgres-range: 1.1.4
pg@8.14.1:
dependencies:
pg-connection-string: 2.7.0
pg-pool: 3.8.0(pg@8.14.1)
pg-protocol: 1.8.0
pg-types: 2.2.0
pgpass: 1.0.5
optionalDependencies:
pg-cloudflare: 1.1.1
pgpass@1.0.5:
dependencies:
split2: 4.2.0
picocolors@1.0.1: {}
picomatch@2.3.1: {}
@@ -13096,10 +13347,32 @@ snapshots:
postcss@8.4.40:
dependencies:
nanoid: 3.3.7
nanoid: 3.3.8
picocolors: 1.0.1
source-map-js: 1.2.0
postgres-array@2.0.0: {}
postgres-array@3.0.4: {}
postgres-bytea@1.0.0: {}
postgres-bytea@3.0.0:
dependencies:
obuf: 1.1.2
postgres-date@1.0.7: {}
postgres-date@2.1.0: {}
postgres-interval@1.2.0:
dependencies:
xtend: 4.0.2
postgres-interval@3.0.0: {}
postgres-range@1.1.4: {}
postgres@3.4.4: {}
prebuild-install@7.1.2:
@@ -13118,6 +13391,8 @@ snapshots:
tunnel-agent: 0.6.0
optional: true
prettier@3.4.2: {}
pretty-format@29.7.0:
dependencies:
'@jest/schemas': 29.6.3

View File

@@ -5,3 +5,4 @@ packages:
- "apps/schedules"
- "apps/models"
- "packages/server"
- "apps/licenses"