Compare commits

..

25 Commits

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -12,7 +12,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
@@ -26,7 +26,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build
@@ -39,7 +39,7 @@ jobs:
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
node-version: 20.9.0
cache: "pnpm"
- run: pnpm install --frozen-lockfile
- run: pnpm run server:build

2
.nvmrc
View File

@@ -1 +1 @@
20.16.0
20.9.0

View File

@@ -52,7 +52,7 @@ feat: add new feature
Before you start, please make the clone based on the `canary` branch, since the `main` branch is the source of truth and should always reflect the latest stable release, also the PRs will be merged to the `canary` branch.
We use Node v20.16.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.16.0 && nvm use` in the root directory.
We use Node v20.9.0 and recommend this specific version. If you have nvm installed, you can run `nvm install 20.9.0 && nvm use` in the root directory.
```bash
git clone https://github.com/dokploy/dokploy.git

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
@@ -30,7 +29,7 @@ WORKDIR /app
# Set production
ENV NODE_ENV=production
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 && rm -rf /var/lib/apt/lists/*
# Copy only the necessary files
COPY --from=build /prod/dokploy/.next ./.next
@@ -50,18 +49,18 @@ RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh && rm
# Install Nixpacks and tsx
# | VERBOSE=1 VERSION=1.21.0 bash
ARG NIXPACKS_VERSION=1.39.0
ARG NIXPACKS_VERSION=1.35.0
RUN curl -sSL https://nixpacks.com/install.sh -o install.sh \
&& chmod +x install.sh \
&& ./install.sh \
&& pnpm install -g tsx
# Install Railpack
ARG RAILPACK_VERSION=0.0.64
ARG RAILPACK_VERSION=0.0.37
RUN curl -sSL https://railpack.com/install.sh | bash
# Install buildpacks
COPY --from=buildpacksio/pack:0.35.0 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
CMD [ "pnpm", "start" ]
CMD [ "pnpm", "start" ]

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
# Build stage
FROM golang:1.21-alpine3.19 AS builder

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -1,4 +1,3 @@
# syntax=docker/dockerfile:1
FROM node:20.9-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"

View File

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

View File

@@ -80,30 +80,12 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
### Premium Supporters 🥇
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://supafort.com/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 20px;">
<img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" height="50"/>
</a>
<a href="https://agentdock.ai/?ref=dokploy" target="_blank" style="display: inline-block; margin-right: 50px;">
<img src=".github/sponsors/agentdock.png" alt="agentdock.ai" height="70"/>
</a>
<div style="display: flex; gap: 30px; flex-wrap: wrap;">
<a href="https://supafort.com/?ref=dokploy" target="_blank"><img src="https://supafort.com/build/q-4Ht4rBZR.webp" alt="Supafort.com" width="190"/></a>
</div>
### Elite Contributors 🥈
<div style="display: flex; align-items: center; gap: 20px;">
<a href="https://americancloud.com/?ref=dokploy" target="_blank" style="display: inline-block; padding: 10px; border-radius: 10px;">
<img src=".github/sponsors/american-cloud.png" alt="AmericanCloud" height="70"/>
</a>
</div>
<!-- Elite Contributors 🥈 -->
<!-- Add Elite Contributors here -->
### Supporting Members 🥉
@@ -115,7 +97,6 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<a href="https://itsdb-center.com?ref=dokploy "><img src=".github/sponsors/its.png" width="65px" alt="Itsdb-center"/></a>
<a href="https://openalternative.co/?ref=dokploy "><img src=".github/sponsors/openalternative.png" width="65px" alt="Openalternative"/></a>
<a href="https://synexa.ai/?ref=dokploy"><img src=".github/sponsors/synexa.png" width="65px" alt="Synexa"/></a>
</div>
@@ -148,6 +129,19 @@ For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
<img src="https://dokploy.com/banner.png" alt="Watch the video" width="400" style="border-radius:20px;"/>
</a>
<!-- ## Supported OS
- Ubuntu 24.04 LTS
- Ubuntu 23.10
- Ubuntu 22.04 LTS
- Ubuntu 20.04 LTS
- Ubuntu 18.04 LTS
- Debian 12
- Debian 11
- Fedora 40
- Centos 9
- Centos 8 -->
## Contributing
Check out the [Contributing Guide](CONTRIBUTING.md) for more information.

View File

@@ -1,28 +0,0 @@
# Dokploy Security Policy
At Dokploy, security is a top priority. We appreciate the help of security researchers and the community in identifying and reporting vulnerabilities.
## How to Report a Vulnerability
If you have discovered a security vulnerability in Dokploy, we ask that you report it responsibly by following these guidelines:
1. **Contact us:** Send an email to [contact@dokploy.com](mailto:contact@dokploy.com).
2. **Provide clear details:** Include as much information as possible to help us understand and reproduce the vulnerability. This should include:
* A clear description of the vulnerability.
* Steps to reproduce the vulnerability.
* Any sample code, screenshots, or videos that might be helpful.
* The potential impact of the vulnerability.
3. **Do not make the vulnerability public:** Please refrain from publicly disclosing the vulnerability until we have had the opportunity to investigate and address it. This is crucial for protecting our users.
4. **Allow us time:** We will endeavor to acknowledge receipt of your report as soon as possible and keep you informed of our progress. The time to resolve the vulnerability may vary depending on its complexity and severity.
## What We Expect From You
* Do not access user data or systems beyond what is necessary to demonstrate the vulnerability.
* Do not perform denial-of-service (DoS) attacks, spamming, or social engineering.
* Do not modify or destroy data that does not belong to you.
## Our Commitment
We are committed to working with you quickly and responsibly to address any legitimate security vulnerability.
Thank you for helping us keep Dokploy secure for everyone.

View File

@@ -9,25 +9,25 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.12.1",
"@hono/zod-validator": "0.3.0",
"@nerimity/mimiqueue": "1.2.3",
"dotenv": "^16.3.1",
"hono": "^4.5.8",
"pino": "9.4.0",
"pino-pretty": "11.2.2",
"@hono/zod-validator": "0.3.0",
"zod": "^3.23.4",
"react": "18.2.0",
"react-dom": "18.2.0",
"@dokploy/server": "workspace:*",
"@hono/node-server": "^1.12.1",
"hono": "^4.5.8",
"dotenv": "^16.3.1",
"redis": "4.7.0",
"zod": "^3.23.4"
"@nerimity/mimiqueue": "1.2.3"
},
"devDependencies": {
"@types/node": "^20.11.17",
"typescript": "^5.4.2",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"tsx": "^4.7.1",
"typescript": "^5.4.2"
"@types/node": "^20.11.17",
"tsx": "^4.7.1"
},
"packageManager": "pnpm@9.5.0"
}

View File

@@ -1 +1 @@
20.16.0
20.9.0

26
apps/dokploy/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:18-slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
FROM base AS build
COPY . /usr/src/app
WORKDIR /usr/src/app
RUN apt-get update && apt-get install -y python3 make g++ git && rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
# Build only the dokploy app
RUN pnpm run dokploy:build
# Deploy only the dokploy app
RUN pnpm deploy --filter=dokploy --prod /prod/dokploy
FROM base AS dokploy
COPY --from=build /prod/dokploy /prod/dokploy
WORKDIR /prod/dokploy
EXPOSE 3000
CMD [ "pnpm", "start" ]

View File

@@ -105,7 +105,6 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,
@@ -121,7 +120,6 @@ const baseApp: ApplicationNested = {
updateConfigSwarm: null,
username: null,
dockerContextPath: null,
rollbackActive: false,
};
describe("unzipDrop using real zip files", () => {
@@ -151,68 +149,67 @@ describe("unzipDrop using real zip files", () => {
} finally {
}
});
it("should correctly extract a zip with a single root folder and a subfolder", async () => {
baseApp.appName = "folderwithfile";
// const appName = "folderwithfile";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
});
it("should correctly extract a zip with multiple root folders", async () => {
baseApp.appName = "two-folders";
// const appName = "two-folders";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1")).toBe(true);
expect(files.some((f) => f.name === "folder2")).toBe(true);
});
it("should correctly extract a zip with a single root with a file", async () => {
baseApp.appName = "nested";
// const appName = "nested";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/nested.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1")).toBe(true);
expect(files.some((f) => f.name === "folder2")).toBe(true);
expect(files.some((f) => f.name === "folder3")).toBe(true);
});
it("should correctly extract a zip with a single root with a folder", async () => {
baseApp.appName = "folder-with-sibling-file";
// const appName = "folder-with-sibling-file";
const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
const zipBuffer = zip.toBuffer();
const file = new File([zipBuffer], "single.zip");
await unzipDrop(file, baseApp);
const files = await fs.readdir(outputPath, { withFileTypes: true });
expect(files.some((f) => f.name === "folder1")).toBe(true);
expect(files.some((f) => f.name === "test.txt")).toBe(true);
});
});
// it("should correctly extract a zip with a single root folder and a subfolder", async () => {
// baseApp.appName = "folderwithfile";
// // const appName = "folderwithfile";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/folder-with-file.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1.txt")).toBe(true);
// });
// it("should correctly extract a zip with multiple root folders", async () => {
// baseApp.appName = "two-folders";
// // const appName = "two-folders";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/two-folders.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1")).toBe(true);
// expect(files.some((f) => f.name === "folder2")).toBe(true);
// });
// it("should correctly extract a zip with a single root with a file", async () => {
// baseApp.appName = "nested";
// // const appName = "nested";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/nested.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1")).toBe(true);
// expect(files.some((f) => f.name === "folder2")).toBe(true);
// expect(files.some((f) => f.name === "folder3")).toBe(true);
// });
// it("should correctly extract a zip with a single root with a folder", async () => {
// baseApp.appName = "folder-with-sibling-file";
// // const appName = "folder-with-sibling-file";
// const outputPath = path.join(APPLICATIONS_PATH, baseApp.appName, "code");
// const zip = new AdmZip("./__test__/drop/zips/folder-with-sibling-file.zip");
// const zipBuffer = zip.toBuffer();
// const file = new File([zipBuffer], "single.zip");
// await unzipDrop(file, baseApp);
// const files = await fs.readdir(outputPath, { withFileTypes: true });
// expect(files.some((f) => f.name === "folder1")).toBe(true);
// expect(files.some((f) => f.name === "test.txt")).toBe(true);
// });
// });

View File

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

View File

@@ -5,7 +5,6 @@ import { createRouterConfig } from "@dokploy/server";
import { expect, test } from "vitest";
const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
herokuVersion: "",
giteaRepository: "",
@@ -86,7 +85,6 @@ const baseApp: ApplicationNested = {
ports: [],
projectId: "",
publishDirectory: null,
isStaticSpa: null,
redirects: [],
refreshToken: "",
registry: null,

View File

@@ -1,5 +1,5 @@
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
import { describe, expect, test } from "vitest";
import { normalizeS3Path } from "@dokploy/server/utils/backups/utils";
describe("normalizeS3Path", () => {
test("should handle empty and whitespace-only prefix", () => {

View File

@@ -263,7 +263,7 @@ export const ShowImport = ({ composeId }: Props) => {
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
className="rounded-lg border bg-card p-2 font-mono text-sm"
>
{env}
</div>
@@ -328,7 +328,7 @@ export const ShowImport = ({ composeId }: Props) => {
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<ScrollArea className="h-[25vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}

View File

@@ -247,7 +247,7 @@ export const UpdateVolume = ({
control={form.control}
name="content"
render={({ field }) => (
<FormItem className="max-w-full max-w-[45rem]">
<FormItem>
<FormLabel>Content</FormLabel>
<FormControl>
<FormControl>
@@ -256,7 +256,7 @@ export const UpdateVolume = ({
placeholder={`NODE_ENV=production
PORT=3000
`}
className="h-96 font-mono w-full"
className="h-96 font-mono"
{...field}
/>
</FormControl>

View File

@@ -1,8 +1,6 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Form,
FormControl,
@@ -64,11 +62,10 @@ const mySchema = z.discriminatedUnion("buildType", [
publishDirectory: z.string().optional(),
}),
z.object({
buildType: z.literal(BuildType.railpack),
buildType: z.literal(BuildType.static),
}),
z.object({
buildType: z.literal(BuildType.static),
isStaticSpa: z.boolean().default(false),
buildType: z.literal(BuildType.railpack),
}),
]);
@@ -85,7 +82,6 @@ interface ApplicationData {
dockerBuildStage?: string | null;
herokuVersion?: string | null;
publishDirectory?: string | null;
isStaticSpa?: boolean | null;
}
function isValidBuildType(value: string): value is BuildType {
@@ -118,18 +114,16 @@ const resetData = (data: ApplicationData): AddTemplate => {
case BuildType.static:
return {
buildType: BuildType.static,
isStaticSpa: data.isStaticSpa ?? false,
};
case BuildType.railpack:
return {
buildType: BuildType.railpack,
};
default: {
default:
const buildType = data.buildType as BuildType;
return {
buildType,
} as AddTemplate;
}
}
};
@@ -179,8 +173,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
data.buildType === BuildType.heroku_buildpacks
? data.herokuVersion
: null,
isStaticSpa:
data.buildType === BuildType.static ? data.isStaticSpa : null,
})
.then(async () => {
toast.success("Build type saved");
@@ -208,22 +200,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
</CardHeader>
<CardContent>
<Form {...form}>
<AlertBlock>
Builders can consume significant memory and CPU resources
(recommended: 4+ GB RAM and 2+ CPU cores). For production
environments, please review our{" "}
<a
href="https://docs.dokploy.com/docs/core/applications/going-production"
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
Production Guide
</a>{" "}
for best practices and optimization recommendations. Builders are
suitable for development and prototyping purposes when you have
sufficient resources available.
</AlertBlock>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4 p-2"
@@ -371,30 +347,6 @@ export const ShowBuildChooseForm = ({ applicationId }: Props) => {
)}
/>
)}
{buildType === BuildType.static && (
<FormField
control={form.control}
name="isStaticSpa"
render={({ field }) => (
<FormItem>
<FormControl>
<div className="flex items-center gap-x-2 p-2">
<Checkbox
id="checkboxIsStaticSpa"
value={String(field.value)}
checked={field.value}
onCheckedChange={field.onChange}
/>
<FormLabel htmlFor="checkboxIsStaticSpa">
Single Page Application (SPA)
</FormLabel>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import { DateTooltip } from "@/components/shared/date-tooltip";
import { StatusTooltip } from "@/components/shared/status-tooltip";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -10,58 +9,28 @@ import {
CardTitle,
} from "@/components/ui/card";
import { type RouterOutputs, api } from "@/utils/api";
import { Clock, Loader2, RocketIcon, Settings, RefreshCcw } from "lucide-react";
import { RocketIcon } from "lucide-react";
import React, { useEffect, useState } from "react";
import { CancelQueues } from "./cancel-queues";
import { RefreshToken } from "./refresh-token";
import { ShowDeployment } from "./show-deployment";
import { ShowRollbackSettings } from "../rollbacks/show-rollback-settings";
import { DialogAction } from "@/components/shared/dialog-action";
import { toast } from "sonner";
interface Props {
id: string;
type:
| "application"
| "compose"
| "schedule"
| "server"
| "backup"
| "previewDeployment";
refreshToken?: string;
serverId?: string;
applicationId: string;
}
export const formatDuration = (seconds: number) => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
};
export const ShowDeployments = ({
id,
type,
refreshToken,
serverId,
}: Props) => {
export const ShowDeployments = ({ applicationId }: Props) => {
const [activeLog, setActiveLog] = useState<
RouterOutputs["deployment"]["all"][number] | null
>(null);
const { data: deployments, isLoading: isLoadingDeployments } =
api.deployment.allByType.useQuery(
{
id,
type,
},
{
enabled: !!id,
refetchInterval: 1000,
},
);
const { mutateAsync: rollback, isLoading: isRollingBack } =
api.rollback.rollback.useMutation();
const { data } = api.application.one.useQuery({ applicationId });
const { data: deployments } = api.deployment.all.useQuery(
{ applicationId },
{
enabled: !!applicationId,
refetchInterval: 1000,
},
);
const [url, setUrl] = React.useState("");
useEffect(() => {
@@ -69,57 +38,34 @@ export const ShowDeployments = ({
}, []);
return (
<Card className="bg-background border-none">
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-2">
<CardTitle className="text-xl">Deployments</CardTitle>
<CardDescription>
See all the 10 last deployments for this {type}
See all the 10 last deployments for this application
</CardDescription>
</div>
<div className="flex flex-row items-center gap-2">
{(type === "application" || type === "compose") && (
<CancelQueues id={id} type={type} />
)}
{type === "application" && (
<ShowRollbackSettings applicationId={id}>
<Button variant="outline">
Configure Rollbacks <Settings className="size-4" />
</Button>
</ShowRollbackSettings>
)}
</div>
<CancelQueues applicationId={applicationId} />
</CardHeader>
<CardContent className="flex flex-col gap-4">
{refreshToken && (
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the
config of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy${type === "compose" ? "/compose" : ""}/${refreshToken}`}
</span>
{(type === "application" || type === "compose") && (
<RefreshToken id={id} type={type} />
)}
</div>
<div className="flex flex-col gap-2 text-sm">
<span>
If you want to re-deploy this application use this URL in the config
of your git provider or docker
</span>
<div className="flex flex-row items-center gap-2 flex-wrap">
<span>Webhook URL: </span>
<div className="flex flex-row items-center gap-2">
<span className="break-all text-muted-foreground">
{`${url}/api/deploy/${data?.refreshToken}`}
</span>
<RefreshToken applicationId={applicationId} />
</div>
</div>
)}
{isLoadingDeployments ? (
<div className="flex w-full flex-row items-center justify-center gap-3 pt-10 min-h-[25vh]">
<Loader2 className="size-6 text-muted-foreground animate-spin" />
<span className="text-base text-muted-foreground">
Loading deployments...
</span>
</div>
) : deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10 min-h-[25vh]">
</div>
{data?.deployments?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 pt-10">
<RocketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
No deployments found
@@ -150,73 +96,24 @@ export const ShowDeployments = ({
)}
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-sm capitalize text-muted-foreground flex items-center gap-2">
<div className="text-sm capitalize text-muted-foreground">
<DateTooltip date={deployment.createdAt} />
{deployment.startedAt && deployment.finishedAt && (
<Badge
variant="outline"
className="text-[10px] gap-1 flex items-center"
>
<Clock className="size-3" />
{formatDuration(
Math.floor(
(new Date(deployment.finishedAt).getTime() -
new Date(deployment.startedAt).getTime()) /
1000,
),
)}
</Badge>
)}
</div>
<div className="flex flex-row items-center gap-2">
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
{deployment?.rollback &&
deployment.status === "done" &&
type === "application" && (
<DialogAction
title="Rollback to this deployment"
description="Are you sure you want to rollback to this deployment?"
type="default"
onClick={async () => {
await rollback({
rollbackId: deployment.rollback.rollbackId,
})
.then(() => {
toast.success(
"Rollback initiated successfully",
);
})
.catch(() => {
toast.error("Error initiating rollback");
});
}}
>
<Button
variant="secondary"
size="sm"
isLoading={isRollingBack}
>
<RefreshCcw className="size-4 text-primary group-hover:text-red-500" />
Rollback
</Button>
</DialogAction>
)}
</div>
<Button
onClick={() => {
setActiveLog(deployment);
}}
>
View
</Button>
</div>
</div>
))}
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={data?.serverId || ""}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

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

View File

@@ -1,5 +1,4 @@
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -8,12 +7,6 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import {
CheckCircle2,
@@ -23,78 +16,49 @@ import {
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
export type ValidationState = {
isLoading: boolean;
isValid?: boolean;
error?: string;
resolvedIp?: string;
message?: string;
cdnProvider?: string;
};
export type ValidationStates = Record<string, ValidationState>;
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 {
id: string;
type: "application" | "compose";
applicationId: string;
}
export const ShowDomains = ({ id, type }: Props) => {
const { data: application } =
type === "application"
? api.application.one.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.compose.one.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
export const ShowDomains = ({ applicationId }: Props) => {
const { data: application } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const { data: ip } = api.settings.getIp.useQuery();
const {
data,
refetch,
isLoading: isLoadingDomains,
} = type === "application"
? api.domain.byApplicationId.useQuery(
{
applicationId: id,
},
{
enabled: !!id,
},
)
: api.domain.byComposeId.useQuery(
{
composeId: id,
},
{
enabled: !!id,
},
);
const { data, refetch } = api.domain.byApplicationId.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync: validateDomain } =
api.domain.validateDomain.useMutation();
const { mutateAsync: deleteDomain, isLoading: isRemoving } =
@@ -120,7 +84,6 @@ export const ShowDomains = ({ id, type }: Props) => {
isValid: result.isValid,
error: result.error,
resolvedIp: result.resolvedIp,
cdnProvider: result.cdnProvider,
message: result.error && result.isValid ? result.error : undefined,
},
}));
@@ -150,7 +113,7 @@ export const ShowDomains = ({ id, type }: Props) => {
<div className="flex flex-row gap-4 flex-wrap">
{data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<AddDomain applicationId={applicationId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -159,22 +122,15 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
</CardHeader>
<CardContent className="flex w-full flex-row gap-4">
{isLoadingDomains ? (
<div className="flex w-full flex-row gap-4 min-h-[40vh] justify-center items-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
<span className="text-base text-muted-foreground">
Loading domains...
</span>
</div>
) : data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3 min-h-[40vh]">
{data?.length === 0 ? (
<div className="flex w-full flex-col items-center justify-center gap-3">
<GlobeIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To access the application it is required to set at least 1
domain
</span>
<div className="flex flex-row gap-4 flex-wrap">
<AddDomain id={id} type={type}>
<AddDomain applicationId={applicationId}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
@@ -182,25 +138,29 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full">
{data?.map((item) => {
const validationState = validationStates[item.host];
return (
<Card
key={item.domainId}
className="relative overflow-hidden w-full border transition-all hover:shadow-md bg-transparent h-fit"
className="relative overflow-hidden w-full border bg-card transition-all hover:shadow-md bg-transparent"
>
<CardContent className="p-6">
<div className="flex flex-col gap-4">
{/* Service & Domain Info */}
<div className="flex items-center justify-between flex-wrap gap-y-2">
{item.serviceName && (
<Badge variant="outline" className="w-fit">
<Server className="size-3 mr-1" />
{item.serviceName}
</Badge>
)}
<div className="flex gap-2 flex-wrap">
<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={{
@@ -215,8 +175,7 @@ export const ShowDomains = ({ id, type }: Props) => {
/>
)}
<AddDomain
id={id}
type={type}
applicationId={applicationId}
domainId={item.domainId}
>
<Button
@@ -257,16 +216,6 @@ export const ShowDomains = ({ id, type }: Props) => {
</DialogAction>
</div>
</div>
<div className="w-full break-all">
<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 min-w-4" />
</Link>
</div>
{/* Domain Details */}
<div className="flex flex-wrap gap-3">
@@ -356,9 +305,8 @@ export const ShowDomains = ({ id, type }: Props) => {
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message &&
validationState.cdnProvider
? `Behind ${validationState.cdnProvider}`
{validationState.message
? "Behind Cloudflare"
: "DNS Valid"}
</>
) : validationState?.error ? (

View File

@@ -136,7 +136,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -435,7 +435,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -454,7 +454,7 @@ export const SaveBitbucketProvider = ({ applicationId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -53,7 +53,7 @@ export const SaveDockerProvider = ({ applicationId }: Props) => {
registryURL: data.registryUrl || "",
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (values: DockerProvider) => {
await mutateAsync({

View File

@@ -17,13 +17,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { KeyRoundIcon, LockIcon, X } from "lucide-react";
@@ -262,7 +262,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -281,7 +281,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -158,7 +158,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules || false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
@@ -381,9 +381,6 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<CommandEmpty>No branch found.</CommandEmpty>
<CommandGroup>
{branches && branches.length === 0 && (
<CommandItem>No branches found.</CommandItem>
)}
{branches?.map((branch: GiteaBranch) => (
<CommandItem
value={branch.name}
@@ -470,7 +467,7 @@ export const SaveGiteaProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -134,7 +134,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -474,7 +474,7 @@ export const SaveGithubProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -141,7 +141,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.applicationId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -452,7 +452,7 @@ export const SaveGitlabProvider = ({ applicationId }: Props) => {
<div className="flex gap-2">
<FormControl>
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -13,14 +13,12 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { GitBranch, Loader2, UploadCloud } from "lucide-react";
import { GitBranch, UploadCloud } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { SaveBitbucketProvider } from "./save-bitbucket-provider";
import { SaveDragNDrop } from "./save-drag-n-drop";
import { SaveGitlabProvider } from "./save-gitlab-provider";
import { UnauthorizedGitProvider } from "./unauthorized-git-provider";
type TabState =
| "github"
@@ -36,100 +34,14 @@ interface Props {
}
export const ShowProviderForm = ({ applicationId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: application, refetch } = api.application.one.useQuery({
applicationId,
});
const { mutateAsync: disconnectGitProvider } =
api.application.disconnectGitProvider.useMutation();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { data: application } = api.application.one.useQuery({ applicationId });
const [tab, setSab] = useState<TabState>(application?.sourceType || "github");
const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
const handleDisconnect = async () => {
try {
await disconnectGitProvider({ applicationId });
toast.success("Repository disconnected successfully");
await refetch();
} catch (error) {
toast.error(
`Failed to disconnect repository: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
};
if (isLoading) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
// Check if user doesn't have access to the current git provider
if (
application &&
!application.hasGitProviderAccess &&
application.sourceType !== "docker" &&
application.sourceType !== "drop"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={application}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -211,7 +123,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -231,7 +143,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -251,7 +163,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -271,7 +183,7 @@ export const ShowProviderForm = ({ applicationId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProvider applicationId={applicationId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

@@ -1,149 +0,0 @@
import {
BitbucketIcon,
GitIcon,
GiteaIcon,
GithubIcon,
GitlabIcon,
} from "@/components/icons/data-tools-icons";
import { DialogAction } from "@/components/shared/dialog-action";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import type { RouterOutputs } from "@/utils/api";
import { AlertCircle, GitBranch, Unlink } from "lucide-react";
interface Props {
service:
| RouterOutputs["application"]["one"]
| RouterOutputs["compose"]["one"];
onDisconnect: () => void;
}
export const UnauthorizedGitProvider = ({ service, onDisconnect }: Props) => {
const getProviderIcon = (sourceType: string) => {
switch (sourceType) {
case "github":
return <GithubIcon className="size-5 text-muted-foreground" />;
case "gitlab":
return <GitlabIcon className="size-5 text-muted-foreground" />;
case "bitbucket":
return <BitbucketIcon className="size-5 text-muted-foreground" />;
case "gitea":
return <GiteaIcon className="size-5 text-muted-foreground" />;
case "git":
return <GitIcon className="size-5 text-muted-foreground" />;
default:
return <GitBranch className="size-5 text-muted-foreground" />;
}
};
const getRepositoryInfo = () => {
switch (service.sourceType) {
case "github":
return {
repo: service.repository,
branch: service.branch,
owner: service.owner,
};
case "gitlab":
return {
repo: service.gitlabRepository,
branch: service.gitlabBranch,
owner: service.gitlabOwner,
};
case "bitbucket":
return {
repo: service.bitbucketRepository,
branch: service.bitbucketBranch,
owner: service.bitbucketOwner,
};
case "gitea":
return {
repo: service.giteaRepository,
branch: service.giteaBranch,
owner: service.giteaOwner,
};
case "git":
return {
repo: service.customGitUrl,
branch: service.customGitBranch,
owner: null,
};
default:
return { repo: null, branch: null, owner: null };
}
};
const { repo, branch, owner } = getRepositoryInfo();
return (
<div className="space-y-4">
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
This application is connected to a {service.sourceType} repository
through a git provider that you don't have access to. You can see
basic repository information below, but cannot modify the
configuration.
</AlertDescription>
</Alert>
<Card className="border-dashed border-2 border-muted-foreground/20 bg-transparent">
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getProviderIcon(service.sourceType)}
<span className="capitalize text-sm font-medium">
{service.sourceType} Repository
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{owner && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Owner:
</span>
<p className="text-sm">{owner}</p>
</div>
)}
{repo && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Repository:
</span>
<p className="text-sm">{repo}</p>
</div>
)}
{branch && (
<div>
<span className="text-sm font-medium text-muted-foreground">
Branch:
</span>
<p className="text-sm">{branch}</p>
</div>
)}
<div className="pt-4 border-t">
<DialogAction
title="Disconnect Repository"
description="Are you sure you want to disconnect this repository?"
type="default"
onClick={async () => {
onDisconnect();
}}
>
<Button variant="secondary" className="w-full">
<Unlink className="size-4 mr-2" />
Disconnect Repository
</Button>
</DialogAction>
<p className="text-xs text-muted-foreground mt-2">
Disconnecting will allow you to configure a new repository with
your own git providers.
</p>
</div>
</CardContent>
</Card>
</div>
);
};

View File

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

View File

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

View File

@@ -1,123 +0,0 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
} from "@/components/ui/form";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
const formSchema = z.object({
rollbackActive: z.boolean(),
});
type FormValues = z.infer<typeof formSchema>;
interface Props {
applicationId: string;
children?: React.ReactNode;
}
export const ShowRollbackSettings = ({ applicationId, children }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: application, refetch } = api.application.one.useQuery(
{
applicationId,
},
{
enabled: !!applicationId,
},
);
const { mutateAsync: updateApplication, isLoading } =
api.application.update.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
rollbackActive: application?.rollbackActive ?? false,
},
});
const onSubmit = async (data: FormValues) => {
await updateApplication({
applicationId,
rollbackActive: data.rollbackActive,
})
.then(() => {
toast.success("Rollback settings updated");
setIsOpen(false);
refetch();
})
.catch(() => {
toast.error("Failed to update rollback settings");
});
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rollback Settings</DialogTitle>
<DialogDescription>
Configure how rollbacks work for this application
</DialogDescription>
<AlertBlock>
Having rollbacks enabled increases storage usage. Be careful with
this option. Note that manually cleaning the cache may delete
rollback images, making them unavailable for future rollbacks.
</AlertBlock>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="rollbackActive"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">
Enable Rollbacks
</FormLabel>
<FormDescription>
Allow rolling back to previous deployments
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Button type="submit" className="w-full" isLoading={isLoading}>
Save Settings
</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
@@ -8,6 +6,8 @@ import {
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";

View File

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

View File

@@ -44,10 +44,8 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
resolver: zodResolver(AddComposeFile),
});
const composeFile = form.watch("composeFile");
useEffect(() => {
if (data && !composeFile) {
if (data) {
form.reset({
composeFile: data.composeFile || "",
});

View File

@@ -136,7 +136,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: BitbucketProvider) => {
await mutateAsync({
@@ -437,7 +437,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -456,7 +456,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -263,7 +263,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -282,7 +282,7 @@ export const SaveGitProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -142,7 +142,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GiteaProvider) => {
await mutateAsync({
@@ -437,7 +437,7 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();

View File

@@ -134,7 +134,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GithubProvider) => {
await mutateAsync({
@@ -474,7 +474,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -496,7 +496,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -142,7 +142,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
enableSubmodules: data.enableSubmodules ?? false,
});
}
}, [form.reset, data?.composeId, form]);
}, [form.reset, data, form]);
const onSubmit = async (data: GitlabProvider) => {
await mutateAsync({
@@ -453,7 +453,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormControl>
<div className="flex gap-2">
<Input
placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"
placeholder="Enter a path to watch (e.g., src/*, dist/*)"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
@@ -472,7 +472,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="Enter a path to watch (e.g., src/**, dist/*.js)"]',
'input[placeholder="Enter a path to watch (e.g., src/*, dist/*)"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value) {

View File

@@ -8,7 +8,7 @@ import {
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { api } from "@/utils/api";
import { CodeIcon, GitBranch, Loader2 } from "lucide-react";
import { CodeIcon, GitBranch } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import { ComposeFileEditor } from "../compose-file-editor";
@@ -18,8 +18,6 @@ import { SaveGitProviderCompose } from "./save-git-provider-compose";
import { SaveGiteaProviderCompose } from "./save-gitea-provider-compose";
import { SaveGithubProviderCompose } from "./save-github-provider-compose";
import { SaveGitlabProviderCompose } from "./save-gitlab-provider-compose";
import { UnauthorizedGitProvider } from "@/components/dashboard/application/general/generic/unauthorized-git-provider";
import { toast } from "sonner";
type TabState = "github" | "git" | "raw" | "gitlab" | "bitbucket" | "gitea";
interface Props {
@@ -27,97 +25,15 @@ interface Props {
}
export const ShowProviderFormCompose = ({ composeId }: Props) => {
const { data: githubProviders, isLoading: isLoadingGithub } =
api.github.githubProviders.useQuery();
const { data: gitlabProviders, isLoading: isLoadingGitlab } =
api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders, isLoading: isLoadingBitbucket } =
const { data: githubProviders } = api.github.githubProviders.useQuery();
const { data: gitlabProviders } = api.gitlab.gitlabProviders.useQuery();
const { data: bitbucketProviders } =
api.bitbucket.bitbucketProviders.useQuery();
const { data: giteaProviders, isLoading: isLoadingGitea } =
api.gitea.giteaProviders.useQuery();
const { data: giteaProviders } = api.gitea.giteaProviders.useQuery();
const { mutateAsync: disconnectGitProvider } =
api.compose.disconnectGitProvider.useMutation();
const { data: compose, refetch } = api.compose.one.useQuery({ composeId });
const { data: compose } = api.compose.one.useQuery({ composeId });
const [tab, setSab] = useState<TabState>(compose?.sourceType || "github");
const isLoading =
isLoadingGithub || isLoadingGitlab || isLoadingBitbucket || isLoadingGitea;
const handleDisconnect = async () => {
try {
await disconnectGitProvider({ composeId });
toast.success("Repository disconnected successfully");
await refetch();
} catch (error) {
toast.error(
`Failed to disconnect repository: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
};
if (isLoading) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Select the source of your code
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex min-h-[25vh] items-center justify-center">
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Loading providers...</span>
</div>
</div>
</CardContent>
</Card>
);
}
// Check if user doesn't have access to the current git provider
if (
compose &&
!compose.hasGitProviderAccess &&
compose.sourceType !== "raw"
) {
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
<CardTitle className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<span className="flex flex-col space-y-0.5">Provider</span>
<p className="flex items-center text-sm font-normal text-muted-foreground">
Repository connection through unauthorized provider
</p>
</div>
<div className="hidden space-y-1 text-sm font-normal md:block">
<GitBranch className="size-6 text-muted-foreground" />
</div>
</CardTitle>
</CardHeader>
<CardContent>
<UnauthorizedGitProvider
service={compose}
onDisconnect={handleDisconnect}
/>
</CardContent>
</Card>
);
}
return (
<Card className="group relative w-full bg-transparent">
<CardHeader>
@@ -192,7 +108,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{githubProviders && githubProviders?.length > 0 ? (
<SaveGithubProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GithubIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitHub, you need to configure your account
@@ -212,7 +128,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{gitlabProviders && gitlabProviders?.length > 0 ? (
<SaveGitlabProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GitlabIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using GitLab, you need to configure your account
@@ -232,7 +148,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{bitbucketProviders && bitbucketProviders?.length > 0 ? (
<SaveBitbucketProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<BitbucketIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Bitbucket, you need to configure your account
@@ -252,7 +168,7 @@ export const ShowProviderFormCompose = ({ composeId }: Props) => {
{giteaProviders && giteaProviders?.length > 0 ? (
<SaveGiteaProviderCompose composeId={composeId} />
) : (
<div className="flex flex-col items-center gap-3 min-h-[25vh] justify-center">
<div className="flex flex-col items-center gap-3 min-h-[15vh] justify-center">
<GiteaIcon className="size-8 text-muted-foreground" />
<span className="text-base text-muted-foreground">
To deploy using Gitea, you need to configure your account

View File

@@ -71,8 +71,8 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
isolatedDeployment: formData?.isolatedDeployment || false,
})
.then(async (_data) => {
await randomizeCompose();
await refetch();
randomizeCompose();
refetch();
toast.success("Compose updated");
})
.catch(() => {
@@ -84,10 +84,15 @@ export const IsolatedDeployment = ({ composeId }: Props) => {
await mutateAsync({
composeId,
suffix: data?.appName || "",
}).then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
});
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose Isolated");
})
.catch(() => {
toast.error("Error isolating the compose");
});
};
return (

View File

@@ -77,8 +77,8 @@ export const RandomizeCompose = ({ composeId }: Props) => {
randomize: formData?.randomize || false,
})
.then(async (_data) => {
await randomizeCompose();
await refetch();
randomizeCompose();
refetch();
toast.success("Compose updated");
})
.catch(() => {
@@ -90,10 +90,15 @@ export const RandomizeCompose = ({ composeId }: Props) => {
await mutateAsync({
composeId,
suffix,
}).then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
});
})
.then(async (data) => {
await utils.project.all.invalidate();
setCompose(data);
toast.success("Compose randomized");
})
.catch(() => {
toast.error("Error randomizing the compose");
});
};
return (

View File

@@ -10,7 +10,7 @@ import {
DialogTrigger,
} from "@/components/ui/dialog";
import { api } from "@/utils/api";
import { Loader2, Puzzle, RefreshCw } from "lucide-react";
import { Puzzle, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
@@ -66,50 +66,36 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isLoading ? (
<div className="flex flex-row items-center justify-center min-h-[25rem] border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
</div>
) : compose?.length === 5 ? (
<div className="border p-4 rounded-md flex flex-col items-center justify-center min-h-[25rem]">
<Puzzle className="h-8 w-8 text-muted-foreground mb-2" />
<span className="text-muted-foreground">
No converted compose data available.
</span>
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isLoading}
onClick={() => {
mutateAsync({ composeId })
.then(() => {
refetch();
toast.success("Fetched source type");
})
.catch((err) => {
toast.error("Error fetching source type", {
description: err.message,
});
});
}}
>
Refresh <RefreshCw className="ml-2 h-4 w-4" />
</Button>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</>
)}
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isLoading}
onClick={() => {
mutateAsync({ composeId })
.then(() => {
refetch();
toast.success("Fetched source type");
})
.catch((err) => {
toast.error("Error fetching source type", {
description: err.message,
});
});
}}
>
Refresh <RefreshCw className="ml-2 h-4 w-4" />
</Button>
</div>
<pre>
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
/>
</pre>
</DialogContent>
</Dialog>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { api } from "@/utils/api";
import { Copy, Loader2 } from "lucide-react";
import { useRouter } from "next/router";
@@ -49,7 +48,6 @@ export const DuplicateProject = ({
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [duplicateType, setDuplicateType] = useState("new-project"); // "new-project" or "same-project"
const utils = api.useUtils();
const router = useRouter();
@@ -61,15 +59,9 @@ export const DuplicateProject = ({
api.project.duplicate.useMutation({
onSuccess: async (newProject) => {
await utils.project.all.invalidate();
toast.success(
duplicateType === "new-project"
? "Project duplicated successfully"
: "Services duplicated successfully",
);
toast.success("Project duplicated successfully");
setOpen(false);
if (duplicateType === "new-project") {
router.push(`/dashboard/project/${newProject.projectId}`);
}
router.push(`/dashboard/project/${newProject.projectId}`);
},
onError: (error) => {
toast.error(error.message);
@@ -77,7 +69,7 @@ export const DuplicateProject = ({
});
const handleDuplicate = async () => {
if (duplicateType === "new-project" && !name) {
if (!name) {
toast.error("Project name is required");
return;
}
@@ -91,7 +83,6 @@ export const DuplicateProject = ({
id: service.id,
type: service.type,
})),
duplicateInSameProject: duplicateType === "same-project",
});
};
@@ -104,7 +95,6 @@ export const DuplicateProject = ({
// Reset form when closing
setName("");
setDescription("");
setDuplicateType("new-project");
}
}}
>
@@ -116,54 +106,32 @@ export const DuplicateProject = ({
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Duplicate Services</DialogTitle>
<DialogTitle>Duplicate Project</DialogTitle>
<DialogDescription>
Choose where to duplicate the selected services
Create a new project with the selected services
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label>Duplicate to</Label>
<RadioGroup
value={duplicateType}
onValueChange={setDuplicateType}
className="grid gap-2"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="new-project" id="new-project" />
<Label htmlFor="new-project">New project</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="same-project" id="same-project" />
<Label htmlFor="same-project">Same project</Label>
</div>
</RadioGroup>
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
</div>
{duplicateType === "new-project" && (
<>
<div className="grid gap-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="New project name"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
</>
)}
<div className="grid gap-2">
<Label htmlFor="description">Description</Label>
<Input
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Project description (optional)"
/>
</div>
<div className="grid gap-2">
<Label>Selected services to duplicate</Label>
@@ -191,14 +159,10 @@ export const DuplicateProject = ({
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{duplicateType === "new-project"
? "Duplicating project..."
: "Duplicating services..."}
Duplicating...
</>
) : duplicateType === "new-project" ? (
"Duplicate project"
) : (
"Duplicate services"
"Duplicate"
)}
</Button>
</DialogFooter>

View File

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

View File

@@ -1,93 +1,80 @@
// @ts-nocheck
export const extractExpirationDate = (certData: string): Date | null => {
try {
// Decode PEM base64 to DER binary
const b64 = certData.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
const binStr = atob(b64);
const der = new Uint8Array(binStr.length);
for (let i = 0; i < binStr.length; i++) {
der[i] = binStr.charCodeAt(i);
const match = certData.match(
/-----BEGIN CERTIFICATE-----\s*([^-]+)\s*-----END CERTIFICATE-----/,
);
if (!match?.[1]) return null;
const base64Cert = match[1].replace(/\s/g, "");
const binaryStr = window.atob(base64Cert);
const bytes = new Uint8Array(binaryStr.length);
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i);
}
let offset = 0;
// ASN.1 tag for UTCTime is 0x17, GeneralizedTime is 0x18
// We need to find the second occurrence of either tag as it's the "not after" (expiration) date
let dateFound = false;
for (let i = 0; i < bytes.length - 2; i++) {
// Look for sequence containing validity period (0x30)
if (bytes[i] === 0x30) {
// Check next bytes for UTCTime or GeneralizedTime
let j = i + 1;
while (j < bytes.length - 2) {
if (bytes[j] === 0x17 || bytes[j] === 0x18) {
const dateType = bytes[j];
const dateLength = bytes[j + 1];
if (typeof dateLength === "undefined") break;
// Helper: read ASN.1 length field
function readLength(pos: number): { length: number; offset: number } {
// biome-ignore lint/style/noParameterAssign: <explanation>
let len = der[pos++];
if (len & 0x80) {
const bytes = len & 0x7f;
len = 0;
for (let i = 0; i < bytes; i++) {
// biome-ignore lint/style/noParameterAssign: <explanation>
len = (len << 8) + der[pos++];
if (!dateFound) {
// Skip "not before" date
dateFound = true;
j += dateLength + 2;
continue;
}
// Found "not after" date
let dateStr = "";
for (let k = 0; k < dateLength; k++) {
const charCode = bytes[j + 2 + k];
if (typeof charCode === "undefined") continue;
dateStr += String.fromCharCode(charCode);
}
if (dateType === 0x17) {
// UTCTime (YYMMDDhhmmssZ)
const year = Number.parseInt(dateStr.slice(0, 2));
const fullYear = year >= 50 ? 1900 + year : 2000 + year;
return new Date(
Date.UTC(
fullYear,
Number.parseInt(dateStr.slice(2, 4)) - 1,
Number.parseInt(dateStr.slice(4, 6)),
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
),
);
}
// GeneralizedTime (YYYYMMDDhhmmssZ)
return new Date(
Date.UTC(
Number.parseInt(dateStr.slice(0, 4)),
Number.parseInt(dateStr.slice(4, 6)) - 1,
Number.parseInt(dateStr.slice(6, 8)),
Number.parseInt(dateStr.slice(8, 10)),
Number.parseInt(dateStr.slice(10, 12)),
Number.parseInt(dateStr.slice(12, 14)),
),
);
}
j++;
}
}
return { length: len, offset: pos };
}
// Skip the outer certificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected sequence");
({ offset } = readLength(offset));
// Skip tbsCertificate sequence
if (der[offset++] !== 0x30) throw new Error("Expected tbsCertificate");
({ offset } = readLength(offset));
// Check for optional version field (context-specific tag [0])
if (der[offset] === 0xa0) {
offset++;
const versionLen = readLength(offset);
offset = versionLen.offset + versionLen.length;
}
// Skip serialNumber, signature, issuer
for (let i = 0; i < 3; i++) {
if (der[offset] !== 0x30 && der[offset] !== 0x02)
throw new Error("Unexpected structure");
offset++;
const fieldLen = readLength(offset);
offset = fieldLen.offset + fieldLen.length;
}
// Validity sequence (notBefore and notAfter)
if (der[offset++] !== 0x30) throw new Error("Expected validity sequence");
const validityLen = readLength(offset);
offset = validityLen.offset;
// notBefore
offset++;
const notBeforeLen = readLength(offset);
offset = notBeforeLen.offset + notBeforeLen.length;
// notAfter
offset++;
const notAfterLen = readLength(offset);
const notAfterStr = new TextDecoder().decode(
der.slice(notAfterLen.offset, notAfterLen.offset + notAfterLen.length),
);
// Parse GeneralizedTime (15 chars) or UTCTime (13 chars)
function parseTime(str: string): Date {
if (str.length === 13) {
// UTCTime YYMMDDhhmmssZ
const year = Number.parseInt(str.slice(0, 2), 10);
const fullYear = year < 50 ? 2000 + year : 1900 + year;
return new Date(
`${fullYear}-${str.slice(2, 4)}-${str.slice(4, 6)}T${str.slice(6, 8)}:${str.slice(8, 10)}:${str.slice(10, 12)}Z`,
);
}
if (str.length === 15) {
// GeneralizedTime YYYYMMDDhhmmssZ
return new Date(
`${str.slice(0, 4)}-${str.slice(4, 6)}-${str.slice(6, 8)}T${str.slice(8, 10)}:${str.slice(10, 12)}:${str.slice(12, 14)}Z`,
);
}
throw new Error("Invalid ASN.1 time format");
}
return parseTime(notAfterStr);
return null;
} catch (error) {
console.error("Error parsing certificate:", error);
return null;

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

@@ -18,7 +18,6 @@ import { useEffect, useState } from "react";
export const AddGithubProvider = () => {
const [isOpen, setIsOpen] = useState(false);
const { data: activeOrganization } = authClient.useActiveOrganization();
const { data: session } = authClient.useSession();
const { data } = api.user.get.useQuery();
const [manifest, setManifest] = useState("");
const [isOrganization, setIsOrganization] = useState(false);
@@ -28,7 +27,7 @@ export const AddGithubProvider = () => {
const url = document.location.origin;
const manifest = JSON.stringify(
{
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}&userId=${session?.user?.id}`,
redirect_url: `${origin}/api/providers/github/setup?organizationId=${activeOrganization?.id}`,
name: `Dokploy-${format(new Date(), "yyyy-MM-dd")}`,
url: origin,
hook_attributes: {

View File

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

View File

@@ -156,67 +156,6 @@ export const HandleServers = ({ serverId }: Props) => {
remotely.
</DialogDescription>
</DialogHeader>
<div>
<p className="text-primary text-sm font-medium">
You will need to purchase or rent a Virtual Private Server (VPS) to
proceed, we recommend to use one of these providers since has been
heavily tested.
</p>
<ul className="list-inside list-disc pl-4 text-sm text-muted-foreground mt-4">
<li>
<a
href="https://www.hostinger.com/vps-hosting?REFERRALCODE=1SIUMAURICI97"
className="text-link underline"
>
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"
className="text-link underline"
>
DigitalOcean - Get $200 Credits
</a>
</li>
<li>
<a
href="https://hetzner.cloud/?ref=vou4fhxJ1W2D"
className="text-link underline"
>
Hetzner - Get 20 Credits
</a>
</li>
<li>
<a
href="https://www.vultr.com/?ref=9679828"
className="text-link underline"
>
Vultr
</a>
</li>
<li>
<a
href="https://www.linode.com/es/pricing/#compute-shared"
className="text-link underline"
>
Linode
</a>
</li>
</ul>
<AlertBlock className="mt-4 px-4">
You are free to use whatever provider, but we recommend to use one
of the above, to avoid issues.
</AlertBlock>
</div>
{!canCreateMoreServers && (
<AlertBlock type="warning">
You cannot create more servers,{" "}

View File

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

View File

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

View File

@@ -40,7 +40,6 @@ import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSchedulesModal } from "./show-schedules-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSuscription } from "./welcome-stripe/welcome-suscription";
@@ -333,10 +332,6 @@ export const ShowServers = () => {
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>

View File

@@ -177,14 +177,6 @@ export const WelcomeSuscription = () => {
Hostinger - Get 20% Discount
</a>
</li>
<li>
<a
href=" https://app.americancloud.com/register?ref=dokploy"
className="text-link underline"
>
American Cloud - Get $20 Credits
</a>
</li>
<li>
<a
href="https://m.do.co/c/db24efd43f35"

View File

@@ -41,7 +41,6 @@ const addInvitation = z.object({
.min(1, "Email is required")
.email({ message: "Invalid email" }),
role: z.enum(["member", "admin"]),
notificationId: z.string().optional(),
});
type AddInvitation = z.infer<typeof addInvitation>;
@@ -50,10 +49,6 @@ export const AddInvitation = () => {
const [open, setOpen] = useState(false);
const utils = api.useUtils();
const [isLoading, setIsLoading] = useState(false);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: emailProviders } =
api.notification.getEmailProviders.useQuery();
const { mutateAsync: sendInvitation } = api.user.sendInvitation.useMutation();
const [error, setError] = useState<string | null>(null);
const { data: activeOrganization } = authClient.useActiveOrganization();
@@ -61,7 +56,6 @@ export const AddInvitation = () => {
defaultValues: {
email: "",
role: "member",
notificationId: "",
},
resolver: zodResolver(addInvitation),
});
@@ -80,20 +74,7 @@ export const AddInvitation = () => {
if (result.error) {
setError(result.error.message || "");
} else {
if (!isCloud && data.notificationId) {
await sendInvitation({
invitationId: result.data.id,
notificationId: data.notificationId || "",
})
.then(() => {
toast.success("Invitation created and email sent");
})
.catch((error: any) => {
toast.error(error.message);
});
} else {
toast.success("Invitation created");
}
toast.success("Invitation created");
setError(null);
setOpen(false);
}
@@ -168,47 +149,6 @@ export const AddInvitation = () => {
);
}}
/>
{!isCloud && (
<FormField
control={form.control}
name="notificationId"
render={({ field }) => {
return (
<FormItem>
<FormLabel>Email Provider</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an email provider" />
</SelectTrigger>
</FormControl>
<SelectContent>
{emailProviders?.map((provider) => (
<SelectItem
key={provider.notificationId}
value={provider.notificationId}
>
{provider.name}
</SelectItem>
))}
<SelectItem value="none" disabled>
None
</SelectItem>
</SelectContent>
</Select>
<FormDescription>
Select the email provider to send the invitation
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}
<DialogFooter className="flex w-full flex-row">
<Button
isLoading={isLoading}

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
import Page from "./side";
interface Props {
children: React.ReactNode;
}
export const ProjectLayout = ({ children }: Props) => {
return <Page>{children}</Page>;
};

View File

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

View File

@@ -1,69 +0,0 @@
import Script from "next/script";
import { useEffect } from "react";
interface ChatwootWidgetProps {
websiteToken: string;
baseUrl?: string;
settings?: {
position?: "left" | "right";
type?: "standard" | "expanded_bubble";
launcherTitle?: string;
darkMode?: boolean;
hideMessageBubble?: boolean;
placement?: "right" | "left";
showPopoutButton?: boolean;
widgetStyle?: "standard" | "bubble";
};
user?: {
identifier: string;
name?: string;
email?: string;
phoneNumber?: string;
avatarUrl?: string;
customAttributes?: Record<string, any>;
identifierHash?: string;
};
}
export const ChatwootWidget = ({
websiteToken,
baseUrl = "https://app.chatwoot.com",
settings = {
position: "right",
type: "standard",
launcherTitle: "Chat with us",
},
user,
}: ChatwootWidgetProps) => {
useEffect(() => {
// Configurar los settings de Chatwoot
window.chatwootSettings = {
position: "right",
};
(window as any).chatwootSDKReady = () => {
window.chatwootSDK?.run({ websiteToken, baseUrl });
const trySetUser = () => {
if (window.$chatwoot && user) {
window.$chatwoot.setUser(user.identifier, {
email: user.email,
name: user.name,
avatar_url: user.avatarUrl,
phone_number: user.phoneNumber,
});
}
};
trySetUser();
};
}, [websiteToken, baseUrl, user, settings]);
return (
<Script
src={`${baseUrl}/packs/js/sdk.js`}
strategy="lazyOnload"
onLoad={() => (window as any).chatwootSDKReady?.()}
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
ALTER TABLE "application" ADD COLUMN "isStaticSpa" boolean;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "user_temp" ALTER COLUMN "logCleanupCron" SET DEFAULT '0 0 * * *';
UPDATE "user_temp" SET "logCleanupCron" = '0 0 * * *' WHERE "logCleanupCron" IS NULL;

View File

@@ -1,14 +0,0 @@
ALTER TABLE "git_provider" ADD COLUMN "userId" text;--> statement-breakpoint
-- Update existing git providers to be owned by the organization owner
-- We can get the owner_id directly from the organization table
UPDATE "git_provider"
SET "userId" = (
SELECT o."owner_id"
FROM "organization" o
WHERE o.id = "git_provider"."organizationId"
);--> statement-breakpoint
-- Now make the column NOT NULL since all rows should have values
ALTER TABLE "git_provider" ALTER COLUMN "userId" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "git_provider" ADD CONSTRAINT "git_provider_userId_user_temp_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user_temp"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,13 +0,0 @@
CREATE TABLE "rollback" (
"rollbackId" text PRIMARY KEY NOT NULL,
"deploymentId" text NOT NULL,
"version" serial NOT NULL,
"image" text,
"createdAt" text NOT NULL,
"fullContext" jsonb
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "rollbackActive" boolean DEFAULT false;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "rollbackId" text;--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_rollbackId_rollback_rollbackId_fk" FOREIGN KEY ("rollbackId") REFERENCES "public"."rollback"("rollbackId") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE set null ON UPDATE no action;

View File

@@ -1,3 +0,0 @@
ALTER TABLE "rollback" DROP CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk";
--> statement-breakpoint
ALTER TABLE "rollback" ADD CONSTRAINT "rollback_deploymentId_deployment_deploymentId_fk" FOREIGN KEY ("deploymentId") REFERENCES "public"."deployment"("deploymentId") ON DELETE cascade ON UPDATE no action;

View File

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

View File

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

View File

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

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