Compare commits

...

70 Commits

Author SHA1 Message Date
Mauricio Siu
e052850b87 Merge pull request #3148 from Dokploy/2938-not-all-ntfy-topics-need-access-tokens
feat: update notification handling to make accessToken optional
2025-12-01 00:47:03 -06:00
Mauricio Siu
e06f5979c3 refactor: streamline notification header construction in sendNtfyNotification
- Consolidated header creation for the notification request, improving code readability and maintainability.
- Made the Authorization header conditional based on the presence of accessToken, enhancing flexibility.
2025-12-01 00:46:35 -06:00
Mauricio Siu
6b346d30ee feat: update notification handling to make accessToken optional
- Modified the notification schema to allow accessToken to be optional, enhancing flexibility in notification settings.
- Updated related components and database schema to accommodate the change, ensuring backward compatibility.
- Improved handling of accessToken in notification requests and responses, defaulting to null when not provided.
2025-12-01 00:44:53 -06:00
Mauricio Siu
9e98f9ce7f Merge pull request #3147 from Dokploy/Add-search-functionality-to-AI-model-selection-dropdown
feat: enhance AI model selection with popover and search functionality
2025-11-30 23:47:20 -06:00
Mauricio Siu
c8e7aae5c6 feat: enhance AI model selection with popover and search functionality
- Replaced the select component with a popover for model selection, allowing for better user experience.
- Added search capability to filter models, improving accessibility and usability.
- Updated form handling to reset model selection when API URL or API Key changes.
- Ensured proper state management for popover visibility and search input.
2025-11-30 23:46:22 -06:00
Mauricio Siu
75a49790ea refactor: simplify useEffect dependencies in AddCommand and ShowCustomCommand components
- Updated the useEffect hooks to remove unnecessary dependencies, improving performance and readability.
- Ensured that the form resets correctly based on the presence of data.
2025-11-30 19:07:59 -06:00
Mauricio Siu
716e8b351f Merge pull request #2735 from VivekKavala/feat/top-loading-progress-bar
feat: Add top-loading progress bar
2025-11-30 18:49:18 -06:00
Mauricio Siu
e993955f5a Merge branch 'canary' into feat/top-loading-progress-bar 2025-11-30 18:47:54 -06:00
Mauricio Siu
caf0aa6a12 Merge pull request #3050 from andresousadotpt/fix/update-cdn-ip-ranges
fix(bunny.net): Update CDN IP ranges
2025-11-30 14:58:31 -06:00
Mauricio Siu
21eb185431 Merge pull request #3137 from Dokploy/feat/add-warning-to-redeploy-on-domain-changes
feat: add informational alert for domain changes in AddDomain component
2025-11-30 14:56:50 -06:00
Mauricio Siu
bb3f73851a Merge pull request #3035 from iamsims/fix/websocket-keepalive-logs-3033
fix: prevent WebSocket timeout in container logs after 60s of inactivity
2025-11-30 12:52:48 -06:00
Mauricio Siu
40949f2a8f Merge pull request #3146 from Dokploy/3013-trim-domain-input-in-server-domain-assignation
feat: enhance domain validation by trimming whitespace from host input
2025-11-30 12:43:31 -06:00
Mauricio Siu
fe7a73baee feat: enhance domain validation by trimming whitespace from host input
- Updated the domain validation schema to ensure the host string does not have leading or trailing spaces.
- Added a refinement to the host field to validate and transform the input accordingly.
- Adjusted the create and update domain functions to trim the host value before saving to the database.
2025-11-30 12:41:42 -06:00
Mauricio Siu
b1505651c2 Merge pull request #3145 from Dokploy/3054-v0257-and-older-request-tab-only-shows-the-current-hour-and-not-the-day-log-stats
feat: set default date range to last 3 days in ShowRequests component
2025-11-30 12:34:40 -06:00
Mauricio Siu
689c689487 feat: set default date range to last 3 days in ShowRequests component
- Introduced a function to automatically set the date range to the last 3 days upon component initialization.
- Updated the reset button to restore the default date range instead of clearing the dates.
2025-11-30 12:34:12 -06:00
Mauricio Siu
1aac5c1670 Merge pull request #3144 from Dokploy/3141-requests-chart-overflowing
refactor: enhance RequestDistributionChart layout and responsiveness
2025-11-30 12:32:07 -06:00
Mauricio Siu
ea83406f6f refactor: enhance RequestDistributionChart layout and responsiveness
- Updated the chart container to improve layout with a fixed height and overflow handling.
- Adjusted margin settings for better spacing and added support for data overflow in the Y-axis.
- Changed the Area chart type to 'monotone' for smoother transitions in the data representation.
2025-11-30 12:31:27 -06:00
Mauricio Siu
25aecab062 Merge pull request #3142 from Bima42/fix/notification-returns-raw-json-on-email-test
fix(notification): use form validation logic for testing
2025-11-30 12:25:50 -06:00
Mauricio Siu
9e11b802fd Merge pull request #3143 from Dokploy/feat/add-args-to-advanced-command
feat: add support for command arguments in application and database s…
2025-11-30 12:24:19 -06:00
Mauricio Siu
adfe29e10c feat: add args field to application configuration in tests
- Introduced an `args` field in the `baseApp` configuration for both drop and traefik test files to support command arguments in application testing.
- This change aligns with recent updates to application schemas, enhancing the flexibility of command handling in tests.
2025-11-30 12:21:02 -06:00
Mauricio Siu
c1d23b18fb refactor: streamline command and args handling in Redis container configuration
- Updated the Redis container build function to simplify the handling of command and arguments.
- Ensured default command and arguments are set when none are provided, improving robustness.
2025-11-30 12:18:47 -06:00
Mauricio Siu
272a8dbdb2 feat: add support for command arguments in application and database schemas
- Updated the application, mariadb, mongo, mysql, postgres, and redis schemas to include an optional `args` field for command arguments.
- Enhanced the AddCommand and ShowCustomCommand components to handle multiple arguments using a dynamic form.
- Modified the database build functions to incorporate the new `args` parameter when creating containers.
- Added SQL migrations to update the database schema for existing applications and services to accommodate the new `args` field.
2025-11-30 12:13:55 -06:00
Bima42
dc4e8ecdc9 fix: use form validation logic for testing notif 2025-11-30 18:08:37 +01:00
Mauricio Siu
559753eae3 Merge pull request #3134 from usings/fix/timebadge-position
fix: stabilize `TimeBadge` position
2025-11-30 02:22:22 -06:00
Mauricio Siu
2d0669e288 fix: correct path for OpenAPI documentation in sync workflow
- Updated the directory structure in the OpenAPI sync workflow to ensure the openapi.json file is copied to the correct path (apps/docs/public) for proper deployment.
2025-11-30 01:13:30 -06:00
Mauricio Siu
3f12f20e4c Merge pull request #3139 from Dokploy/feat/sync-open-api-website-docs
Feat/sync open api website docs
2025-11-30 01:10:55 -06:00
Mauricio Siu
4907a021a4 fix: update OpenAPI sync workflow to copy file to correct path
- Changed the destination path for copying openapi.json to the apps/docs/public directory to ensure proper deployment of the OpenAPI documentation.
2025-11-30 01:09:38 -06:00
Mauricio Siu
817825e8bd chore: update OpenAPI sync workflow triggers and paths
- Modified the workflow to trigger on pushes to the 'canary' and 'main' branches.
- Re-enabled path filters for specific directories related to the OpenAPI documentation.
- Removed commented-out sections for clarity and improved workflow readability.
2025-11-30 01:05:54 -06:00
Mauricio Siu
0f632e3f55 chore: update OpenAPI sync workflow to always commit changes
- Modified the workflow to always commit the OpenAPI specification to the website repository, even if no changes are detected.
- Enhanced the copy command to force overwrite the existing openapi.json file.
- Improved commit message formatting by allowing empty commits to ensure consistency in the sync process.
2025-11-30 00:56:55 -06:00
autofix-ci[bot]
8728d4b600 [autofix.ci] apply automated fixes 2025-11-30 06:54:10 +00:00
Mauricio Siu
88b4374019 chore: update OpenAPI sync workflow to commit changes
- Re-enabled the steps to commit the generated OpenAPI specification to the website repository.
- Improved checks for changes in the OpenAPI spec before committing.
- Enhanced commit message formatting for clarity and added a timestamp to the commit.
2025-11-30 00:53:38 -06:00
Dokploy Bot
b91cb6cb5e chore: update OpenAPI specification [skip ci]
Generated from commit: c8277f6573

Triggered by: push
2025-11-30 06:51:50 +00:00
autofix-ci[bot]
c8277f6573 [autofix.ci] apply automated fixes 2025-11-30 06:51:18 +00:00
Mauricio Siu
24c216e61a chore: comment out OpenAPI spec commit steps in workflow
- Commented out the steps related to committing the OpenAPI specification in the GitHub Actions workflow to prevent automatic commits.
- Adjusted the condition for triggering the website sync based on changes detected in the OpenAPI spec.
2025-11-30 00:50:52 -06:00
Dokploy Bot
5c630e7ad7 chore: update OpenAPI specification [skip ci]
Generated from commit: c0dec0ed20

Triggered by: push
2025-11-30 06:45:19 +00:00
Mauricio Siu
c0dec0ed20 chore: update Node.js and pnpm setup in OpenAPI sync workflow
- Upgraded the pnpm action to version 4 for improved performance.
- Specified Node.js version to 20.16.0 and enabled caching for pnpm to optimize dependency installation.
2025-11-30 00:44:25 -06:00
Mauricio Siu
7d9806a050 chore: improve commit message formatting in OpenAPI sync workflow
- Updated the GitHub Actions workflow to format the commit message for OpenAPI specification updates using multiple `-m` flags for better readability and clarity.
- Added `continue-on-error: true` to the repository dispatch step to ensure the workflow proceeds even if the dispatch fails.
2025-11-30 00:42:23 -06:00
Mauricio Siu
96e7b39e3c chore: trigger OpenAPI sync workflow 2025-11-30 00:38:44 -06:00
Mauricio Siu
ded16f39af chore: remove unnecessary whitespace in OpenAPI documentation sync workflow
- Eliminated an empty line in the GitHub Actions workflow file for syncing OpenAPI documentation to improve readability and maintain consistency.
2025-11-30 00:36:16 -06:00
Mauricio Siu
d8e521e4dc chore: comment out paths in OpenAPI documentation sync workflow
- Commented out the paths section in the GitHub Actions workflow for syncing OpenAPI documentation to allow for more flexible triggering without specific path constraints.
2025-11-30 00:31:50 -06:00
Mauricio Siu
67643fe088 chore: update GitHub Actions workflow branch for OpenAPI documentation sync
- Changed the branch trigger for the OpenAPI documentation sync workflow from 'canary' to 'feat/sync-open-api-website-docs' to align with the new feature branch naming convention.
2025-11-30 00:31:07 -06:00
Mauricio Siu
aab982b431 feat: add OpenAPI generation script and workflow
- Introduced a new script to generate OpenAPI specifications for the Dokploy API.
- Added a GitHub Actions workflow to automate the generation and syncing of OpenAPI documentation upon changes in the API routers.
- Updated package.json files to include new commands for generating OpenAPI specifications.
- Added openapi.json to .gitignore to prevent accidental commits of generated files.
2025-11-30 00:30:40 -06:00
Mauricio Siu
362416afa8 Merge pull request #3138 from Dokploy/711-custom-build-server
711 custom build server
2025-11-29 23:54:16 -06:00
Mauricio Siu
035f8835cf fix: use unified server ID for deployment commands in rebuildApplication
- Updated the rebuildApplication function to utilize a consistent server ID by incorporating buildServerId where available.
- Refactored deployment command execution to ensure compatibility with the new server ID logic, enhancing deployment reliability.
2025-11-29 23:25:01 -06:00
Mauricio Siu
8cff84ef54 feat: add build server and registry configurations to database schema
- Created a new SQL file to define the serverType enum and added buildServerId and buildRegistryId columns to the application and deployment tables.
- Established foreign key constraints for buildServerId and buildRegistryId to ensure referential integrity with the server and registry tables.
- Updated the journal and snapshot files to reflect these schema changes, enhancing the overall build server functionality.
2025-11-29 23:09:54 -06:00
Mauricio Siu
742ca00d3d refactor: remove obsolete SQL files and snapshots related to server and application schema updates
- Deleted SQL files for cold server type, careless Odin application properties, and faulty synchronization constraints.
- Removed corresponding snapshot files to maintain consistency in the database schema versioning.
2025-11-29 23:09:43 -06:00
Mauricio Siu
3481da9b0e feat: add build server properties to application models and enhance server creation
- Introduced new properties (buildServerId, buildRegistryId, buildRegistry) in the ApplicationNested model for better build server configuration.
- Updated the CreateServer component to include a default server type for deployments.
- Improved logging messages for clarity during the image upload process.
2025-11-29 23:05:26 -06:00
Mauricio Siu
15634c9f10 feat: integrate build server functionality and enhance deployment process
- Added support for build server configuration in the application dashboard, including new UI elements and validation.
- Updated database schema to include build server associations and foreign key constraints.
- Enhanced deployment logic to utilize build server IDs, improving deployment flexibility.
- Improved logging and user feedback during the build and deployment processes, including new alerts for image download status.
- Refactored application and deployment services to accommodate build server integration.
2025-11-29 23:04:02 -06:00
autofix-ci[bot]
704582f6de [autofix.ci] apply automated fixes 2025-11-30 05:01:51 +00:00
Mauricio Siu
65d962efc8 feat: enhance server validation and setup for build servers
- Added logic to differentiate between build servers and regular servers in the ValidateServer component.
- Updated the server setup process to conditionally install dependencies based on server type.
- Enhanced the default command generation to include specific commands for build servers.
- Improved UI feedback to reflect the server type in the dashboard.
2025-11-29 21:46:12 -06:00
Mauricio Siu
78d2e13dc8 feat: add build server configuration to application dashboard
- Introduced a new component for configuring build servers in the application dashboard.
- Implemented form validation using Zod and integrated API calls for updating build server settings.
- Enhanced server and application schemas to support build server and registry associations.
- Updated UI to display build server options and provide user feedback on updates.
2025-11-29 21:22:35 -06:00
Mauricio Siu
28f7fb90c0 feat: add informational alert for domain changes in AddDomain component
- Introduced an info alert in the AddDomain component to remind users to redeploy their compose after making changes to domains, enhancing user awareness and experience.
2025-11-29 20:55:04 -06:00
Joie
8647e7a6b7 fix: stabilize TimeBadge position 2025-11-29 18:19:13 +08:00
Mauricio Siu
cc1620b5fa Merge pull request #3133 from Dokploy/feat/add-test-for-deployments
test: add e2e tests for deployments (nixpacks, dockerfile, git)
2025-11-29 01:16:55 -06:00
Mauricio Siu
27b605f961 refactor: update comments and improve clarity in application real tests
- Translated comments from Spanish to English for better accessibility.
- Enhanced comment clarity to improve understanding of test behavior and expectations.
2025-11-29 01:16:14 -06:00
Mauricio Siu
a72281c018 refactor: enhance StopGracePeriod handling in database builders
- Updated the condition for StopGracePeriod in various database builder files to check for null and undefined values, improving code robustness and clarity.
2025-11-29 01:07:22 -06:00
Mauricio Siu
aa750be036 Merge branch 'canary' into feat/add-test-for-deployments 2025-11-29 01:04:58 -06:00
Mauricio Siu
067777f28e feat: initialize Docker Swarm in CI workflow
- Added a step to initialize Docker Swarm and create an overlay network for testing jobs.
- This enhancement improves the CI environment setup for containerized testing.
2025-11-29 00:55:14 -06:00
Mauricio Siu
f77a67ba33 refactor: improve type safety in application command test mock
- Updated the type definition for the createChainableMock function to enhance type safety.
- Ensured that the returning method in the mock returns a properly typed value.
2025-11-29 00:47:31 -06:00
Mauricio Siu
30d2f38259 feat: enhance CI workflow with Nixpacks and Railpack installation
- Added steps to install Nixpacks and Railpack in the CI workflow for testing jobs.
- Updated the PATH to include build tools for better accessibility during the build process.
- Improved Vitest configuration to ensure proper TypeScript path resolution.
2025-11-29 00:44:44 -06:00
Mauricio Siu
b23ba17a41 Merge pull request #3073 from perinm/fix/stop-grace-period-swarm
fix: apply stop grace period within container spec
2025-11-28 10:42:26 -06:00
Mauricio Siu
218c077255 refactor: simplify StopGracePeriod handling in container specifications
- Updated the handling of StopGracePeriod in various database builders to streamline the condition check, improving code readability and maintainability.
2025-11-28 10:41:33 -06:00
Mauricio Siu
f94d5b9582 Merge pull request #3118 from Dokploy/3007-gitlab-oauth-error-the-requested-scope-is-invalid-due-to-scope-instead-of-scopes-in-oauth-url-v0256
fix: correct query parameter name in GitLab authorization URL
2025-11-26 12:18:25 -05:00
Mauricio Siu
b9d05b00a9 fix: correct query parameter name in GitLab authorization URL 2025-11-26 11:17:28 -06:00
Lucas Manchine
1c652477fb fix: apply stop grace period within container spec 2025-11-20 16:15:52 -03:00
autofix-ci[bot]
a79afe49b4 [autofix.ci] apply automated fixes 2025-11-19 04:46:00 +00:00
Andre Sousa
48503c96c1 fix(bunny.net): Update CDN IP ranges 2025-11-18 22:48:13 +00:00
iamsims
8b13919d3b fix: prevent WebSocket timeout in container logs after 60s of inactivity
Fixes #3033

The container logs WebSocket connection was closing after approximately
60 seconds of inactivity with error code 1006 (abnormal closure).
This required users to manually refresh the page to re-establish the
connection, making it difficult to monitor containers that produce logs
infrequently.

Changes:
- Added WebSocket ping mechanism sending ping frames every 45 seconds
- Ensures connection stays alive indefinitely during periods of no log activity
- Properly cleanup ping intervals on connection close (3 locations)
- Prevents memory leaks by clearing intervals on error and close events

The browser automatically responds with pong frames, keeping the
connection alive without requiring any client-side changes.
2025-11-16 18:03:45 -06:00
google-labs-jules[bot]
e4aefe7f9d feat: Add theme-aware top-loading progress bar
This commit introduces a top-loading progress bar that provides visual feedback during page transitions, improving the user's navigation experience.

- **Package Integration:** The `nextjs-toploader` package has been added to provide a lightweight and efficient progress bar solution for Next.js.
- **Theme-Aware Color:** The progress bar's color is dynamically set using the `hsl(var(--sidebar-ring))` CSS variable, ensuring it automatically adapts to the application's current theme (light or dark mode).
- **Implementation:** The `NextTopLoader` component is integrated into the main `_app.tsx` file, making it active across the entire application.
2025-10-01 03:35:29 +00:00
google-labs-jules[bot]
15c81a0982 feat: Add top-loading progress bar
Adds a top-loading progress bar that appears during page transitions to improve user experience by providing visual feedback during navigation.

- Integrated the `nextjs-toploader` package, a lightweight and efficient solution for Next.js applications.
- The progress bar is initialized in the main `_app.tsx` file to ensure it's active across the entire application.
- This feature works seamlessly with the Next.js App Router and does not interfere with server-side rendering (SSR).
2025-10-01 03:13:42 +00:00
67 changed files with 42828 additions and 422 deletions

View File

@@ -20,6 +20,32 @@ jobs:
with:
node-version: 20.16.0
cache: "pnpm"
- name: Install Nixpacks
if: matrix.job == 'test'
run: |
export NIXPACKS_VERSION=1.39.0
curl -sSL https://nixpacks.com/install.sh | bash
echo "Nixpacks installed $NIXPACKS_VERSION"
- name: Install Railpack
if: matrix.job == 'test'
run: |
export RAILPACK_VERSION=0.15.0
curl -sSL https://railpack.com/install.sh | bash
echo "Railpack installed $RAILPACK_VERSION"
- name: Add build tools to PATH
if: matrix.job == 'test'
run: echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Initialize Docker Swarm
if: matrix.job == 'test'
run: |
docker swarm init
docker network create --driver overlay dokploy-network || true
echo "✅ Docker Swarm initialized"
- run: pnpm install --frozen-lockfile
- run: pnpm server:build
- run: pnpm ${{ matrix.job }}

70
.github/workflows/sync-openapi-docs.yml vendored Normal file
View File

@@ -0,0 +1,70 @@
name: Generate and Sync OpenAPI
on:
push:
branches:
- canary
- main
paths:
- 'apps/dokploy/server/api/routers/**'
- 'packages/server/src/services/**'
- 'packages/server/src/db/schema/**'
workflow_dispatch:
jobs:
generate-and-commit:
name: Generate OpenAPI and commit to Dokploy repo
runs-on: ubuntu-latest
steps:
- name: Checkout Dokploy repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20.16.0
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Generate OpenAPI specification
run: |
pnpm generate:openapi
# Verifica que se generó correctamente
if [ ! -f openapi.json ]; then
echo "❌ openapi.json not found"
exit 1
fi
echo "✅ OpenAPI specification generated successfully"
- name: Sync to website repository
run: |
# Clona el repositorio de website
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/website.git website-repo
cd website-repo
# Copia el openapi.json al website (sobrescribe)
mkdir -p apps/docs/public
cp -f ../openapi.json apps/docs/public/openapi.json
# Configura git
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
# Agrega y commitea siempre
git add apps/docs/public/openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to website successfully"

2
.gitignore vendored
View File

@@ -13,6 +13,8 @@ node_modules
.env.test.local
.env.production.local
openapi.json
# Testing
coverage

View File

@@ -0,0 +1,276 @@
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
import * as builders from "@dokploy/server/utils/builders";
import * as notifications from "@dokploy/server/utils/notifications/build-success";
import * as execProcess from "@dokploy/server/utils/process/execAsync";
import * as gitProvider from "@dokploy/server/utils/providers/git";
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}] as any),
} as any;
return chain;
};
return {
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn(),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/providers/git", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/providers/git")
>("@dokploy/server/utils/providers/git");
return {
...actual,
getGitCommitInfo: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
execAsync: vi.fn(),
ExecError: class ExecError extends Error {},
}));
vi.mock("@dokploy/server/utils/builders", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/utils/builders")
>("@dokploy/server/utils/builders");
return {
...actual,
mechanizeDockerContainer: vi.fn(),
getBuildCommand: vi.fn(),
};
});
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
import { db } from "@dokploy/server/db";
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
const createMockApplication = (overrides = {}) => ({
applicationId: "test-app-id",
name: "Test App",
appName: "test-app",
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
buildType: "nixpacks" as const,
buildPath: "/astro",
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
...overrides,
});
const createMockDeployment = () => ({
deploymentId: "deployment-id",
logPath: "/tmp/test-deployment.log",
});
describe("deployApplication - Command Generation Tests", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
createMockApplication() as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
createMockDeployment() as any,
);
vi.mocked(execProcess.execAsync).mockResolvedValue({
stdout: "",
stderr: "",
} as any);
vi.mocked(builders.mechanizeDockerContainer).mockResolvedValue(
undefined as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(notifications.sendBuildSuccessNotifications).mockResolvedValue(
undefined as any,
);
vi.mocked(gitProvider.getGitCommitInfo).mockResolvedValue({
message: "test commit",
hash: "abc123",
});
vi.mocked(deploymentService.updateDeployment).mockResolvedValue({} as any);
});
it("should generate correct git clone command for astro example", async () => {
const app = createMockApplication();
const command = await cloneGitRepository(app);
console.log(command);
expect(command).toContain("https://github.com/Dokploy/examples.git");
expect(command).not.toContain("--recurse-submodules");
expect(command).toContain("--branch main");
expect(command).toContain("--depth 1");
expect(command).toContain("git clone");
});
it("should generate git clone with submodules when enabled", async () => {
const app = createMockApplication({ enableSubmodules: true });
const command = await cloneGitRepository(app);
expect(command).toContain("--recurse-submodules");
expect(command).toContain("https://github.com/Dokploy/examples.git");
});
it("should verify nixpacks command is called with correct app", async () => {
const mockNixpacksCommand = "nixpacks build /path/to/app --name test-app";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test deployment",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "nixpacks",
customGitUrl: "https://github.com/Dokploy/examples.git",
buildPath: "/astro",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("nixpacks build"),
);
});
it("should verify railpack command includes correct parameters", async () => {
const mockApp = createMockApplication({ buildType: "railpack" });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
const mockRailpackCommand = "railpack prepare /path/to/app";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockRailpackCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Railpack test",
descriptionLog: "",
});
expect(builders.getBuildCommand).toHaveBeenCalledWith(
expect.objectContaining({
buildType: "railpack",
}),
);
expect(execProcess.execAsync).toHaveBeenCalledWith(
expect.stringContaining("railpack prepare"),
);
});
it("should execute commands in correct order", async () => {
const mockNixpacksCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockNixpacksCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
expect(execCalls.length).toBeGreaterThan(0);
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain("set -e");
expect(fullCommand).toContain("git clone");
expect(fullCommand).toContain("nixpacks build");
});
it("should include log redirection in command", async () => {
const mockCommand = "nixpacks build";
vi.mocked(builders.getBuildCommand).mockReturnValue(mockCommand);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Test",
descriptionLog: "",
});
const execCalls = vi.mocked(execProcess.execAsync).mock.calls;
const fullCommand = execCalls[0]?.[0];
expect(fullCommand).toContain(">> /tmp/test-deployment.log 2>&1");
});
});

View File

@@ -0,0 +1,479 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { ApplicationNested } from "@dokploy/server";
import { paths } from "@dokploy/server/constants";
import { execAsync } from "@dokploy/server/utils/process/execAsync";
import { format } from "date-fns";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const REAL_TEST_TIMEOUT = 180000; // 3 minutes
// Mock ONLY database and notifications
vi.mock("@dokploy/server/db", () => {
const createChainableMock = (): any => {
const chain: any = {
set: vi.fn(() => chain),
where: vi.fn(() => chain),
returning: vi.fn().mockResolvedValue([{}]),
};
return chain;
};
return {
db: {
select: vi.fn(),
insert: vi.fn(),
update: vi.fn(() => createChainableMock()),
delete: vi.fn(),
query: {
applications: {
findFirst: vi.fn(),
},
},
},
};
});
vi.mock("@dokploy/server/services/application", async () => {
const actual = await vi.importActual<
typeof import("@dokploy/server/services/application")
>("@dokploy/server/services/application");
return {
...actual,
findApplicationById: vi.fn(),
updateApplicationStatus: vi.fn(),
};
});
vi.mock("@dokploy/server/services/admin", () => ({
getDokployUrl: vi.fn().mockResolvedValue("http://localhost:3000"),
}));
vi.mock("@dokploy/server/services/deployment", () => ({
createDeployment: vi.fn(),
updateDeploymentStatus: vi.fn(),
updateDeployment: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-success", () => ({
sendBuildSuccessNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/utils/notifications/build-error", () => ({
sendBuildErrorNotifications: vi.fn(),
}));
vi.mock("@dokploy/server/services/rollbacks", () => ({
createRollback: vi.fn(),
}));
// NOT mocked (executed for real):
// - execAsync
// - cloneGitRepository
// - getBuildCommand
// - mechanizeDockerContainer (requires Docker Swarm)
import { db } from "@dokploy/server/db";
import * as adminService from "@dokploy/server/services/admin";
import * as applicationService from "@dokploy/server/services/application";
import { deployApplication } from "@dokploy/server/services/application";
import * as deploymentService from "@dokploy/server/services/deployment";
const createMockApplication = (
overrides: Partial<ApplicationNested> = {},
): ApplicationNested =>
({
applicationId: "test-app-id",
name: "Real Test App",
appName: `real-test-${Date.now()}`,
sourceType: "git" as const,
customGitUrl: "https://github.com/Dokploy/examples.git",
customGitBranch: "main",
customGitSSHKeyId: null,
customGitBuildPath: "/astro",
buildType: "nixpacks" as const,
env: "NODE_ENV=production",
serverId: null,
rollbackActive: false,
enableSubmodules: false,
environmentId: "env-id",
environment: {
projectId: "project-id",
env: "",
name: "production",
project: {
name: "Test Project",
organizationId: "org-id",
env: "",
},
},
domains: [],
mounts: [],
security: [],
redirects: [],
ports: [],
registry: null,
...overrides,
}) as ApplicationNested;
const createMockDeployment = async (appName: string) => {
const { LOGS_PATH } = paths(false); // false = local, no remote server
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
const fileName = `${appName}-${formattedDateTime}.log`;
const logFilePath = path.join(LOGS_PATH, appName, fileName);
// Actually create the log directory
await execAsync(`mkdir -p ${path.dirname(logFilePath)}`);
await execAsync(`echo "Initializing deployment" > ${logFilePath}`);
return {
deploymentId: "deployment-id",
logPath: logFilePath,
};
};
async function cleanupDocker(appName: string) {
try {
await execAsync(`docker stop ${appName} 2>/dev/null || true`);
await execAsync(`docker rm ${appName} 2>/dev/null || true`);
await execAsync(`docker rmi ${appName} 2>/dev/null || true`);
} catch (error) {
console.log("Docker cleanup completed");
}
}
async function cleanupFiles(appName: string) {
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
// Clean cloned code directories
const appPath = path.join(APPLICATIONS_PATH, appName);
await execAsync(`rm -rf ${appPath} 2>/dev/null || true`);
// Clean logs for appName - removes entire folder
const logPath = path.join(LOGS_PATH, appName);
await execAsync(`rm -rf ${logPath} 2>/dev/null || true`);
console.log(`✅ Cleaned up files and logs for ${appName}`);
} catch (error) {
console.error(`⚠️ Error during cleanup for ${appName}:`, error);
}
}
describe(
"deployApplication - REAL Execution Tests",
() => {
let currentAppName: string;
let currentDeployment: any;
const allTestAppNames: string[] = [];
beforeEach(async () => {
vi.clearAllMocks();
currentAppName = `real-test-${Date.now()}`;
currentDeployment = await createMockDeployment(currentAppName);
allTestAppNames.push(currentAppName);
const mockApp = createMockApplication({ appName: currentAppName });
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
mockApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
mockApp as any,
);
vi.mocked(adminService.getDokployUrl).mockResolvedValue(
"http://localhost:3000",
);
vi.mocked(deploymentService.createDeployment).mockResolvedValue(
currentDeployment as any,
);
vi.mocked(deploymentService.updateDeploymentStatus).mockResolvedValue(
undefined as any,
);
vi.mocked(applicationService.updateApplicationStatus).mockResolvedValue(
{} as any,
);
vi.mocked(deploymentService.updateDeployment).mockResolvedValue(
{} as any,
);
});
afterEach(async () => {
// ALWAYS cleanup, even if test failed or passed
console.log(`\n🧹 Cleaning up test: ${currentAppName}`);
// Clean current appName
try {
await cleanupDocker(currentAppName);
await cleanupFiles(currentAppName);
} catch (error) {
console.error("⚠️ Error cleaning current app:", error);
}
// Clean ALL test folders just in case
try {
const { LOGS_PATH, APPLICATIONS_PATH } = paths(false);
await execAsync(`rm -rf ${LOGS_PATH}/real-* 2>/dev/null || true`);
await execAsync(
`rm -rf ${APPLICATIONS_PATH}/real-* 2>/dev/null || true`,
);
console.log("✅ Cleaned up all test artifacts");
} catch (error) {
console.error("⚠️ Error cleaning all artifacts:", error);
}
console.log("✅ Cleanup completed\n");
});
it(
"should REALLY clone git repo and build with nixpacks",
async () => {
console.log(`\n🚀 Testing real deployment with app: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Nixpacks Test",
descriptionLog: "Testing real execution",
});
expect(result).toBe(true);
// Verify that Docker image was actually created
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
// Verify log exists and has content
expect(existsSync(currentDeployment.logPath)).toBe(true);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent).toContain("nixpacks");
console.log(`✅ Build log created with ${logContent.length} chars`);
// Verify update functions were called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"done",
);
},
REAL_TEST_TIMEOUT,
);
it.skip(
"should REALLY build with railpack (SKIPPED: requires special permissions)",
async () => {
const railpackAppName = `real-railpack-${Date.now()}`;
const railpackApp = createMockApplication({
appName: railpackAppName,
buildType: "railpack",
railpackVersion: "3",
});
currentAppName = railpackAppName;
allTestAppNames.push(railpackAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
railpackApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
railpackApp as any,
);
console.log(`\n🚀 Testing real railpack deployment: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Railpack Test",
descriptionLog: "",
});
expect(result).toBe(true);
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Railpack image created: ${currentAppName}`);
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("railpack");
console.log("✅ Railpack build completed");
},
REAL_TEST_TIMEOUT,
);
it(
"should handle REAL git clone errors",
async () => {
const errorAppName = `real-error-${Date.now()}`;
const errorApp = createMockApplication({
appName: errorAppName,
customGitUrl:
"https://github.com/invalid/nonexistent-repo-123456.git",
});
currentAppName = errorAppName;
allTestAppNames.push(errorAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
errorApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
errorApp as any,
);
console.log(`\n🚀 Testing real error handling: ${currentAppName}`);
await expect(
deployApplication({
applicationId: "test-app-id",
titleLog: "Real Error Test",
descriptionLog: "",
}),
).rejects.toThrow();
// Verify error status was called
expect(deploymentService.updateDeploymentStatus).toHaveBeenCalledWith(
"deployment-id",
"error",
);
// Verify log contains error
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent.toLowerCase()).toContain("error");
console.log("✅ Error handling verified");
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY clone with submodules when enabled",
async () => {
const submodulesAppName = `real-submodules-${Date.now()}`;
const submodulesApp = createMockApplication({
appName: submodulesAppName,
enableSubmodules: true,
});
currentAppName = submodulesAppName;
allTestAppNames.push(submodulesAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
submodulesApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
submodulesApp as any,
);
console.log(`\n🚀 Testing real submodules support: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Submodules Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify deployment completed successfully
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Cloning");
expect(logContent.length).toBeGreaterThan(100);
console.log("✅ Submodules deployment completed");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
expect(dockerImages.trim()).toBe(currentAppName);
},
REAL_TEST_TIMEOUT,
);
it(
"should verify REAL commit info extraction",
async () => {
console.log(`\n🚀 Testing real commit info: ${currentAppName}`);
await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Commit Test",
descriptionLog: "",
});
// Verify updateDeployment was called with commit info
expect(deploymentService.updateDeployment).toHaveBeenCalled();
const updateCall = vi.mocked(deploymentService.updateDeployment).mock
.calls[0];
// Real commit info should have title and hash
expect(updateCall?.[1]).toHaveProperty("title");
expect(updateCall?.[1]).toHaveProperty("description");
expect(updateCall?.[1]?.description).toContain("Commit:");
console.log(
`✅ Real commit extracted: ${updateCall?.[1]?.title?.substring(0, 50)}...`,
);
},
REAL_TEST_TIMEOUT,
);
it(
"should REALLY build with Dockerfile",
async () => {
const dockerfileAppName = `real-dockerfile-${Date.now()}`;
const dockerfileApp = createMockApplication({
appName: dockerfileAppName,
buildType: "dockerfile",
customGitBuildPath: "/deno",
dockerfile: "Dockerfile",
});
currentAppName = dockerfileAppName;
allTestAppNames.push(dockerfileAppName);
vi.mocked(db.query.applications.findFirst).mockResolvedValue(
dockerfileApp as any,
);
vi.mocked(applicationService.findApplicationById).mockResolvedValue(
dockerfileApp as any,
);
console.log(`\n🚀 Testing real Dockerfile build: ${currentAppName}`);
const result = await deployApplication({
applicationId: "test-app-id",
titleLog: "Real Dockerfile Test",
descriptionLog: "",
});
expect(result).toBe(true);
// Verify log
const { stdout: logContent } = await execAsync(
`cat ${currentDeployment.logPath}`,
);
expect(logContent).toContain("Building");
expect(logContent).toContain(dockerfileAppName);
console.log("✅ Dockerfile build log verified");
// Verify image
const { stdout: dockerImages } = await execAsync(
`docker images ${currentAppName} --format "{{.Repository}}"`,
);
console.log("dockerImages", dockerImages);
expect(dockerImages.trim()).toBe(currentAppName);
console.log(`✅ Docker image created: ${currentAppName}`);
},
REAL_TEST_TIMEOUT,
);
},
REAL_TEST_TIMEOUT,
);

View File

@@ -30,6 +30,10 @@ const baseApp: ApplicationNested = {
previewLabels: [],
herokuVersion: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
args: [],
giteaBuildPath: "",
previewRequireCollaboratorPermissions: false,
giteaId: "",

View File

@@ -4,7 +4,11 @@ import type { ApplicationNested } from "@dokploy/server/utils/builders";
import { mechanizeDockerContainer } from "@dokploy/server/utils/builders";
type MockCreateServiceOptions = {
StopGracePeriod?: number;
TaskTemplate?: {
ContainerSpec?: {
StopGracePeriod?: number;
};
};
[key: string]: unknown;
};
@@ -82,8 +86,10 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings.StopGracePeriod).toBe(0);
expect(typeof settings.StopGracePeriod).toBe("number");
expect(settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(0);
expect(typeof settings.TaskTemplate?.ContainerSpec?.StopGracePeriod).toBe(
"number",
);
});
it("omits StopGracePeriod when stopGracePeriodSwarm is null", async () => {
@@ -97,6 +103,8 @@ describe("mechanizeDockerContainer", () => {
throw new Error("createServiceMock should have been called once");
}
const [settings] = call;
expect(settings).not.toHaveProperty("StopGracePeriod");
expect(settings.TaskTemplate?.ContainerSpec).not.toHaveProperty(
"StopGracePeriod",
);
});
});

View File

@@ -11,8 +11,12 @@ const baseApp: ApplicationNested = {
giteaRepository: "",
giteaOwner: "",
giteaBranch: "",
buildServerId: "",
buildRegistryId: "",
buildRegistry: null,
giteaBuildPath: "",
giteaId: "",
args: [],
cleanCache: false,
applicationStatus: "done",
endpointSpecSwarm: null,

View File

@@ -13,7 +13,11 @@ export default defineConfig({
NODE: "test",
},
},
plugins: [tsconfigPaths()],
plugins: [
tsconfigPaths({
projects: [path.resolve(__dirname, "../tsconfig.json")],
}),
],
resolve: {
alias: {
"@dokploy/server": path.resolve(

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -28,6 +29,13 @@ interface Props {
const AddRedirectSchema = z.object({
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
type AddCommand = z.infer<typeof AddRedirectSchema>;
@@ -47,22 +55,30 @@ export const AddCommand = ({ applicationId }: Props) => {
const form = useForm<AddCommand>({
defaultValues: {
command: "",
args: [],
},
resolver: zodResolver(AddRedirectSchema),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data?.command) {
if (data) {
form.reset({
command: data?.command || "",
args: data?.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [form, form.reset, form.formState.isSubmitSuccessful, data?.command]);
}, [data, form]);
const onSubmit = async (data: AddCommand) => {
await mutateAsync({
applicationId,
command: data?.command,
args: data?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Command Updated");
@@ -100,13 +116,65 @@ export const AddCommand = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0 ? "-c" : "echo Hello World"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
</div>
<div className="flex justify-end">
<Button isLoading={isLoading} type="submit" className="w-fit">

View File

@@ -0,0 +1,248 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Server } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
interface Props {
applicationId: string;
}
const schema = z.object({
buildServerId: z.string().min(1, "Build server is required"),
buildRegistryId: z.string().min(1, "Build registry is required"),
});
type Schema = z.infer<typeof schema>;
export const ShowBuildServer = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery(
{ applicationId },
{ enabled: !!applicationId },
);
const { data: buildServers } = api.server.buildServers.useQuery();
const { data: registries } = api.registry.all.useQuery();
const { mutateAsync, isLoading } = api.application.update.useMutation();
const form = useForm<Schema>({
defaultValues: {
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
},
resolver: zodResolver(schema),
});
useEffect(() => {
if (data) {
form.reset({
buildServerId: data?.buildServerId || "",
buildRegistryId: data?.buildRegistryId || "",
});
}
}, [form, form.reset, data]);
const onSubmit = async (formData: Schema) => {
await mutateAsync({
applicationId,
buildServerId:
formData?.buildServerId === "none" || !formData?.buildServerId
? null
: formData?.buildServerId,
buildRegistryId:
formData?.buildRegistryId === "none" || !formData?.buildRegistryId
? null
: formData?.buildRegistryId,
})
.then(async () => {
toast.success("Build Server Settings Updated");
await refetch();
})
.catch(() => {
toast.error("Error updating build server settings");
});
};
return (
<Card className="bg-background">
<CardHeader>
<div className="flex flex-row items-center gap-2">
<Server className="size-6 text-muted-foreground" />
<div>
<CardTitle className="text-xl">Build Server</CardTitle>
<CardDescription>
Configure a dedicated server for building your application.
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AlertBlock type="info">
Build servers offload the build process from your deployment servers.
Select a build server and registry to use for building your
application.
</AlertBlock>
<AlertBlock type="info">
📊 <strong>Important:</strong> Once the build finishes, you'll need to
wait a few seconds for the deployment server to download the image.
These download logs will <strong>NOT</strong> appear in the build
deployment logs. Check the <strong>Logs</strong> tab to see when the
container starts running.
</AlertBlock>
{!registries || registries.length === 0 ? (
<AlertBlock type="warning">
You need to add at least one registry to use build servers. Please
go to{" "}
<Link
href="/dashboard/settings/registry"
className="text-primary underline"
>
Settings
</Link>{" "}
to add a registry.
</AlertBlock>
) : null}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="buildServerId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Server</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a build server" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{buildServers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Build Servers ({buildServers?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a build server to handle the build process for this
application.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildRegistryId"
render={({ field }) => (
<FormItem>
<FormLabel>Build Registry</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || "none"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a registry" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectGroup>
<SelectItem value="none">
<span className="flex items-center gap-2">
<span>None</span>
</span>
</SelectItem>
{registries?.map((registry) => (
<SelectItem
key={registry.registryId}
value={registry.registryId}
>
{registry.registryName}
</SelectItem>
))}
<SelectLabel>
Registries ({registries?.length || 0})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormDescription>
Select a registry to store the built images from the build
server.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex w-full justify-end">
<Button isLoading={isLoading} type="submit">
Save
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
);
};

View File

@@ -407,7 +407,7 @@ export const ShowDeployments = ({
</div>
)}
<ShowDeployment
serverId={serverId}
serverId={activeLog?.buildServerId || serverId}
open={Boolean(activeLog && activeLog.logPath !== null)}
onClose={() => setActiveLog(null)}
logPath={activeLog?.logPath || ""}

View File

@@ -46,7 +46,13 @@ export type CacheType = "fetch" | "cache";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -299,6 +305,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
{type === "compose" && (
<AlertBlock type="info" className="mb-4">
Whenever you make changes to domains, remember to redeploy your
compose to apply the changes.
</AlertBlock>
)}
<Form {...form}>
<form
id="hook-form"

View File

@@ -1,6 +1,7 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Plus, Trash2 } from "lucide-react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
@@ -20,6 +21,13 @@ import type { ServiceType } from "../../application/advanced/show-resources";
const addDockerImage = z.object({
dockerImage: z.string().min(1, "Docker image is required"),
command: z.string(),
args: z
.array(
z.object({
value: z.string().min(1, "Argument cannot be empty"),
}),
)
.optional(),
});
interface Props {
@@ -61,18 +69,25 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
defaultValues: {
dockerImage: "",
command: "",
args: [],
},
resolver: zodResolver(addDockerImage),
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "args",
});
useEffect(() => {
if (data) {
form.reset({
dockerImage: data.dockerImage,
command: data.command || "",
args: data.args?.map((arg) => ({ value: arg })) || [],
});
}
}, [data, form, form.reset]);
}, [data, form]);
const onSubmit = async (formData: AddDockerImage) => {
await mutateAsync({
@@ -83,6 +98,7 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
mariadbId: id || "",
dockerImage: formData?.dockerImage,
command: formData?.command,
args: formData?.args?.map((arg) => arg.value).filter(Boolean),
})
.then(async () => {
toast.success("Custom Command Updated");
@@ -128,13 +144,68 @@ export const ShowCustomCommand = ({ id, type }: Props) => {
<FormItem>
<FormLabel>Command</FormLabel>
<FormControl>
<Input placeholder="Custom command" {...field} />
<Input placeholder="/bin/sh" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<FormLabel>Arguments (Args)</FormLabel>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => append({ value: "" })}
>
<Plus className="h-4 w-4 mr-1" />
Add Argument
</Button>
</div>
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No arguments added yet. Click "Add Argument" to add one.
</p>
)}
{fields.map((field, index) => (
<FormField
key={field.id}
control={form.control}
name={`args.${index}.value`}
render={({ field }) => (
<FormItem>
<div className="flex gap-2">
<FormControl>
<Input
placeholder={
index === 0
? "-c"
: "redis-server --port 6379"
}
{...field}
/>
</FormControl>
<Button
type="button"
variant="destructive"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
))}
</div>
<div className="flex w-full justify-end">
<Button isLoading={form.formState.isSubmitting} type="submit">
Save

View File

@@ -138,7 +138,7 @@ export const ShowProjects = () => {
list={[{ name: "Projects", href: "/dashboard/projects" }]}
/>
{!isCloud && (
<div className="absolute top-5 right-5">
<div className="absolute top-4 right-4">
<TimeBadge />
</div>
)}

View File

@@ -49,51 +49,65 @@ export const RequestDistributionChart = ({
);
return (
<ResponsiveContainer width="100%" height={200}>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis tickLine={false} axisLine={false} tickMargin={8} />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="natural"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
<div className="w-full h-[200px] overflow-hidden">
<ResponsiveContainer
width="100%"
height="100%"
className="overflow-hidden"
>
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={stats || []}
margin={{
top: 10,
left: 12,
right: 12,
bottom: 0,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="hour"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) =>
new Date(value).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
/>
<YAxis
tickLine={false}
axisLine={false}
tickMargin={8}
allowDataOverflow={false}
domain={[0, "auto"]}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="line" />}
labelFormatter={(value) =>
new Date(value).toLocaleString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
}
/>
<Area
dataKey="count"
type="monotone"
fill="hsl(var(--chart-1))"
fillOpacity={0.4}
stroke="hsl(var(--chart-1))"
/>
</AreaChart>
</ChartContainer>
</ResponsiveContainer>
</div>
);
};

View File

@@ -51,13 +51,19 @@ export const ShowRequests = () => {
const { mutateAsync: updateLogCleanup } =
api.settings.updateLogCleanup.useMutation();
const [cronExpression, setCronExpression] = useState<string | null>(null);
// Set default date range to last 3 days
const getDefaultDateRange = () => {
const to = new Date();
const from = new Date();
from.setDate(from.getDate() - 3);
return { from, to };
};
const [dateRange, setDateRange] = useState<{
from: Date | undefined;
to: Date | undefined;
}>({
from: undefined,
to: undefined,
});
}>(getDefaultDateRange());
useEffect(() => {
if (logCleanupStatus) {
@@ -169,17 +175,13 @@ export const ShowRequests = () => {
{isActive ? (
<>
<div className="flex justify-end mb-4 gap-2">
{(dateRange.from || dateRange.to) && (
<Button
variant="outline"
onClick={() =>
setDateRange({ from: undefined, to: undefined })
}
className="px-3"
>
Clear dates
</Button>
)}
<Button
variant="outline"
onClick={() => setDateRange(getDefaultDateRange())}
className="px-3"
>
Reset to Last 3 Days
</Button>
<Popover>
<PopoverTrigger asChild>
<Button

View File

@@ -48,7 +48,7 @@ export const ShowGitProviders = () => {
) => {
const redirectUri = `${url}/api/providers/gitlab/callback?gitlabId=${gitlabId}`;
const scope = "api read_user read_repository";
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${encodeURIComponent(scope)}`;
const authUrl = `${gitlabUrl}/oauth/authorize?client_id=${clientId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scopes=${encodeURIComponent(scope)}`;
return authUrl;
};

View File

@@ -1,12 +1,19 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { PenBoxIcon, PlusIcon } from "lucide-react";
import { Check, ChevronDown, PenBoxIcon, PlusIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
@@ -26,13 +33,12 @@ import {
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
const Schema = z.object({
@@ -53,6 +59,8 @@ export const HandleAi = ({ aiId }: Props) => {
const utils = api.useUtils();
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [modelPopoverOpen, setModelPopoverOpen] = useState(false);
const [modelSearch, setModelSearch] = useState("");
const { data, refetch } = api.ai.one.useQuery(
{
aiId: aiId || "",
@@ -77,13 +85,17 @@ export const HandleAi = ({ aiId }: Props) => {
});
useEffect(() => {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
if (data) {
form.reset({
name: data?.name ?? "",
apiUrl: data?.apiUrl ?? "https://api.openai.com/v1",
apiKey: data?.apiKey ?? "",
model: data?.model ?? "",
isEnabled: data?.isEnabled ?? true,
});
}
setModelSearch("");
setModelPopoverOpen(false);
}, [aiId, form, data]);
const apiUrl = form.watch("apiUrl");
@@ -104,14 +116,6 @@ export const HandleAi = ({ aiId }: Props) => {
},
);
useEffect(() => {
const apiUrl = form.watch("apiUrl");
const apiKey = form.watch("apiKey");
if (apiUrl && apiKey) {
form.setValue("model", "");
}
}, [form.watch("apiUrl"), form.watch("apiKey")]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({
@@ -131,7 +135,16 @@ export const HandleAi = ({ aiId }: Props) => {
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<Dialog
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
setModelSearch("");
setModelPopoverOpen(false);
}
}}
>
<DialogTrigger className="" asChild>
{aiId ? (
<Button
@@ -182,7 +195,17 @@ export const HandleAi = ({ aiId }: Props) => {
<FormItem>
<FormLabel>API URL</FormLabel>
<FormControl>
<Input placeholder="https://api.openai.com/v1" {...field} />
<Input
placeholder="https://api.openai.com/v1"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API URL
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
The base URL for your AI provider's API
@@ -205,6 +228,13 @@ export const HandleAi = ({ aiId }: Props) => {
placeholder="sk-..."
autoComplete="one-time-code"
{...field}
onChange={(e) => {
field.onChange(e);
// Reset model when user changes API Key
if (form.getValues("model")) {
form.setValue("model", "");
}
}}
/>
</FormControl>
<FormDescription>
@@ -232,30 +262,89 @@ export const HandleAi = ({ aiId }: Props) => {
<FormField
control={form.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Model</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value || ""}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
</FormControl>
<SelectContent>
{models.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.id}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>Select an AI model to use</FormDescription>
<FormMessage />
</FormItem>
)}
render={({ field }) => {
const selectedModel = models.find(
(m) => m.id === field.value,
);
const filteredModels = models.filter((model) =>
model.id.toLowerCase().includes(modelSearch.toLowerCase()),
);
// Ensure selected model is always in the filtered list
const displayModels =
field.value &&
!filteredModels.find((m) => m.id === field.value) &&
selectedModel
? [selectedModel, ...filteredModels]
: filteredModels;
return (
<FormItem>
<FormLabel>Model</FormLabel>
<Popover
open={modelPopoverOpen}
onOpenChange={setModelPopoverOpen}
>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
className={cn(
"w-full justify-between",
!field.value && "text-muted-foreground",
)}
>
{field.value
? (selectedModel?.id ?? field.value)
: "Select a model"}
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0" align="start">
<Command>
<CommandInput
placeholder="Search models..."
value={modelSearch}
onValueChange={setModelSearch}
/>
<CommandList>
<CommandEmpty>No models found.</CommandEmpty>
{displayModels.map((model) => {
const isSelected = field.value === model.id;
return (
<CommandItem
key={model.id}
value={model.id}
onSelect={() => {
field.onChange(model.id);
setModelPopoverOpen(false);
setModelSearch("");
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
isSelected
? "opacity-100"
: "opacity-0",
)}
/>
{model.id}
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormDescription>
Select an AI model to use
</FormDescription>
<FormMessage />
</FormItem>
);
}}
/>
)}

View File

@@ -103,7 +103,7 @@ export const notificationSchema = z.discriminatedUnion("type", [
type: z.literal("ntfy"),
serverUrl: z.string().min(1, { message: "Server URL is required" }),
topic: z.string().min(1, { message: "Topic is required" }),
accessToken: z.string().min(1, { message: "Access Token is required" }),
accessToken: z.string().optional(),
priority: z.number().min(1).max(5).default(3),
})
.merge(notificationBaseSchema),
@@ -303,7 +303,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: notification.dokployRestart,
databaseBackup: notification.databaseBackup,
type: notification.notificationType,
accessToken: notification.ntfy?.accessToken,
accessToken: notification.ntfy?.accessToken || "",
topic: notification.ntfy?.topic,
priority: notification.ntfy?.priority,
serverUrl: notification.ntfy?.serverUrl,
@@ -432,7 +432,7 @@ export const HandleNotifications = ({ notificationId }: Props) => {
dokployRestart: dokployRestart,
databaseBackup: databaseBackup,
serverUrl: data.serverUrl,
accessToken: data.accessToken,
accessToken: data.accessToken || "",
topic: data.topic,
priority: data.priority,
name: data.name,
@@ -1001,8 +1001,12 @@ export const HandleNotifications = ({ notificationId }: Props) => {
<Input
placeholder="AzxcvbnmKjhgfdsa..."
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormDescription>
Optional. Leave blank for public topics.
</FormDescription>
<FormMessage />
</FormItem>
)}
@@ -1214,56 +1218,62 @@ export const HandleNotifications = ({ notificationId }: Props) => {
isLoadingLark
}
variant="secondary"
type="button"
onClick={async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
try {
if (type === "slack") {
if (data.type === "slack") {
await testSlackConnection({
webhookUrl: form.getValues("webhookUrl"),
channel: form.getValues("channel"),
webhookUrl: data.webhookUrl,
channel: data.channel,
});
} else if (type === "telegram") {
} else if (data.type === "telegram") {
await testTelegramConnection({
botToken: form.getValues("botToken"),
chatId: form.getValues("chatId"),
messageThreadId: form.getValues("messageThreadId") || "",
botToken: data.botToken,
chatId: data.chatId,
messageThreadId: data.messageThreadId || "",
});
} else if (type === "discord") {
} else if (data.type === "discord") {
await testDiscordConnection({
webhookUrl: form.getValues("webhookUrl"),
decoration: form.getValues("decoration"),
webhookUrl: data.webhookUrl,
decoration: data.decoration,
});
} else if (type === "email") {
} else if (data.type === "email") {
await testEmailConnection({
smtpServer: form.getValues("smtpServer"),
smtpPort: form.getValues("smtpPort"),
username: form.getValues("username"),
password: form.getValues("password"),
toAddresses: form.getValues("toAddresses"),
fromAddress: form.getValues("fromAddress"),
smtpServer: data.smtpServer,
smtpPort: data.smtpPort,
username: data.username,
password: data.password,
fromAddress: data.fromAddress,
toAddresses: data.toAddresses,
});
} else if (type === "gotify") {
} else if (data.type === "gotify") {
await testGotifyConnection({
serverUrl: form.getValues("serverUrl"),
appToken: form.getValues("appToken"),
priority: form.getValues("priority"),
decoration: form.getValues("decoration"),
serverUrl: data.serverUrl,
appToken: data.appToken,
priority: data.priority,
decoration: data.decoration,
});
} else if (type === "ntfy") {
} else if (data.type === "ntfy") {
await testNtfyConnection({
serverUrl: form.getValues("serverUrl"),
topic: form.getValues("topic"),
accessToken: form.getValues("accessToken"),
priority: form.getValues("priority"),
serverUrl: data.serverUrl,
topic: data.topic,
accessToken: data.accessToken || "",
priority: data.priority,
});
} else if (type === "lark") {
} else if (data.type === "lark") {
await testLarkConnection({
webhookUrl: form.getValues("webhookUrl"),
webhookUrl: data.webhookUrl,
});
}
toast.success("Connection Success");
} catch (error) {
toast.error(
`Error testing the provider ${error instanceof Error ? error.message : "Unknown error"}`,
`Error testing the provider: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}}

View File

@@ -52,6 +52,7 @@ const Schema = z.object({
sshKeyId: z.string().min(1, {
message: "SSH Key is required",
}),
serverType: z.enum(["deploy", "build"]).default("deploy"),
});
type Schema = z.infer<typeof Schema>;
@@ -89,6 +90,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: 22,
username: "root",
sshKeyId: "",
serverType: "deploy",
},
resolver: zodResolver(Schema),
});
@@ -101,6 +103,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data?.port || 22,
username: data?.username || "root",
sshKeyId: data?.sshKeyId || "",
serverType: data?.serverType || "deploy",
});
}, [form, form.reset, form.formState.isSubmitSuccessful, data]);
@@ -116,6 +119,7 @@ export const HandleServers = ({ serverId }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: data.serverType || "deploy",
serverId: serverId || "",
})
.then(async (_data) => {
@@ -266,6 +270,50 @@ export const HandleServers = ({ serverId }: Props) => {
</FormItem>
)}
/>
<FormField
control={form.control}
name="serverType"
render={({ field }) => {
const serverTypeValue = form.watch("serverType");
return (
<FormItem>
<FormLabel>Server Type</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a server type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value="deploy">Deploy Server</SelectItem>
<SelectItem value="build">Build Server</SelectItem>
<SelectLabel>Server Type</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
{serverTypeValue === "deploy" && (
<AlertBlock type="info" className="mt-2">
Deploy servers are used to run your applications,
databases, and services. They handle the deployment and
execution of your projects.
</AlertBlock>
)}
{serverTypeValue === "build" && (
<AlertBlock type="info" className="mt-2">
Build servers are dedicated to building your
applications. They handle the compilation and build
process, offloading this work from your deployment
servers. Build servers won't appear in deployment
options.
</AlertBlock>
)}
</FormItem>
);
}}
/>
<FormField
control={form.control}
name="sshKeyId"

View File

@@ -51,6 +51,7 @@ export const SetupServer = ({ serverId }: Props) => {
const [activeLog, setActiveLog] = useState<string | null>(null);
const { data: isCloud } = api.settings.isCloud.useQuery();
const isBuildServer = server?.serverType === "build";
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [filteredLogs, setFilteredLogs] = useState<LogLine[]>([]);
const [isDeploying, setIsDeploying] = useState(false);
@@ -117,17 +118,26 @@ export const SetupServer = ({ serverId }: Props) => {
<TabsList
className={cn(
"grid w-[700px]",
isCloud ? "grid-cols-6" : "grid-cols-5",
isBuildServer
? "grid-cols-3"
: isCloud
? "grid-cols-6"
: "grid-cols-5",
)}
>
<TabsTrigger value="ssh-keys">SSH Keys</TabsTrigger>
<TabsTrigger value="deployments">Deployments</TabsTrigger>
<TabsTrigger value="validate">Validate</TabsTrigger>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
{!isBuildServer && (
<>
<TabsTrigger value="audit">Security</TabsTrigger>
{isCloud && (
<TabsTrigger value="monitoring">Monitoring</TabsTrigger>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</>
)}
<TabsTrigger value="gpu-setup">GPU Setup</TabsTrigger>
</TabsList>
<TabsContent
value="ssh-keys"
@@ -311,32 +321,36 @@ export const SetupServer = ({ serverId }: Props) => {
<ValidateServer serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
{!isBuildServer && (
<>
<TabsContent
value="audit"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<SecurityAudit serverId={serverId} />
</div>
</TabsContent>
<TabsContent
value="monitoring"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm pt-3">
<div className="rounded-xl bg-background shadow-md border">
<SetupMonitoring serverId={serverId} />
</div>
</div>
</TabsContent>
<TabsContent
value="gpu-setup"
className="outline-none ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
>
<div className="flex flex-col gap-2 text-sm text-muted-foreground pt-3">
<GPUSupport serverId={serverId} />
</div>
</TabsContent>
</>
)}
</Tabs>
</div>
)}

View File

@@ -129,6 +129,9 @@ export const ShowServers = () => {
Status
</TableHead>
)}
<TableHead className="text-center">
Type
</TableHead>
<TableHead className="text-center">
IP Address
</TableHead>
@@ -153,6 +156,8 @@ export const ShowServers = () => {
{data?.map((server) => {
const canDelete = server.totalSum === 0;
const isActive = server.serverStatus === "active";
const isBuildServer =
server.serverType === "build";
return (
<TableRow key={server.serverId}>
<TableCell className="text-left">
@@ -171,6 +176,15 @@ export const ShowServers = () => {
</Badge>
</TableHead>
)}
<TableCell className="text-center">
<Badge
variant={
isBuildServer ? "secondary" : "default"
}
>
{server.serverType}
</Badge>
</TableCell>
<TableCell className="text-center">
<Badge>{server.ipAddress}</Badge>
</TableCell>
@@ -233,11 +247,12 @@ export const ShowServers = () => {
serverId={server.serverId}
/>
{server.sshKeyId && (
<ShowServerActions
serverId={server.serverId}
/>
)}
{server.sshKeyId &&
!isBuildServer && (
<ShowServerActions
serverId={server.serverId}
/>
)}
</>
)}
@@ -286,41 +301,43 @@ export const ShowServers = () => {
</DropdownMenuItem>
</DialogAction>
{isActive && server.sshKeyId && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel>
Extra
</DropdownMenuLabel>
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig?.server
?.token
}
<ShowTraefikFileSystemModal
serverId={server.serverId}
/>
)}
<ShowDockerContainersModal
serverId={server.serverId}
/>
{isCloud && (
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
<ShowSchedulesModal
serverId={server.serverId}
/>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>

View File

@@ -25,6 +25,13 @@ export const ValidateServer = ({ serverId }: Props) => {
enabled: !!serverId,
},
);
const { data: server } = api.server.one.useQuery(
{ serverId },
{
enabled: !!serverId,
},
);
const isBuildServer = server?.serverType === "build";
const _utils = api.useUtils();
return (
<CardContent className="p-0">
@@ -73,7 +80,9 @@ export const ValidateServer = ({ serverId }: Props) => {
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold mb-1">Status</h3>
<p className="text-sm text-muted-foreground mb-4">
Shows the server configuration status
{isBuildServer
? "Shows the build server configuration status"
: "Shows the server configuration status"}
</p>
<div className="grid gap-2.5">
<StatusRow
@@ -85,15 +94,17 @@ export const ValidateServer = ({ serverId }: Props) => {
: undefined
}
/>
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
{!isBuildServer && (
<StatusRow
label="RClone Installed"
isEnabled={data?.rclone?.enabled}
description={
data?.rclone?.enabled
? `Installed: ${data?.rclone?.version}`
: undefined
}
/>
)}
<StatusRow
label="Nixpacks Installed"
isEnabled={data?.nixpacks?.enabled}
@@ -113,23 +124,36 @@ export const ValidateServer = ({ serverId }: Props) => {
}
/>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
{!isBuildServer && (
<>
<StatusRow
label="Docker Swarm Initialized"
isEnabled={data?.isSwarmInstalled}
description={
data?.isSwarmInstalled
? "Initialized"
: "Not Initialized"
}
/>
<StatusRow
label="Dokploy Network Created"
isEnabled={data?.isDokployNetworkInstalled}
description={
data?.isDokployNetworkInstalled
? "Created"
: "Not Created"
}
/>
</>
)}
<StatusRow
label="Main Directory Created"
isEnabled={data?.isMainDirectoryInstalled}
@@ -139,15 +163,6 @@ export const ValidateServer = ({ serverId }: Props) => {
: "Not Created"
}
/>
<StatusRow
label="Railpack Installed"
isEnabled={data?.railpack?.enabled}
description={
data?.railpack?.enabled
? `Installed: ${data?.railpack?.version}`
: undefined
}
/>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@ export const CreateServer = ({ stepper }: Props) => {
port: data.port || 22,
username: data.username || "root",
sshKeyId: data.sshKeyId || "",
serverType: "deploy",
})
.then(async (_data) => {
toast.success("Server Created");

View File

@@ -0,0 +1,8 @@
CREATE TYPE "public"."serverType" AS ENUM('deploy', 'build');--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "buildRegistryId" text;--> statement-breakpoint
ALTER TABLE "deployment" ADD COLUMN "buildServerId" text;--> statement-breakpoint
ALTER TABLE "server" ADD COLUMN "serverType" "serverType" DEFAULT 'deploy' NOT NULL;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "application" ADD CONSTRAINT "application_buildRegistryId_registry_registryId_fk" FOREIGN KEY ("buildRegistryId") REFERENCES "public"."registry"("registryId") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "deployment" ADD CONSTRAINT "deployment_buildServerId_server_serverId_fk" FOREIGN KEY ("buildServerId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;

View File

@@ -0,0 +1,6 @@
ALTER TABLE "application" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "args" text[];--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "args" text[];

View File

@@ -0,0 +1 @@
ALTER TABLE "ntfy" ALTER COLUMN "accessToken" DROP NOT NULL;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -855,6 +855,27 @@
"when": 1763755037033,
"tag": "0121_rainy_cargill",
"breakpoints": true
},
{
"idx": 122,
"version": "7",
"when": 1764479387555,
"tag": "0122_absent_frightful_four",
"breakpoints": true
},
{
"idx": 123,
"version": "7",
"when": 1764525308939,
"tag": "0123_cloudy_piledriver",
"breakpoints": true
},
{
"idx": 124,
"version": "7",
"when": 1764571454170,
"tag": "0124_certain_cloak",
"breakpoints": true
}
]
}

View File

@@ -34,7 +34,8 @@
"docker:build:canary": "./docker/build.sh canary",
"docker:push:canary": "./docker/push.sh canary",
"version": "echo $(node -p \"require('./package.json').version\")",
"test": "vitest --config __test__/vitest.config.ts"
"test": "vitest --config __test__/vitest.config.ts",
"generate:openapi": "tsx -r dotenv/config scripts/generate-openapi.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^2.0.5",
@@ -113,7 +114,6 @@
"i18next": "^23.16.8",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"yaml": "2.8.1",
"lodash": "4.17.21",
"lucide-react": "^0.469.0",
"micromatch": "4.0.8",
@@ -121,6 +121,7 @@
"next": "^15.3.2",
"next-i18next": "^15.4.2",
"next-themes": "^0.2.1",
"nextjs-toploader": "^3.9.17",
"node-os-utils": "2.0.1",
"node-pty": "1.0.0",
"node-schedule": "2.1.1",
@@ -154,6 +155,7 @@
"use-resize-observer": "9.1.0",
"ws": "8.16.0",
"xterm-addon-fit": "^0.8.0",
"yaml": "2.8.1",
"zod": "^3.25.32",
"zod-form-data": "^2.0.7"
},

View File

@@ -7,6 +7,7 @@ import Head from "next/head";
import Script from "next/script";
import { appWithTranslation } from "next-i18next";
import { ThemeProvider } from "next-themes";
import NextTopLoader from "nextjs-toploader";
import type { ReactElement, ReactNode } from "react";
import { SearchCommand } from "@/components/dashboard/search-command";
import { Toaster } from "@/components/ui/sonner";
@@ -57,6 +58,7 @@ const MyApp = ({
disableTransitionOnChange
forcedTheme={Component.theme}
>
<NextTopLoader color="hsl(var(--sidebar-ring))" />
<Toaster richColors />
<SearchCommand />
{getLayout(<Component {...pageProps} />)}

View File

@@ -17,6 +17,7 @@ import { AddCommand } from "@/components/dashboard/application/advanced/general/
import { ShowPorts } from "@/components/dashboard/application/advanced/ports/show-port";
import { ShowRedirects } from "@/components/dashboard/application/advanced/redirects/show-redirects";
import { ShowSecurity } from "@/components/dashboard/application/advanced/security/show-security";
import { ShowBuildServer } from "@/components/dashboard/application/advanced/show-build-server";
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowTraefikConfig } from "@/components/dashboard/application/advanced/traefik/show-traefik-config";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
@@ -353,7 +354,7 @@ const Service = (
id={applicationId}
type="application"
/>
<ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} />

View File

@@ -0,0 +1,132 @@
#!/usr/bin/env tsx
/**
* Script to generate OpenAPI specification locally
* This runs in CI/CD to generate the openapi.json file
* which can then be consumed by the documentation website
*/
import { writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
import { appRouter } from "../server/api/root";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function generateOpenAPI() {
try {
console.log("🔄 Generating OpenAPI specification...");
const openApiDocument = generateOpenApiDocument(appRouter, {
title: "Dokploy API",
version: "1.0.0",
baseUrl: "https://your-dokploy-instance.com/api",
docsUrl: "https://docs.dokploy.com/api",
tags: [
"admin",
"docker",
"compose",
"registry",
"cluster",
"user",
"domain",
"destination",
"backup",
"deployment",
"mounts",
"certificates",
"settings",
"security",
"redirects",
"port",
"project",
"application",
"mysql",
"postgres",
"redis",
"mongo",
"mariadb",
"sshRouter",
"gitProvider",
"bitbucket",
"github",
"gitlab",
"gitea",
"server",
"swarm",
"ai",
"organization",
"schedule",
"rollback",
"volumeBackups",
"environment",
],
});
// Enhance metadata
openApiDocument.info = {
title: "Dokploy API",
description:
"Complete API documentation for Dokploy - Deploy applications, manage databases, and orchestrate your infrastructure. This API allows you to programmatically manage all aspects of your Dokploy instance.",
version: "1.0.0",
contact: {
name: "Dokploy Team",
url: "https://dokploy.com",
},
license: {
name: "Apache 2.0",
url: "https://github.com/dokploy/dokploy/blob/canary/LICENSE",
},
};
// Add security schemes
openApiDocument.components = {
...openApiDocument.components,
securitySchemes: {
apiKey: {
type: "apiKey",
in: "header",
name: "x-api-key",
description:
"API key authentication. Generate an API key from your Dokploy dashboard under Settings > API Keys.",
},
},
};
// Apply global security
openApiDocument.security = [
{
apiKey: [],
},
];
// Add external docs
openApiDocument.externalDocs = {
description: "Full documentation",
url: "https://docs.dokploy.com",
};
// Write to root of repo
const outputPath = resolve(__dirname, "../../../openapi.json");
writeFileSync(
outputPath,
JSON.stringify(openApiDocument, null, 2),
"utf-8",
);
console.log("✅ OpenAPI specification generated successfully!");
console.log(`📄 Output: ${outputPath}`);
console.log(
`📊 Endpoints: ${Object.keys(openApiDocument.paths || {}).length}`,
);
} catch (error) {
console.error("❌ Error generating OpenAPI specification:", error);
process.exit(1);
} finally {
process.exit(0);
}
}
generateOpenAPI();

View File

@@ -81,8 +81,10 @@ export const serverRouter = createTRPCRouter({
}),
getDefaultCommand: protectedProcedure
.input(apiFindOneServer)
.query(async () => {
return defaultCommand();
.query(async ({ input }) => {
const server = await findServerById(input.serverId);
const isBuildServer = server.serverType === "build";
return defaultCommand(isBuildServer);
}),
all: protectedProcedure.query(async ({ ctx }) => {
const result = await db
@@ -124,10 +126,30 @@ export const serverRouter = createTRPCRouter({
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "deploy"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "deploy"),
),
});
return result;
}),
buildServers: protectedProcedure.query(async ({ ctx }) => {
const result = await db.query.server.findMany({
orderBy: desc(server.createdAt),
where: IS_CLOUD
? and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverStatus, "active"),
eq(server.serverType, "build"),
)
: and(
isNotNull(server.sshKeyId),
eq(server.organizationId, ctx.session.activeOrganizationId),
eq(server.serverType, "build"),
),
});
return result;

View File

@@ -2,7 +2,13 @@ import { z } from "zod";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
port: z
.number()
@@ -33,7 +39,13 @@ export const domain = z
export const domainCompose = z
.object({
host: z.string().min(1, { message: "Host is required" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
port: z
.number()

View File

@@ -46,6 +46,14 @@ export const setupDockerContainerLogsWebSocketServer = (
ws.close();
return;
}
// Set up keep-alive ping mechanism to prevent timeout
// Send ping every 45 seconds to keep connection alive
const pingInterval = setInterval(() => {
if (ws.readyState === ws.OPEN) {
ws.ping();
}
}, 45000); // 45 seconds
try {
if (serverId) {
const server = await findServerById(serverId);
@@ -86,6 +94,7 @@ export const setupDockerContainerLogsWebSocketServer = (
.on("error", (err) => {
console.error("SSH connection error:", err);
ws.send(`SSH error: ${err.message}`);
clearInterval(pingInterval);
ws.close(); // Cierra el WebSocket si hay un error con SSH
client.end();
})
@@ -96,6 +105,7 @@ export const setupDockerContainerLogsWebSocketServer = (
privateKey: server.sshKey?.privateKey,
});
ws.on("close", () => {
clearInterval(pingInterval);
client.end();
});
} else {
@@ -121,6 +131,7 @@ export const setupDockerContainerLogsWebSocketServer = (
ws.send(data);
});
ws.on("close", () => {
clearInterval(pingInterval);
ptyProcess.kill();
});
ws.on("message", (message) => {

20004
openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@
"build": "pnpm -r run build",
"format-and-lint": "biome check .",
"check": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
"format-and-lint:fix": "biome check . --write"
"format-and-lint:fix": "biome check . --write",
"generate:openapi": "pnpm --filter=dokploy run generate:openapi"
},
"devDependencies": {
"@biomejs/biome": "2.1.1",

View File

@@ -111,6 +111,7 @@ export const applications = pgTable("application", {
enabled: boolean("enabled"),
subtitle: text("subtitle"),
command: text("command"),
args: text("args").array(),
refreshToken: text("refreshToken").$defaultFn(() => nanoid()),
sourceType: sourceType("sourceType").notNull().default("github"),
cleanCache: boolean("cleanCache").default(false),
@@ -204,6 +205,15 @@ export const applications = pgTable("application", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
buildServerId: text("buildServerId").references(() => server.serverId, {
onDelete: "set null",
}),
buildRegistryId: text("buildRegistryId").references(
() => registry.registryId,
{
onDelete: "set null",
},
),
});
export const applicationsRelations = relations(
@@ -226,6 +236,7 @@ export const applicationsRelations = relations(
registry: one(registry, {
fields: [applications.registryId],
references: [registry.registryId],
relationName: "applicationRegistry",
}),
github: one(github, {
fields: [applications.githubId],
@@ -246,6 +257,17 @@ export const applicationsRelations = relations(
server: one(server, {
fields: [applications.serverId],
references: [server.serverId],
relationName: "applicationServer",
}),
buildServer: one(server, {
fields: [applications.buildServerId],
references: [server.serverId],
relationName: "applicationBuildServer",
}),
buildRegistry: one(registry, {
fields: [applications.buildRegistryId],
references: [registry.registryId],
relationName: "applicationBuildRegistry",
}),
previewDeployments: many(previewDeployments),
}),
@@ -272,6 +294,7 @@ const createSchema = createInsertSchema(applications, {
username: z.string().optional(),
isPreviewDeploymentsActive: z.boolean().optional(),
password: z.string().optional(),
args: z.array(z.string()).optional(),
registryUrl: z.string().optional(),
customGitSSHKeyId: z.string().optional(),
repository: z.string().optional(),

View File

@@ -70,6 +70,9 @@ export const deployments = pgTable("deployment", {
(): AnyPgColumn => volumeBackups.volumeBackupId,
{ onDelete: "cascade" },
),
buildServerId: text("buildServerId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const deploymentsRelations = relations(deployments, ({ one }) => ({
@@ -84,6 +87,12 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
server: one(server, {
fields: [deployments.serverId],
references: [server.serverId],
relationName: "deploymentServer",
}),
buildServer: one(server, {
fields: [deployments.buildServerId],
references: [server.serverId],
relationName: "deploymentBuildServer",
}),
previewDeployment: one(previewDeployments, {
fields: [deployments.previewDeploymentId],
@@ -115,6 +124,7 @@ const schema = createInsertSchema(deployments, {
composeId: z.string(),
description: z.string().optional(),
previewDeploymentId: z.string(),
buildServerId: z.string(),
});
export const apiCreateDeployment = schema

View File

@@ -45,6 +45,7 @@ export const mariadb = pgTable("mariadb", {
databaseRootPassword: text("rootPassword").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
// RESOURCES
memoryReservation: text("memoryReservation"),
@@ -114,6 +115,7 @@ const createSchema = createInsertSchema(mariadb, {
.optional(),
dockerImage: z.string().default("mariadb:6"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -50,6 +50,7 @@ export const mongo = pgTable("mongo", {
databasePassword: text("databasePassword").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
@@ -110,6 +111,7 @@ const createSchema = createInsertSchema(mongo, {
databaseUser: z.string().min(1),
dockerImage: z.string().default("mongo:15"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -45,6 +45,7 @@ export const mysql = pgTable("mysql", {
databaseRootPassword: text("rootPassword").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
@@ -112,6 +113,7 @@ const createSchema = createInsertSchema(mysql, {
.optional(),
dockerImage: z.string().default("mysql:8"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -116,7 +116,7 @@ export const ntfy = pgTable("ntfy", {
.$defaultFn(() => nanoid()),
serverUrl: text("serverUrl").notNull(),
topic: text("topic").notNull(),
accessToken: text("accessToken").notNull(),
accessToken: text("accessToken"),
priority: integer("priority").notNull().default(3),
});
@@ -331,7 +331,7 @@ export const apiCreateNtfy = notificationsSchema
.extend({
serverUrl: z.string().min(1),
topic: z.string().min(1),
accessToken: z.string().min(1),
accessToken: z.string().optional(),
priority: z.number().min(1),
})
.required();
@@ -395,7 +395,7 @@ export const apiSendTest = notificationsSchema
serverUrl: z.string(),
topic: z.string(),
appToken: z.string(),
accessToken: z.string(),
accessToken: z.string().optional(),
priority: z.number(),
})
.partial();

View File

@@ -44,6 +44,7 @@ export const postgres = pgTable("postgres", {
description: text("description"),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
externalPort: integer("externalPort"),
@@ -103,6 +104,7 @@ const createSchema = createInsertSchema(postgres, {
databaseUser: z.string().min(1),
dockerImage: z.string().default("postgres:15"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -41,6 +41,7 @@ export const redis = pgTable("redis", {
databasePassword: text("password").notNull(),
dockerImage: text("dockerImage").notNull(),
command: text("command"),
args: text("args").array(),
env: text("env"),
memoryReservation: text("memoryReservation"),
memoryLimit: text("memoryLimit"),
@@ -93,6 +94,7 @@ const createSchema = createInsertSchema(redis, {
databasePassword: z.string(),
dockerImage: z.string().default("redis:8"),
command: z.string().optional(),
args: z.array(z.string()).optional(),
env: z.string().optional(),
memoryReservation: z.string().optional(),
memoryLimit: z.string().optional(),

View File

@@ -33,7 +33,12 @@ export const registry = pgTable("registry", {
});
export const registryRelations = relations(registry, ({ many }) => ({
applications: many(applications),
applications: many(applications, {
relationName: "applicationRegistry",
}),
buildApplications: many(applications, {
relationName: "applicationBuildRegistry",
}),
}));
const createSchema = createInsertSchema(registry, {

View File

@@ -24,6 +24,7 @@ import { schedules } from "./schedule";
import { sshKeys } from "./ssh-key";
import { generateAppName } from "./utils";
export const serverStatus = pgEnum("serverStatus", ["active", "inactive"]);
export const serverType = pgEnum("serverType", ["deploy", "build"]);
export const server = pgTable("server", {
serverId: text("serverId")
@@ -44,6 +45,7 @@ export const server = pgTable("server", {
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverStatus: serverStatus("serverStatus").notNull().default("active"),
serverType: serverType("serverType").notNull().default("deploy"),
command: text("command").notNull().default(""),
sshKeyId: text("sshKeyId").references(() => sshKeys.sshKeyId, {
onDelete: "set null",
@@ -97,12 +99,22 @@ export const server = pgTable("server", {
});
export const serverRelations = relations(server, ({ one, many }) => ({
deployments: many(deployments),
deployments: many(deployments, {
relationName: "deploymentServer",
}),
buildDeployments: many(deployments, {
relationName: "deploymentBuildServer",
}),
sshKey: one(sshKeys, {
fields: [server.sshKeyId],
references: [sshKeys.sshKeyId],
}),
applications: many(applications),
applications: many(applications, {
relationName: "applicationServer",
}),
buildApplications: many(applications, {
relationName: "applicationBuildServer",
}),
compose: many(compose),
redis: many(redis),
mariadb: many(mariadb),
@@ -131,6 +143,7 @@ export const apiCreateServer = createSchema
port: true,
username: true,
sshKeyId: true,
serverType: true,
})
.required();
@@ -155,6 +168,7 @@ export const apiUpdateServer = createSchema
port: true,
username: true,
sshKeyId: true,
serverType: true,
})
.required()
.extend({

View File

@@ -2,7 +2,13 @@ import { z } from "zod";
export const domain = z
.object({
host: z.string().min(1, { message: "Add a hostname" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),
@@ -58,7 +64,13 @@ export const domain = z
export const domainCompose = z
.object({
host: z.string().min(1, { message: "Host is required" }),
host: z
.string()
.min(1, { message: "Add a hostname" })
.refine((val) => val === val.trim(), {
message: "Domain name cannot have leading or trailing spaces",
})
.transform((val) => val.trim()),
path: z.string().min(1).optional(),
internalPath: z.string().optional(),
stripPath: z.boolean().optional(),

View File

@@ -112,6 +112,7 @@ export const findApplicationById = async (applicationId: string) => {
gitea: true,
server: true,
previewDeployments: true,
buildRegistry: true,
},
});
if (!application) {
@@ -172,6 +173,7 @@ export const deployApplication = async ({
descriptionLog: string;
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
@@ -199,8 +201,8 @@ export const deployApplication = async ({
command += getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (application.serverId) {
await execAsyncRemote(application.serverId, commandWithLog);
if (serverId) {
await execAsyncRemote(serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
@@ -240,8 +242,8 @@ export const deployApplication = async ({
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
if (application.serverId) {
await execAsyncRemote(application.serverId, command);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}
@@ -285,6 +287,7 @@ export const rebuildApplication = async ({
descriptionLog: string;
}) => {
const application = await findApplicationById(applicationId);
const serverId = application.buildServerId || application.serverId;
const buildLink = `${await getDokployUrl()}/dashboard/project/${application.environment.projectId}/environment/${application.environmentId}/services/application/${application.applicationId}?tab=deployments`;
const deployment = await createDeployment({
@@ -298,8 +301,8 @@ export const rebuildApplication = async ({
// Check case for docker only
command += getBuildCommand(application);
const commandWithLog = `(${command}) >> ${deployment.logPath} 2>&1`;
if (application.serverId) {
await execAsyncRemote(application.serverId, commandWithLog);
if (serverId) {
await execAsyncRemote(serverId, commandWithLog);
} else {
await execAsync(commandWithLog);
}
@@ -338,8 +341,8 @@ export const rebuildApplication = async ({
}
command += `echo "\nError occurred ❌, check the logs for details." >> ${deployment.logPath};`;
if (application.serverId) {
await execAsyncRemote(application.serverId, command);
if (serverId) {
await execAsyncRemote(serverId, command);
} else {
await execAsync(command);
}

View File

@@ -78,7 +78,6 @@ const BUNNY_CDN_IPS = new Set([
"89.187.188.227",
"89.187.188.228",
"139.180.134.196",
"89.38.96.158",
"89.187.162.249",
"89.187.162.242",
"185.102.217.65",
@@ -106,12 +105,9 @@ const BUNNY_CDN_IPS = new Set([
"200.25.38.69",
"200.25.42.70",
"200.25.36.166",
"195.206.229.106",
"194.242.11.186",
"185.164.35.8",
"94.20.154.22",
"185.93.1.244",
"156.59.145.154",
"143.244.49.177",
"138.199.46.66",
"138.199.37.227",
@@ -136,7 +132,6 @@ const BUNNY_CDN_IPS = new Set([
"84.17.59.115",
"89.187.165.194",
"138.199.15.193",
"89.35.237.170",
"37.19.216.130",
"185.93.1.247",
"185.93.3.244",
@@ -150,6 +145,7 @@ const BUNNY_CDN_IPS = new Set([
"84.17.63.178",
"200.25.32.131",
"37.19.207.34",
"37.19.207.38",
"192.189.65.146",
"143.244.45.177",
"185.93.1.249",
@@ -168,9 +164,7 @@ const BUNNY_CDN_IPS = new Set([
"129.227.217.178",
"129.227.217.179",
"200.25.69.94",
"128.1.52.179",
"200.25.16.103",
"15.235.54.226",
"102.67.138.155",
"156.146.43.65",
"195.181.163.203",
@@ -278,13 +272,11 @@ const BUNNY_CDN_IPS = new Set([
"107.155.47.146",
"193.201.190.174",
"156.59.95.218",
"213.170.143.139",
"129.227.186.154",
"195.238.127.98",
"200.25.22.6",
"204.16.244.92",
"200.25.70.101",
"200.25.66.100",
"139.180.209.182",
"103.108.231.41",
"103.108.229.5",
@@ -387,46 +379,13 @@ const BUNNY_CDN_IPS = new Set([
"38.54.5.37",
"38.54.3.92",
"185.165.170.74",
"207.121.80.118",
"207.121.46.228",
"207.121.46.236",
"207.121.46.244",
"207.121.46.252",
"216.202.235.164",
"207.121.46.220",
"207.121.75.132",
"207.121.80.12",
"207.121.80.172",
"207.121.90.60",
"207.121.90.68",
"207.121.97.204",
"207.121.90.252",
"207.121.97.236",
"207.121.99.12",
"138.199.24.219",
"185.93.2.251",
"138.199.46.65",
"207.121.41.196",
"207.121.99.20",
"207.121.99.36",
"207.121.99.44",
"207.121.99.52",
"207.121.99.60",
"207.121.23.68",
"207.121.23.124",
"207.121.23.244",
"207.121.23.180",
"207.121.23.188",
"207.121.23.196",
"207.121.23.204",
"207.121.24.52",
"207.121.24.60",
"207.121.24.68",
"207.121.24.76",
"207.121.24.92",
"207.121.24.100",
"207.121.24.108",
"207.121.24.116",
"154.95.86.76",
"5.9.99.73",
"78.46.92.118",
@@ -434,14 +393,52 @@ const BUNNY_CDN_IPS = new Set([
"78.46.156.89",
"88.198.9.155",
"144.76.79.22",
"103.1.215.93",
"103.137.12.33",
"103.107.196.31",
"116.90.72.155",
"103.137.14.5",
"116.90.75.65",
"37.19.207.37",
"208.83.234.224",
"79.127.237.104",
"79.127.243.187",
"45.156.248.73",
"79.127.134.225",
"79.127.134.226",
"79.127.134.227",
"79.127.134.228",
"79.127.134.229",
"79.127.134.230",
"79.127.134.231",
"79.127.134.130",
"79.127.134.131",
"79.127.134.132",
"79.127.134.234",
"79.127.134.235",
"185.111.111.154",
"185.111.111.155",
"185.111.111.156",
"185.111.111.157",
"185.111.111.158",
"185.111.111.159",
"185.111.111.160",
"141.227.142.242",
"94.128.254.166",
"195.206.229.69",
"200.25.86.90",
"148.113.190.161",
"46.151.194.242",
"46.151.194.243",
"212.102.40.120",
"213.170.143.100",
"154.93.86.71",
"143.244.60.196",
"143.244.60.197",
"143.244.60.195",
"79.127.134.129",
"79.127.134.133",
"152.233.22.97",
"152.233.22.98",
"152.233.22.100",
"152.233.22.99",
"152.233.22.101",
"152.233.22.102",
"152.233.22.103",
"116.202.155.146",
"116.202.193.178",
"116.202.224.168",
@@ -502,6 +499,12 @@ const BUNNY_CDN_IPS = new Set([
"103.60.15.166",
"103.60.15.167",
"103.60.15.168",
"176.9.139.94",
"148.251.129.132",
"148.251.131.73",
"148.251.131.74",
"136.243.70.170",
"148.251.131.238",
"109.248.43.116",
"109.248.43.117",
"109.248.43.162",
@@ -527,7 +530,9 @@ const BUNNY_CDN_IPS = new Set([
"139.180.129.216",
"139.99.174.7",
"89.187.169.18",
"143.244.38.133",
"89.187.179.7",
"169.150.213.50",
"143.244.62.213",
"185.93.3.246",
"195.181.163.198",
@@ -535,7 +540,6 @@ const BUNNY_CDN_IPS = new Set([
"84.17.37.211",
"212.102.50.54",
"212.102.46.115",
"143.244.38.135",
"169.150.238.21",
"169.150.207.51",
"169.150.207.49",
@@ -546,7 +550,6 @@ const BUNNY_CDN_IPS = new Set([
"169.150.247.139",
"169.150.247.177",
"169.150.247.178",
"169.150.213.49",
"212.102.46.119",
"84.17.38.234",
"84.17.38.233",
@@ -558,7 +561,6 @@ const BUNNY_CDN_IPS = new Set([
"169.150.247.138",
"169.150.247.184",
"169.150.247.185",
"156.146.58.83",
"212.102.43.88",
"89.187.169.26",
"109.61.89.57",
@@ -587,6 +589,17 @@ const BUNNY_CDN_IPS = new Set([
"138.199.4.177",
"37.19.222.34",
"46.151.193.85",
"79.127.237.99",
"212.104.158.30",
"212.104.158.31",
"212.104.158.32",
"212.104.158.33",
"212.104.158.34",
"212.104.158.28",
"212.104.158.29",
"212.104.158.35",
"212.104.158.36",
"212.104.158.37",
"212.104.158.17",
"212.104.158.18",
"212.104.158.19",
@@ -595,12 +608,20 @@ const BUNNY_CDN_IPS = new Set([
"212.104.158.22",
"212.104.158.24",
"212.104.158.26",
"79.127.237.134",
"89.187.184.177",
"89.187.184.179",
"89.187.184.173",
"89.187.184.178",
"89.187.184.176",
"212.104.158.25",
"212.104.158.27",
"212.104.158.67",
"212.104.158.10",
"212.104.158.12",
"212.104.158.64",
"212.104.158.16",
"212.104.158.23",
"212.104.158.54",
]);
// Arvancloud IP ranges

View File

@@ -80,7 +80,7 @@ export const createDeployment = async (
"application",
application.serverId,
);
const serverId = application.serverId;
const serverId = application.buildServerId || application.serverId;
const { LOGS_PATH } = paths(!!serverId);
const formattedDateTime = format(new Date(), "yyyy-MM-dd:HH:mm:ss");
@@ -93,6 +93,7 @@ export const createDeployment = async (
const command = `
mkdir -p ${LOGS_PATH}/${application.appName};
echo "Initializing deployment" >> ${logFilePath};
echo "Building on ${serverId ? "Build Server" : "Dokploy Server"}" >> ${logFilePath};
`;
await execAsyncRemote(server.serverId, command);
@@ -112,6 +113,9 @@ export const createDeployment = async (
logPath: logFilePath,
description: deployment.description || "",
startedAt: new Date().toISOString(),
...(application.buildServerId && {
buildServerId: application.buildServerId,
}),
})
.returning();
if (deploymentCreate.length === 0 || !deploymentCreate[0]) {

View File

@@ -19,6 +19,7 @@ export const createDomain = async (input: typeof apiCreateDomain._type) => {
.insert(domains)
.values({
...input,
host: input.host?.trim(),
})
.returning()
.then((response) => response[0]);
@@ -120,6 +121,7 @@ export const updateDomainById = async (
.update(domains)
.set({
...domainData,
...(domainData.host && { host: domainData.host.trim() }),
})
.where(eq(domains.domainId, domainId))
.returning();

View File

@@ -498,7 +498,7 @@ export const createNtfyNotification = async (
.values({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
accessToken: input.accessToken ?? null,
priority: input.priority,
})
.returning()
@@ -569,7 +569,7 @@ export const updateNtfyNotification = async (
.set({
serverUrl: input.serverUrl,
topic: input.topic,
accessToken: input.accessToken,
accessToken: input.accessToken ?? null,
priority: input.priority,
})
.where(eq(ntfy.ntfyId, input.ntfyId));

View File

@@ -51,7 +51,12 @@ export const serverSetup = async (
});
try {
onData?.("\nInstalling Server Dependencies: ✅\n");
const isBuildServer = server.serverType === "build";
onData?.(
isBuildServer
? "\nInstalling Build Server Dependencies: ✅\n"
: "\nInstalling Server Dependencies: ✅\n",
);
await installRequirements(serverId, onData);
await updateDeploymentStatus(deployment.deploymentId, "done");
@@ -65,7 +70,7 @@ export const serverSetup = async (
}
};
export const defaultCommand = () => {
export const defaultCommand = (isBuildServer = false) => {
const bashCommand = `
set -e;
DOCKER_VERSION=27.0.3
@@ -126,6 +131,7 @@ echo -e "---------------------------------------------"
echo "| CPU Architecture | $SYS_ARCH"
echo "| Operating System | $OS_TYPE $OS_VERSION"
echo "| Docker | $DOCKER_VERSION"
${isBuildServer ? 'echo "| Server Type | Build Server"' : ""}
echo -e "---------------------------------------------\n"
echo -e "1. Installing required packages (curl, wget, git, jq, openssl). "
@@ -135,6 +141,9 @@ command_exists() {
${installUtilities()}
${
!isBuildServer
? `
echo -e "2. Validating ports. "
${validatePorts()}
@@ -173,6 +182,25 @@ ${installBuildpacks()}
echo -e "13. Installing Railpack"
${installRailpack()}
`
: `
echo -e "2. Installing Docker. "
${installDocker()}
echo -e "3. Setting up Directories"
${setupMainDirectory()}
${setupDirectories()}
echo -e "4. Installing Nixpacks"
${installNixpacks()}
echo -e "5. Installing Buildpacks"
${installBuildpacks()}
echo -e "6. Installing Railpack"
${installRailpack()}
`
}
`;
return bashCommand;
@@ -189,10 +217,12 @@ const installRequirements = async (
throw new Error("No SSH Key found");
}
const isBuildServer = server.serverType === "build";
return new Promise<void>((resolve, reject) => {
client
.once("ready", () => {
const command = server.command || defaultCommand();
const command = server.command || defaultCommand(isBuildServer);
client.exec(command, (err, stream) => {
if (err) {
onData?.(err.message);

View File

@@ -29,13 +29,14 @@ export type ApplicationNested = InferResultType<
redirects: true;
ports: true;
registry: true;
buildRegistry: true;
environment: { with: { project: true } };
}
>;
export const getBuildCommand = (application: ApplicationNested) => {
let command = "";
const { buildType, registry } = application;
const { buildType } = application;
if (application.sourceType === "docker") {
return "";
@@ -60,7 +61,7 @@ export const getBuildCommand = (application: ApplicationNested) => {
command = getRailpackCommand(application);
break;
}
if (registry) {
if (application.registry || application.buildRegistry) {
command += uploadImageRemoteCommand(application);
}
@@ -79,6 +80,7 @@ export const mechanizeDockerContainer = async (
memoryReservation,
cpuReservation,
command,
args,
ports,
} = application;
@@ -125,12 +127,16 @@ export const mechanizeDockerContainer = async (
Image: image,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -153,8 +159,6 @@ export const mechanizeDockerContainer = async (
})),
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
@@ -169,13 +173,15 @@ export const mechanizeDockerContainer = async (
ForceUpdate: inspect.Spec.TaskTemplate.ForceUpdate + 1,
},
});
} catch {
} catch (error) {
console.log(error);
await docker.createService(settings);
}
};
const getImageName = (application: ApplicationNested) => {
const { appName, sourceType, dockerImage, registry } = application;
const { appName, sourceType, dockerImage, registry, buildRegistry } =
application;
const imageName = `${appName}:latest`;
if (sourceType === "docker") {
return dockerImage || "ERROR-NO-IMAGE-PROVIDED";
@@ -188,12 +194,26 @@ const getImageName = (application: ApplicationNested) => {
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
return registryTag;
}
if (buildRegistry) {
const { registryUrl, imagePrefix, username } = buildRegistry;
const registryTag = imagePrefix
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
return registryTag;
}
return imageName;
};
export const getAuthConfig = (application: ApplicationNested) => {
const { registry, username, password, sourceType, registryUrl } = application;
const {
registry,
buildRegistry,
username,
password,
sourceType,
registryUrl,
} = application;
if (sourceType === "docker") {
if (username && password) {
@@ -209,6 +229,12 @@ export const getAuthConfig = (application: ApplicationNested) => {
username: registry.username,
serveraddress: registry.registryUrl,
};
} else if (buildRegistry) {
return {
password: buildRegistry.password,
username: buildRegistry.username,
serveraddress: buildRegistry.registryUrl,
};
}
return undefined;

View File

@@ -1,44 +1,77 @@
import type { Registry } from "@dokploy/server/services/registry";
import type { ApplicationNested } from "../builders";
export const uploadImageRemoteCommand = (application: ApplicationNested) => {
const registry = application.registry;
const buildRegistry = application.buildRegistry;
if (!registry) {
throw new Error("Registry not found");
if (!registry && !buildRegistry) {
throw new Error("No registry found");
}
const { registryUrl, imagePrefix, username } = registry;
const { appName } = application;
const imageName = `${appName}:latest`;
const finalURL = registryUrl;
// Build registry tag in correct format: registry.com/owner/image:tag
const registryTag = imagePrefix
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
const commands: string[] = [];
if (registry) {
const registryTag = getRegistryTag(registry, imageName);
if (registryTag) {
commands.push(`echo "📦 [Enabled Registry Swarm]"`);
commands.push(getRegistryCommands(registry, imageName, registryTag));
}
}
if (buildRegistry) {
const buildRegistryTag = getRegistryTag(buildRegistry, imageName);
if (buildRegistryTag) {
commands.push(`echo "🔑 [Enabled Build Registry]"`);
commands.push(
getRegistryCommands(buildRegistry, imageName, buildRegistryTag),
);
commands.push(
`echo "⚠️ INFO: After the build is finished, you need to wait a few seconds for the server to download the image and run the container."`,
);
commands.push(
`echo "📊 Check the Logs tab to see when the container starts running."`,
);
}
}
try {
const command = `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${finalURL} -u ${registry.username} --password-stdin || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
echo "❌ Error pushing image" ;
exit 1;
}
echo "✅ Image Pushed" ;
`;
return command;
return commands.join("\n");
} catch (error) {
throw error;
}
};
const getRegistryTag = (registry: Registry | null, imageName: string) => {
if (!registry) {
return null;
}
const { registryUrl, imagePrefix, username } = registry;
return imagePrefix
? `${registryUrl ? `${registryUrl}/` : ""}${imagePrefix}/${imageName}`
: `${registryUrl ? `${registryUrl}/` : ""}${username}/${imageName}`;
};
const getRegistryCommands = (
registry: Registry,
imageName: string,
registryTag: string,
): string => {
return `
echo "📦 [Enabled Registry] Uploading image to '${registry.registryType}' | '${registryTag}'" ;
echo "${registry.password}" | docker login ${registry.registryUrl} -u ${registry.username} --password-stdin || {
echo "❌ DockerHub Failed" ;
exit 1;
}
echo "✅ Registry Login Success" ;
docker tag ${imageName} ${registryTag} || {
echo "❌ Error tagging image" ;
exit 1;
}
echo "✅ Image Tagged" ;
docker push ${registryTag} || {
echo "❌ Error pushing image" ;
exit 1;
}
echo "✅ Image Pushed" ;
`;
};

View File

@@ -29,6 +29,7 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
cpuLimit,
cpuReservation,
command,
args,
mounts,
} = mariadb;
@@ -73,12 +74,16 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -106,8 +111,6 @@ export const buildMariadb = async (mariadb: MariadbNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -28,6 +28,7 @@ export const buildMongo = async (mongo: MongoNested) => {
databaseUser,
databasePassword,
command,
args,
mounts,
replicaSets,
} = mongo;
@@ -121,17 +122,24 @@ ${command ?? "wait $MONGOD_PID"}`;
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(replicaSets
? {
Command: ["/bin/bash"],
Args: ["-c", startupScript],
}
: {
...(command && {
Command: ["/bin/bash"],
Args: ["-c", command],
}),
}),
: {}),
...(command &&
!replicaSets && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 &&
!replicaSets && {
Args: args,
}),
Labels,
},
Networks,
@@ -159,8 +167,6 @@ ${command ?? "wait $MONGOD_PID"}`;
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -30,6 +30,7 @@ export const buildMysql = async (mysql: MysqlNested) => {
cpuLimit,
cpuReservation,
command,
args,
mounts,
} = mysql;
@@ -79,12 +80,16 @@ export const buildMysql = async (mysql: MysqlNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -112,8 +117,6 @@ export const buildMysql = async (mysql: MysqlNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -28,6 +28,7 @@ export const buildPostgres = async (postgres: PostgresNested) => {
databaseUser,
databasePassword,
command,
args,
mounts,
} = postgres;
@@ -72,12 +73,16 @@ export const buildPostgres = async (postgres: PostgresNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
...(command
? {
Command: ["/bin/sh"],
Args: ["-c", command],
}
: {}),
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
Labels,
},
Networks,
@@ -105,8 +110,6 @@ export const buildPostgres = async (postgres: PostgresNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {
const service = docker.getService(appName);

View File

@@ -26,6 +26,7 @@ export const buildRedis = async (redis: RedisNested) => {
cpuLimit,
cpuReservation,
command,
args,
mounts,
} = redis;
@@ -70,11 +71,22 @@ export const buildRedis = async (redis: RedisNested) => {
Image: dockerImage,
Env: envVariables,
Mounts: [...volumesMount, ...bindsMount, ...filesMount],
Command: ["/bin/sh"],
Args: [
"-c",
command ? command : `redis-server --requirepass ${databasePassword}`,
],
...(StopGracePeriod !== null &&
StopGracePeriod !== undefined && { StopGracePeriod }),
...(command || args
? {
...(command && {
Command: command.split(" "),
}),
...(args &&
args.length > 0 && {
Args: args,
}),
}
: {
Command: ["/bin/sh"],
Args: ["-c", `redis-server --requirepass ${databasePassword}`],
}),
Labels,
},
Networks,
@@ -102,8 +114,6 @@ export const buildRedis = async (redis: RedisNested) => {
: [],
},
UpdateConfig,
...(StopGracePeriod !== undefined &&
StopGracePeriod !== null && { StopGracePeriod }),
};
try {

View File

@@ -159,7 +159,9 @@ export const sendNtfyNotification = async (
const response = await fetch(`${connection.serverUrl}/${connection.topic}`, {
method: "POST",
headers: {
Authorization: `Bearer ${connection.accessToken}`,
...(connection.accessToken && {
Authorization: `Bearer ${connection.accessToken}`,
}),
"X-Priority": connection.priority?.toString() || "3",
"X-Title": title,
"X-Tags": tags,

23
pnpm-lock.yaml generated
View File

@@ -346,6 +346,9 @@ importers:
next-themes:
specifier: ^0.2.1
version: 0.2.1(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nextjs-toploader:
specifier: ^3.9.17
version: 3.9.17(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
node-os-utils:
specifier: 2.0.1
version: 2.0.1
@@ -6267,6 +6270,13 @@ packages:
sass:
optional: true
nextjs-toploader@3.9.17:
resolution: {integrity: sha512-9OF0KSSLtoSAuNg2LZ3aTl4hR9mBDj5L9s9DZiFCbMlXehyICGjkIz5dVGzuATU2bheJZoBdFgq9w07AKSuQQw==}
peerDependencies:
next: '>= 6.0.0'
react: '>= 16.0.0'
react-dom: '>= 16.0.0'
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
@@ -6366,6 +6376,9 @@ packages:
resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==}
deprecated: This package is no longer supported.
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -13816,6 +13829,14 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
nextjs-toploader@3.9.17(next@15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0):
dependencies:
next: 15.3.2(@opentelemetry/api@1.9.0)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
nprogress: 0.2.0
prop-types: 15.8.1
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
node-abort-controller@3.1.1: {}
node-addon-api@5.1.0: {}
@@ -13900,6 +13921,8 @@ snapshots:
gauge: 3.0.2
set-blocking: 2.0.0
nprogress@0.2.0: {}
object-assign@4.1.1: {}
object-hash@3.0.0: {}