Compare commits

..

59 Commits
main ... canary

Author SHA1 Message Date
Mauricio Siu
24b02f5523 Feat/concurrent deployments in memory queue (#4645)
* feat: add builds concurrency management for servers

- Introduced a new `BuildsConcurrency` component to manage the number of concurrent builds for both local and remote servers, gated by license validity.
- Implemented backend logic to resolve effective builds concurrency based on server settings and organization licenses.
- Added unit tests for concurrency resolution logic to ensure correct behavior under various licensing scenarios.
- Updated database schema to include `buildsConcurrency` field for servers and web server settings.
- Refactored deployment queue to support in-memory job processing with configurable concurrency limits.

This feature enhances deployment flexibility and control for enterprise users.

* refactor: enhance deployment cancellation logic and improve Railpack build isolation

- Reintroduced the `initCancelDeployments` function in the server initialization sequence to ensure deployments can be canceled effectively.
- Updated the Railpack build command to use a unique builder name for each build, preventing conflicts during concurrent deployments.
- Enhanced the cancellation logic to reset application and compose statuses to "idle" after canceling running deployments, improving system reliability.

* test: add buildsConcurrency setting to server configuration tests

- Introduced a new `buildsConcurrency` property in the server configuration tests to ensure proper handling of concurrent builds in deployment scenarios.

* feat: implement builds concurrency management and validation

- Added `assertBuildsConcurrencyAllowed` function to validate concurrency settings based on license status.
- Updated `resolveBuildsConcurrency` to reflect new concurrency limits for free and enterprise tiers.
- Enhanced `BuildsConcurrency` component to manage concurrent builds for servers, with UI adjustments for better user experience.
- Introduced a new settings page for managing concurrent builds across servers, ensuring proper handling of deployments.
- Updated database schema to support increased maximum concurrency values for servers and web server settings.
2026-06-16 23:15:19 -06:00
Mauricio Siu
439f575669 refactor: unify server admin tools into dashboard pages with server selector (#4625)
* refactor: unify server admin tools into dashboard pages with server selector

Replace the per-server Advanced dropdown (Traefik file system, Docker
containers, swarm overview, swarm nodes, schedules) with a server
selector on the existing dashboard routes, defaulting to the Dokploy
server. Pages are now available in cloud too, since the dropdown was
the only entry point there; the cloud-only monitoring modal moves to
an icon button on the server card.

* feat: add frontend-design skill and enhance dashboard UI components

- Introduced a new skill for creating high-quality frontend designs, emphasizing intentional aesthetics and detailed guidelines for implementation.
- Updated the Traefik system component to improve the user experience when no files or directories are found, incorporating new icons and a more informative layout.
- Enhanced the server filter component with improved loading states, user prompts, and a more visually appealing design, including badges and better server information display.

* [autofix.ci] apply automated fixes

* style: adjust Card component layout in schedules page for improved responsiveness

- Modified the Card component in the schedules page to ensure it utilizes full width while maintaining the minimum height, enhancing the overall layout and user experience.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-12 14:39:08 -06:00
Mauricio Siu
1f4f94042f fix: prevent registry password from appearing in error messages and shell commands (#4579) 2026-06-08 09:20:34 -06:00
Mauricio Siu
e9a0932b23 fix: correct git provider access check for existing deploys (#4570)
* fix: use canEditDeployGitSource for git provider access on existing deploys

Replaces the simple userId ownership check with a new canEditDeployGitSource
function that correctly handles all role/sharing scenarios. Owner always has
access; admin and member only if they own the provider or it is shared with
the org — being assigned via accessedGitProviders (enterprise) only grants
permission to connect new deploys, not to edit the git source of existing ones.

Adds 26 unit tests covering owner, admin, member (with/without enterprise
license), shared providers, and the key regression case from issue #4469.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-07 02:10:49 -06:00
Mauricio Siu
6b68fcab8c fix: strip credentials from gitProvider.getAll API response (#4569)
* fix: strip credentials from gitProvider.getAll API response

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-07 01:29:04 -06:00
Mauricio Siu
dfbae18557 fix: correct deriveCookieSecret test to validate 16-byte hex secret as per oauth2-proxy requirements 2026-06-07 01:25:05 -06:00
Mauricio Siu
c1c887d03c fix: update deriveCookieSecret to meet oauth2-proxy requirements 2026-06-07 00:50:20 -06:00
Mauricio Siu
0f77c40ee3 refactor: replace BETTER_AUTH_SECRET with betterAuthSecret in forward-auth setup 2026-06-07 00:28:57 -06:00
Mauricio Siu
a0288f83d5 fix: enforce docker:read on container start/stop/kill/restart mutations (#4568) 2026-06-07 00:18:40 -06:00
Mauricio Siu
4900204107 fix: use swarm advertise address in docker swarm join command (#4567) 2026-06-07 00:15:09 -06:00
Mauricio Siu
0f76d8f385 refactor: improve restore logging for database backups (#4566)
* refactor: improve restore logging for database backups

- Updated restore functions across various database types (Postgres, MySQL, MongoDB, MariaDB, LibSQL, and Compose) to provide clearer logging messages.
- Replaced generic command execution logs with specific messages indicating the database being restored and the source backup file.
- This change enhances the clarity of restore operations and aids in troubleshooting by providing more context in the logs.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-07 00:12:08 -06:00
Mauricio Siu
c968a2755e fix: strip credentials from service-level API responses (#4564)
* fix: strip credentials from service-level API responses

Registry passwords and S3 destination credentials were being returned
in service `.one` tRPC endpoints to any user with service-level read
access. Reported by Nihon Kohden Corporation security team.

- Strip registry `password` from `findApplicationById` via Drizzle `columns: { password: false }`
- Strip destination `accessKey`/`secretAccessKey` from all DB service finders (postgres, mysql, mariadb, mongo, libsql, compose, backup, volume-backups)
- Add `findRegistryByIdWithCredentials` for internal use only
- Builders and upload utils now load registry credentials by ID at execution time
- `createRollback` enriches `fullContext` with registry credentials before persisting to DB so rollback execution has what it needs
- Remove `findApplicationByIdWithCredentials` and `ApplicationNestedWithCredentials` — no longer needed
- Backup execution utils load full destination via `findDestinationById` at runtime instead of reading from the joined relation

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-06 17:45:24 -06:00
Mauricio Siu
f35f3064e9 chore: bump dokploy version to v0.29.8 2026-06-06 15:08:52 -06:00
Mauricio Siu
c377be0a14 fix: respect gitProviders permissions in git provider UI (#4561) 2026-06-06 15:08:32 -06:00
Mauricio Siu
e944603f99 fix: use stop-first update order for all database services (#4560)
Docker Swarm's default start-first update order causes new database
containers to fail with 'DBPathInUse' because two containers compete
for the same data volume simultaneously. Docker then rolls back the
update, silently reverting any env var or config changes.

Using stop-first ensures the old container is stopped before the new
one starts, preventing volume lock conflicts across all database types.

Fixes #4550
2026-06-06 14:49:24 -06:00
Mauricio Siu
e6fc3db08f fix: add docker cleanup toggle to remote server creation (#4559)
* fix: add docker cleanup toggle to remote server creation and update forms

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-06 14:21:57 -06:00
Mauricio Siu
57ef96a458 fix: swarm health check fields not resetting to default values (#4558)
Fixes #4553

- Replace z.coerce.number() with a custom transform that converts empty strings to undefined instead of 0
- Add value={field.value ?? ""} to numeric inputs so they visually clear when reset to undefined
2026-06-06 14:05:03 -06:00
Mauricio Siu
b29a87aaa8 Merge pull request #4555 from Dokploy/feat/forward-auth-sso
Feat/forward auth sso
2026-06-06 13:58:05 -06:00
Mauricio Siu
705ca54ccc refactor: improve path validation in Traefik configuration schema
- Enhanced the `apiReadTraefikConfig` schema by reintroducing path validation logic to prevent directory traversal attacks and unauthorized access.
- The validation now includes checks for null bytes and ensures paths start with a defined main Traefik path, improving security and robustness.

These changes strengthen the integrity of the configuration handling by ensuring only valid paths are accepted.
2026-06-06 13:54:58 -06:00
Mauricio Siu
aa545ec71c feat: add SQL migration for lucky echo and update foreign key constraints
- Introduced a new SQL migration file `0171_lucky_echo.sql` to modify the foreign key constraint on the `sso_provider` table, changing the `ON DELETE` behavior from `cascade` to `set null`.
- Updated the journal to include the new migration version and its associated tag.
- Added a snapshot file for version 7 of the database schema, reflecting the current state of the `sso_provider` and other related tables.

These changes enhance the integrity of the database by ensuring that user references are set to null instead of being deleted when the referenced user is removed.
2026-06-06 13:53:34 -06:00
Mauricio Siu
51b5af55d0 refactor: enhance forward authentication UI and API integration
- Updated the alert block in the HandleForwardAuth component to provide clearer requirements for deploying the authentication proxy.
- Added a DnsHelperModal to assist with DNS configuration in the ForwardAuthServers component.
- Refined API input schemas for forward authentication operations to improve type safety and clarity.
- Removed the obsolete forward-auth SSO design document to streamline documentation.

These changes improve the user experience and maintainability of the forward authentication feature across the application.
2026-06-06 13:27:17 -06:00
Mauricio Siu
28673a6166 Merge branch 'canary' into feat/forward-auth-sso 2026-06-06 03:56:40 -06:00
Mauricio Siu
f886010acc Delete .github/workflows/pr-quality.yml 2026-06-06 03:56:23 -06:00
Mauricio Siu
238bb2f6f9 chore: remove PR quality workflow configuration
Deleted the `.github/workflows/pr-quality.yml` file, which contained the configuration for the PR Quality workflow. This removal streamlines the repository by eliminating unused workflow files.
2026-06-06 03:55:07 -06:00
Mauricio Siu
1df6774ee8 refactor: update forward authentication handling in domain schema and tests
- Replaced `forwardAuthProviderId` with `forwardAuthEnabled` in the domain schema to simplify the configuration of forward authentication.
- Updated related tests to reflect this change, ensuring consistency across the application.
- Introduced a new SQL migration to create the `forward_auth_settings` table for managing authentication domains and their configurations.

This refactor enhances the clarity and maintainability of the forward authentication logic within the application.
2026-06-06 03:53:45 -06:00
Mauricio Siu
35f452d25f Merge branch 'canary' into feat/forward-auth-sso 2026-06-06 03:41:27 -06:00
Mauricio Siu
931203a310 refactor: remove obsolete SQL migration files and snapshots
- Deleted several SQL migration files related to the `webServerSettings` and `schedule` tables, which included adding and dropping columns and constraints.
- Removed snapshots corresponding to the deleted migrations to maintain consistency in the database schema history.

This cleanup enhances the maintainability of the migration history by removing outdated and unused files.
2026-06-06 03:40:36 -06:00
Mauricio Siu
a3c8b3bd42 refactor: unify branch validation imports across provider components
- Added the `VALID_BRANCH_REGEX` import to all Git provider components to ensure consistent branch validation.
- Removed duplicate imports of `VALID_BRANCH_REGEX` to streamline the code and improve readability.

This change enhances maintainability by centralizing branch validation logic across the application.
2026-06-06 03:38:25 -06:00
Mauricio Siu
4f6e57cc9c refactor: simplify forward authentication handling in UI and API
- Removed the selection of SSO providers from the UI, streamlining the process to enable/disable SSO for domains.
- Updated the API to eliminate the need for a provider ID when enabling forward authentication, relying on the configured settings instead.
- Enhanced user feedback by updating toast messages to reflect the current state of SSO authentication.
- Improved the UI layout for better clarity on SSO status and actions.

This refactor enhances the user experience by simplifying the SSO configuration process and ensuring clearer communication of actions taken.
2026-06-06 03:37:31 -06:00
Mauricio Siu
41c09cd86b feat: implement forward authentication settings and UI components
- Added a new `forward_auth_settings` table to manage authentication domains and their configurations.
- Introduced UI components for handling forward authentication, including enabling/disabling SSO for domains and selecting SSO providers.
- Updated existing tests to include validation for the new `forwardAuthProviderId` field in domain configurations.
- Enhanced the dashboard to integrate forward authentication management, allowing users to configure SSO settings directly from the application interface.

This update improves the flexibility and security of application authentication by allowing integration with various identity providers.
2026-06-02 01:47:50 -06:00
Mauricio Siu
6ff2ca0173 fix: scope dokploy-server schedules to organization instead of user (#4526)
* fix: scope dokploy-server schedules to organization instead of user

Replaces userId with organizationId on the schedule table so that
global (dokploy-server) schedules are shared across all owners and
admins of the same organization, while remaining isolated between
different organizations.

Includes a data migration that backfills organizationId from the
owner membership record for any existing dokploy-server schedules.

Closes #4300

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-31 15:29:05 -06:00
Mauricio Siu
d56a17c8ae Merge branch 'main' into canary 2026-05-30 15:24:19 -06:00
youcef zr
85211afd41 fix: preserve HOME in compose deploy so --with-registry-auth can read docker config (#4485)
The compose/stack deploy command runs under `env -i PATH="$PATH"`, which
clears the environment except for PATH. That strips HOME, so when the
generated command is `docker stack deploy --prune --with-registry-auth`
the docker CLI cannot resolve `~/.docker/config.json` (e.g.
`/root/.docker/config.json`) and ships no registry credentials to the
swarm. Private-registry images then fail to pull on the nodes:

  image registry.example.com/... could not be accessed on a registry to
  record its digest. Each node will access ... independently

while the deploy still logs "Docker Compose Deployed: ".

Keep PATH isolation but preserve HOME so docker can read its config for
both `stack deploy --with-registry-auth` and `compose up -d --build`.

Add a regression test asserting the generated command preserves
`HOME="$HOME"` for both stack and docker-compose deploys.

Fixes #4401

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 01:42:49 -06:00
Mauricio Siu
9bd44512f0 chore: update version to v0.29.6 in package.json 2026-05-30 01:36:45 -06:00
Philippe Parage
ad680ae108 fix: wrap long server names and keep actions menu visible (#4434)
On settings/servers, a long server name in the card title (h3) did not
wrap and overflowed its container, overlapping nearby content and
squeezing the three-dots actions menu until it disappeared.

Allow the title block to shrink and wrap (min-w-0 + break-words), keep
the server icon and the actions trigger from being crushed (shrink-0),
and add gap between the title and the actions button.
2026-05-30 01:34:21 -06:00
Mauricio Siu
d7d642230c fix: use create permission for basic auth delete instead of delete (#4513) 2026-05-30 01:11:42 -06:00
Mauricio Siu
4ba0f71220 fix: grant create and delete SSH key permissions when canAccessToSSHKeys is enabled for members (#4512) 2026-05-30 01:06:45 -06:00
Mauricio Siu
8018027330 feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso) (#4511)
* feat: add self-hosted enterprise restrictions (remote-servers-only, enforce-sso)

- Add `remoteServersOnly` field to webServerSettings: prevents creating services
  on the local Dokploy VM, forcing all deployments to remote servers. Validated
  in all 8 service routers (application, compose, postgres, mysql, mongo, redis,
  mariadb, libsql).
- Add `enforceSSO` field to webServerSettings: hides the email/password login
  form and shows only the SSO button on the login page.
- Both settings are enterprise-only (enterpriseProcedure) and self-hosted-only
  (blocked at the API level when IS_CLOUD=true).
- UI toggles added to the SSO settings page under a new "Self-hosted
  Restrictions" card (hidden in cloud). Login page reads enforceSSO from
  getServerSideProps to avoid client-side flash.
- Migrations: 0167_fresh_goliath.sql, 0168_long_justice.sql

* fix: add missing final newlines to migration files

* refactor: improve code formatting for better readability in multiple components

- Adjusted formatting in `add-application.tsx`, `add-compose.tsx`, and `add-database.tsx` to enhance readability by adding line breaks and consistent indentation.
- Updated `toggle-enforce-sso.tsx` to simplify the Switch component's props.
- Reformatted imports in `index.tsx` and `sso.tsx` for consistency.
- Cleaned up conditional statements in various router files for improved clarity.

* fix: add enforceSSO to test mock
2026-05-30 01:02:34 -06:00
Jasael
6675aa6f37 chore(deps): upgrade next to 16.2.6 (#4477)
Upgraded next dependency in apps/dokploy to 16.2.6 exactly. Verified typescript typecheck passes successfully.
2026-05-24 12:05:28 -06:00
Mauricio Siu
2f43f605f3 chore: update version to v0.29.5 in package.json 2026-05-22 17:20:12 -06:00
Mauricio Siu
103e2f70a8 fix: add tls=true label for domains when certificateType is none (#4018) (#4474)
* fix: add tls=true label for compose domains when certificateType is none (#4018)

* test: cover tls=true label for certificateType none, require https

* fix: scope tls fix to compose labels, leave traefik file config unchanged (#4018)
2026-05-22 17:11:05 -06:00
Mauricio Siu
34d38cf90e fix: enable comment toggle shortcut in env variable editor (#4402) (#4473) 2026-05-22 17:00:58 -06:00
mixelburg
f6e6e5cc00 fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
2026-05-22 16:50:40 -06:00
Mauricio Siu
b06138b230 fix: prevent webhook deploy crash when commit data lacks modified files (#4470)
shouldDeploy passed undefined/null entries from commit.modified straight
into micromatch, which throws "Expected input to be a string" and fails
every webhook deployment when watch paths are configured. Filter out
non-string values before matching.
2026-05-22 16:46:26 -06:00
Mauricio Siu
af8072d7ad fix: allow square brackets in zip path validation for Next.js dynamic routes (#4468)
* fix: allow square brackets in zip drop path validation for Next.js dynamic routes

ZIP uploads containing Next.js dynamic route files (e.g. app/api/[id]/route.ts,
pages/[slug].tsx) were rejected by readValidDirectory because the path regex
did not include square bracket characters.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-22 16:26:34 -06:00
Francis
6e342ee2f2 fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382) 2026-05-13 01:09:47 -06:00
Nahidujjaman Hridoy
ef0cf9bd02 fix: responsive layout (#4391)
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-13 01:03:59 -06:00
Volodymyr Kravchuk
8d88a34a64 fix: copy Dokploy server IP when clicking server badge (#4390)
* fix: copy Dokploy server IP when clicking server badge

When a service runs on the local Dokploy server (no remote server),
clicking the server badge did nothing because `data.server` is null.
Now falls back to the server IP from settings so the badge always
copies an IP address.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat(copy-ip): implement IP address copying functionality across database service components

- Added the ability to copy the server IP address to the clipboard when clicking the server badge in various database service components (Libsql, MariaDB, MongoDB, MySQL, PostgreSQL, Redis).
- Integrated the `copy-to-clipboard` library and `sonner` for user feedback upon successful copy action.
- Ensured fallback to the server IP from settings when the service data is not available, enhancing user experience and functionality.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
2026-05-13 01:03:29 -06:00
Mauricio Siu
a50f958a6f feat(settings): add copy button to server IP in web server settings (#4397) 2026-05-13 00:54:20 -06:00
Mauricio Siu
1fdbe87d84 feat(user): implement session cleanup on user update
- Added functionality to delete old sessions when a user updates their password, ensuring that only the current session remains active.
- This change enhances security by preventing unauthorized access from previous sessions after a password change.

Close here https://github.com/Dokploy/dokploy/security/advisories/GHSA-rr9m-w87g-46f3
2026-05-13 00:49:32 -06:00
Mauricio Siu
67278d8783 feat(organization): prevent inviting users with owner role
- Added validation to prevent users from being invited with the owner role in the organization and user routers.
- Implemented TRPCError responses to ensure proper error handling when attempting to assign the owner role.
This change enhances role management and security within the organization structure.

https://github.com/Dokploy/dokploy/security/advisories/GHSA-fm9p-wmpw-gxjh
2026-05-13 00:42:29 -06:00
Mauricio Siu
aff200f84f feat(deployment): add server access validation for deployment actions
- Implemented server access validation in deployment procedures to ensure users can only access deployments associated with their active organization.
- Added checks to throw an UNAUTHORIZED error if a user attempts to access a deployment linked to a server outside their organization.

This enhancement improves security and access control within the deployment management system.
2026-05-13 00:09:47 -06:00
Mauricio Siu
558d809871 feat(deployment): add readLogs procedure to fetch deployment logs
- Introduced a new `readLogs` procedure that allows users to retrieve logs for a specific deployment by providing the deployment ID and an optional tail parameter.
- Implemented permission checks to ensure users have access to the requested logs.
- Enhanced log retrieval for both cloud and non-cloud environments, utilizing appropriate commands based on the server context.

Resolve https://github.com/Dokploy/mcp/issues/14
2026-05-13 00:04:26 -06:00
Mauricio Siu
f8fcf68909 Enhance version synchronization workflow to include SDK repository
- Updated the GitHub Actions workflow to sync versioning across MCP, CLI, and SDK repositories.
- Added steps to bump the version in the SDK repository and regenerate tools from the latest OpenAPI spec.
- Improved commit message formatting to include source and release information for all repositories.
- Ensured successful synchronization messages for each repository after the version update.
2026-05-12 13:26:09 -06:00
Mauricio Siu
7a568aadac Merge pull request #4395 from Dokploy/feat/import-compose-from-base64
feat(compose): add import from base64 in create service dropdown
2026-05-12 13:13:33 -06:00
autofix-ci[bot]
63e33a29cc [autofix.ci] apply automated fixes 2026-05-12 19:12:46 +00:00
Mauricio Siu
754774ea02 feat(compose): add import from base64 in create service dropdown
Adds an "Import" option to the Create Service dropdown that lets users
paste a base64-encoded compose export, preview the template (compose YAML,
domains, envs, mounts) before confirming, and create the service only on
confirm. Adds a `previewTemplate` tRPC procedure that processes the base64
without touching the DB, with server access validation via session.
2026-05-12 13:12:14 -06:00
Mauricio Siu
a714e0f83f Merge pull request #4394 from ngenohkevin/fix/migrate-auth-secret-exit-on-empty
fix(migrate-auth-secret): exit cleanly when there are no 2FA records
2026-05-12 13:04:23 -06:00
ngenohkevin
9f10f0f4e9 fix(migrate-auth-secret): exit cleanly when there are no 2FA records
The empty-records branch of `main()` returned without calling
`process.exit(0)`, leaving the Drizzle Postgres connection pool
holding the event loop open. The `migrate-auth-secret` process
then hangs indefinitely after printing "No 2FA records found,
nothing to migrate." causing the upstream `0.29.3.sh` security
migration script (which calls this via `docker exec`) to never
reach its final `docker service update` step that mounts the new
Docker Secret. Operators end up with the new secret created but
the dokploy service still configured with the hardcoded
`BETTER_AUTH_SECRET`, while believing the migration completed.

Match the success branch a few lines below which already does
`process.exit(0)`, and the pattern used in sibling scripts
`reset-password.ts` and `reset-2fa.ts`.

Closes #4392
2026-05-12 21:35:02 +03:00
38 changed files with 10170 additions and 487 deletions

View File

@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.

View File

@@ -0,0 +1,148 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const hasValidLicense = vi.fn();
const getWebServerSettings = vi.fn();
const findFirstOrg = vi.fn();
const findFirstServer = vi.fn();
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
organization: {
findFirst: (...args: unknown[]) => findFirstOrg(...args),
},
server: {
findFirst: (...args: unknown[]) => findFirstServer(...args),
},
},
},
}));
vi.mock("@dokploy/server/db/schema", () => ({
organization: {},
server: {},
}));
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: (...args: unknown[]) => hasValidLicense(...args),
}));
vi.mock("@dokploy/server/services/web-server-settings", () => ({
getWebServerSettings: (...args: unknown[]) => getWebServerSettings(...args),
}));
vi.mock("drizzle-orm", () => ({ eq: vi.fn() }));
import {
assertBuildsConcurrencyAllowed,
resolveBuildsConcurrency,
} from "../../server/queues/concurrency";
import { LOCAL_PARTITION } from "../../server/queues/in-memory-queue";
describe("resolveBuildsConcurrency (enterprise gating)", () => {
beforeEach(() => {
vi.clearAllMocks();
findFirstOrg.mockResolvedValue({ id: "org-1" });
});
describe("local web server partition", () => {
it("returns the configured concurrency when licensed", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 5 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(5);
});
it("clamps to the free max (2) when there is no valid license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 10 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("allows the free max (2) without a license", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 2 });
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(2);
});
it("does not cap the value when licensed (N allowed)", async () => {
getWebServerSettings.mockResolvedValue({ buildsConcurrency: 999 });
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(
999,
);
});
it("defaults to 1 when settings are missing", async () => {
getWebServerSettings.mockResolvedValue(undefined);
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("remote server partition", () => {
it("returns the server concurrency when its org is licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 4,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(true);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(4);
expect(hasValidLicense).toHaveBeenCalledWith("org-1");
});
it("clamps to the free max (2) when the server org is not licensed", async () => {
findFirstServer.mockResolvedValue({
buildsConcurrency: 8,
organizationId: "org-1",
});
hasValidLicense.mockResolvedValue(false);
await expect(resolveBuildsConcurrency("server-1")).resolves.toBe(2);
});
it("defaults to 1 for an unknown server", async () => {
findFirstServer.mockResolvedValue(undefined);
await expect(resolveBuildsConcurrency("ghost")).resolves.toBe(1);
});
});
it("falls back to 1 if resolution throws", async () => {
getWebServerSettings.mockRejectedValue(new Error("db down"));
await expect(resolveBuildsConcurrency(LOCAL_PARTITION)).resolves.toBe(1);
});
});
describe("assertBuildsConcurrencyAllowed", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows up to the free max (2) without checking the license", async () => {
await expect(
assertBuildsConcurrencyAllowed(2, "org-1"),
).resolves.toBeUndefined();
expect(hasValidLicense).not.toHaveBeenCalled();
});
it("allows more than 2 when licensed", async () => {
hasValidLicense.mockResolvedValue(true);
await expect(
assertBuildsConcurrencyAllowed(5, "org-1"),
).resolves.toBeUndefined();
});
it("rejects more than 2 without a license", async () => {
hasValidLicense.mockResolvedValue(false);
await expect(assertBuildsConcurrencyAllowed(3, "org-1")).rejects.toThrow(
/enterprise license/i,
);
});
});

View File

@@ -0,0 +1,337 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
getGroup,
getPartition,
InMemoryQueue,
LOCAL_PARTITION,
} from "../../server/queues/in-memory-queue";
import type { DeploymentJob } from "../../server/queues/queue-types";
const appJob = (applicationId: string, serverId?: string): DeploymentJob => ({
applicationId,
titleLog: "deploy",
descriptionLog: "",
type: "deploy",
applicationType: "application",
serverId,
});
const composeJob = (composeId: string, serverId?: string): DeploymentJob => ({
composeId,
titleLog: "deploy",
descriptionLog: "",
type: "deploy",
applicationType: "compose",
serverId,
});
/** A controllable async task: resolves only when `release()` is called. */
const deferred = () => {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, release: resolve };
};
const flush = () => new Promise((r) => setTimeout(r, 0));
describe("getPartition / getGroup", () => {
it("partitions by serverId, falling back to the local partition", () => {
expect(getPartition(appJob("a"))).toBe(LOCAL_PARTITION);
expect(getPartition(appJob("a", "server-1"))).toBe("server-1");
});
it("groups applications and compose by their id", () => {
expect(getGroup(appJob("a"))).toBe("application:a");
expect(getGroup(composeJob("c"))).toBe("compose:c");
});
});
describe("InMemoryQueue concurrency", () => {
let nowValue = 0;
const now = () => ++nowValue;
beforeEach(() => {
nowValue = 0;
});
it("runs different applications concurrently up to the limit", async () => {
const tasks = new Map<string, ReturnType<typeof deferred>>();
const started: string[] = [];
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await queue.add(appJob("c"));
await flush();
// Concurrency 2 -> only a and b start, c waits.
expect(started).toEqual(["a", "b"]);
tasks.get("a")!.release();
await flush();
// A slot freed -> c starts.
expect(started).toEqual(["a", "b", "c"]);
});
it("serializes jobs of the same application (per-group FIFO)", async () => {
const tasks: Array<ReturnType<typeof deferred>> = [];
const started: number[] = [];
let counter = 0;
const queue = new InMemoryQueue({ resolveConcurrency: () => 5, now });
queue.process(async () => {
started.push(++counter);
const d = deferred();
tasks.push(d);
await d.promise;
});
await queue.run();
// Two deploys of the SAME app, even with concurrency 5.
await queue.add(appJob("same"));
await queue.add(appJob("same"));
await flush();
// Only the first one runs; the second waits for the group to free.
expect(started).toEqual([1]);
tasks[0]!.release();
await flush();
expect(started).toEqual([1, 2]);
});
it("isolates concurrency per server partition", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
// server-1 allows 1, server-2 allows 1, but they are independent.
const queue = new InMemoryQueue({
resolveConcurrency: () => 1,
now,
});
queue.process(async (job) => {
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a", "server-1"));
await queue.add(appJob("b", "server-2"));
await flush();
// One per partition runs in parallel despite concurrency 1 each.
expect(started.sort()).toEqual(["server-1:a", "server-2:b"]);
});
it("honors a different concurrency per server", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
// server-fast allows 2, server-slow allows 1.
const queue = new InMemoryQueue({
resolveConcurrency: (partition) => (partition === "server-fast" ? 2 : 1),
now,
});
queue.process(async (job) => {
const id = `${job.data.serverId}:${(job.data as any).applicationId}`;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a", "server-fast"));
await queue.add(appJob("b", "server-fast"));
await queue.add(appJob("c", "server-slow"));
await queue.add(appJob("d", "server-slow"));
await flush();
// server-fast runs 2 in parallel; server-slow only 1.
expect(started.sort()).toEqual([
"server-fast:a",
"server-fast:b",
"server-slow:c",
]);
// Free a server-slow slot -> its queued app starts.
tasks.get("server-slow:c")!.release();
await flush();
expect(started).toContain("server-slow:d");
});
it("serializes the same app on a server even with spare concurrency", async () => {
const started: number[] = [];
const tasks: Array<ReturnType<typeof deferred>> = [];
let counter = 0;
// Plenty of room (concurrency 2) but two deploys of the SAME app.
const queue = new InMemoryQueue({ resolveConcurrency: () => 2, now });
queue.process(async () => {
started.push(++counter);
const d = deferred();
tasks.push(d);
await d.promise;
});
await queue.run();
await queue.add(appJob("app-x", "server-1"));
await queue.add(appJob("app-x", "server-1"));
await flush();
// Only one build of app-x runs despite 2 free slots.
expect(started).toEqual([1]);
tasks[0]!.release();
await flush();
expect(started).toEqual([1, 2]);
});
it("clamps concurrency below 1 up to 1 (license-disabled behaviour)", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
// Simulate a non-licensed resolver returning 0 — must still run 1.
const queue = new InMemoryQueue({ resolveConcurrency: () => 0, now });
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await flush();
expect(started).toEqual(["a"]);
});
it("picks up concurrency changes between scheduling ticks", async () => {
const started: string[] = [];
const tasks = new Map<string, ReturnType<typeof deferred>>();
let limit = 1;
const queue = new InMemoryQueue({
resolveConcurrency: () => limit,
now,
});
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
const d = deferred();
tasks.set(id, d);
await d.promise;
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await flush();
expect(started).toEqual(["a"]);
// Raise the limit (e.g. license activated) and release the running job
// so a new tick observes the new concurrency.
limit = 2;
tasks.get("a")!.release();
await flush();
expect(started).toContain("b");
});
});
describe("InMemoryQueue job management", () => {
it("lists waiting jobs and removes them by predicate", async () => {
const block = deferred();
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
queue.process(async () => {
await block.promise;
});
await queue.run();
await queue.add(appJob("running"));
await queue.add(appJob("waiting-1"));
await queue.add(composeJob("waiting-2"));
await flush();
const waiting = await queue.getJobs(["waiting"]);
expect(waiting.map((j) => j.data)).toHaveLength(2);
const removed = queue.removeWaiting(
(data) => (data as any).applicationId === "waiting-1",
);
expect(removed).toBe(1);
const after = await queue.getJobs(["waiting"]);
expect(after).toHaveLength(1);
});
it("clears all waiting jobs", async () => {
const block = deferred();
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
queue.process(async () => {
await block.promise;
});
await queue.run();
await queue.add(appJob("running"));
await queue.add(appJob("waiting-1"));
await queue.add(appJob("waiting-2"));
await flush();
expect(queue.clearWaiting()).toBe(2);
expect(await queue.getJobs(["waiting"])).toHaveLength(0);
});
it("starts processing as soon as a processor is registered", async () => {
const started: string[] = [];
const queue = new InMemoryQueue({ resolveConcurrency: () => 5 });
// No processor yet -> jobs queue but do not run.
await queue.add(appJob("a"));
await flush();
expect(started).toEqual([]);
// Registering the processor auto-starts the queue (no separate run()).
queue.process(async (job) => {
started.push((job.data as any).applicationId);
});
await flush();
expect(started).toEqual(["a"]);
});
it("continues scheduling after a job throws", async () => {
const started: string[] = [];
const queue = new InMemoryQueue({ resolveConcurrency: () => 1 });
queue.process(async (job) => {
const id = (job.data as any).applicationId;
started.push(id);
if (id === "a") throw new Error("boom");
});
await queue.run();
await queue.add(appJob("a"));
await queue.add(appJob("b"));
await flush();
expect(started).toEqual(["a", "b"]);
});
});

View File

@@ -25,6 +25,7 @@ const baseSettings: WebServerSettings = {
letsEncryptEmail: null, letsEncryptEmail: null,
sshPrivateKey: null, sshPrivateKey: null,
enableDockerCleanup: false, enableDockerCleanup: false,
buildsConcurrency: 1,
logCleanupCron: null, logCleanupCron: null,
metricsConfig: { metricsConfig: {
containers: { containers: {

View File

@@ -1,4 +1,11 @@
import { FileIcon, Folder, Loader2, Workflow } from "lucide-react"; import {
FileIcon,
Folder,
FolderOpen,
Loader2,
MousePointerClick,
Workflow,
} from "lucide-react";
import React from "react"; import React from "react";
import { AlertBlock } from "@/components/shared/alert-block"; import { AlertBlock } from "@/components/shared/alert-block";
import { import {
@@ -68,12 +75,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
</div> </div>
)} )}
{directories?.length === 0 && ( {directories?.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]"> <div className="w-full flex-col gap-4 flex items-center justify-center h-[55vh] border border-dashed rounded-lg">
<span className="text-muted-foreground text-lg font-medium"> <div className="flex items-center justify-center size-14 rounded-full bg-muted">
No directories or files detected in{" "} <FolderOpen className="size-7 text-muted-foreground" />
{"'/etc/dokploy/traefik'"} </div>
</span> <div className="flex flex-col items-center gap-1 text-center px-4">
<Folder className="size-8 text-muted-foreground" /> <span className="text-base font-medium">
No configuration files found
</span>
<span className="text-sm text-muted-foreground">
There are no directories or files in{" "}
<code className="bg-muted px-1.5 py-0.5 rounded text-xs">
/etc/dokploy/traefik
</code>{" "}
on this server yet.
</span>
</div>
</div> </div>
)} )}
{directories && directories?.length > 0 && ( {directories && directories?.length > 0 && (
@@ -89,11 +106,19 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
{file ? ( {file ? (
<ShowTraefikFile path={file} serverId={serverId} /> <ShowTraefikFile path={file} serverId={serverId} />
) : ( ) : (
<div className="h-full w-full flex-col gap-2 flex items-center justify-center"> <div className="h-full min-h-[300px] w-full flex-col gap-4 flex items-center justify-center border border-dashed rounded-lg">
<span className="text-muted-foreground text-lg font-medium"> <div className="flex items-center justify-center size-14 rounded-full bg-muted">
No file selected <MousePointerClick className="size-7 text-muted-foreground" />
</span> </div>
<FileIcon className="size-8 text-muted-foreground" /> <div className="flex flex-col items-center gap-1 text-center px-4">
<span className="text-base font-medium">
Select a file to edit
</span>
<span className="text-sm text-muted-foreground">
Choose a file from the tree on the left to view
and edit its contents.
</span>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,30 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowNodes } from "./show-nodes";
interface Props {
serverId: string;
}
export const ShowNodesModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Nodes
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="min-w-[70vw]">
<div className="grid w-full gap-1">
<ShowNodes serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,122 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
// Free tier may set up to 2 concurrent builds; enterprise unlocks more.
const FREE_MAX_CONCURRENCY = 2;
const ENTERPRISE_MAX_CONCURRENCY = 100;
interface Props {
/**
* When provided, configures concurrency for that remote server. When
* omitted, configures the local Dokploy web server.
*/
serverId?: string;
/** Optional title override (e.g. the server name in a list). */
label?: string;
}
/**
* Control to set the number of concurrent builds, either for a remote server
* (`serverId` provided) or the local web server (omitted). Available to
* everyone self-hosted up to FREE_MAX_CONCURRENCY; higher values require a
* valid enterprise license. Not shown in cloud.
*/
export const BuildsConcurrency = ({ serverId, label }: Props) => {
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const serverQuery = api.server.one.useQuery(
{ serverId: serverId ?? "" },
{ enabled: !!serverId },
);
const webServerQuery = api.settings.getWebServerSettings.useQuery(undefined, {
enabled: !serverId,
});
const current = serverId
? serverQuery.data?.buildsConcurrency
: webServerQuery.data?.buildsConcurrency;
const refetch = serverId ? serverQuery.refetch : webServerQuery.refetch;
const updateServer = api.server.updateBuildsConcurrency.useMutation();
const updateWebServer = api.settings.updateBuildsConcurrency.useMutation();
const isPending = serverId
? updateServer.isPending
: updateWebServer.isPending;
const [value, setValue] = useState("1");
useEffect(() => {
if (current) {
setValue(String(current));
}
}, [current]);
// Concurrent builds are a self-hosted feature; not shown in cloud.
if (isCloud) return null;
const max = haveValidLicense
? ENTERPRISE_MAX_CONCURRENCY
: FREE_MAX_CONCURRENCY;
const clamp = (n: number) => Math.min(max, Math.max(1, n));
const handleSave = async () => {
const parsed = clamp(Number.parseInt(value, 10) || 1);
setValue(String(parsed));
try {
if (serverId) {
await updateServer.mutateAsync({ serverId, buildsConcurrency: parsed });
} else {
await updateWebServer.mutateAsync({ buildsConcurrency: parsed });
}
await refetch();
toast.success("Builds concurrency updated");
} catch {
toast.error("Error updating builds concurrency");
}
};
const hasChanges = Number(value) !== (current ?? 1);
return (
<div className="flex flex-col gap-3 rounded-lg border p-3">
<div className="flex flex-row items-center justify-between gap-4">
<div className="space-y-0.5">
<div className="flex items-center gap-2">
<p className="text-sm font-medium">
{label ?? serverQuery.data?.name ?? "Dokploy Server"}
</p>
<span className="text-xs text-muted-foreground rounded border px-1.5 py-0.5">
{serverId
? (serverQuery.data?.ipAddress ?? "remote server")
: "local host"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={max}
value={value}
onChange={(e) => setValue(e.target.value)}
className="w-20"
/>
<Button
type="button"
size="sm"
onClick={handleSave}
isLoading={isPending}
disabled={!hasChanges}
>
Save
</Button>
</div>
</div>
</div>
);
};

View File

@@ -1,30 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowContainers } from "../../docker/show/show-containers";
interface Props {
serverId: string;
}
export const ShowDockerContainersModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Docker Containers
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<div className="grid w-full gap-1">
<ShowContainers serverId={serverId} />
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,6 +1,7 @@
import { BarChartHorizontalBigIcon } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring"; import { ShowPaidMonitoring } from "../../monitoring/paid/servers/show-paid-monitoring";
interface Props { interface Props {
@@ -14,12 +15,9 @@ export const ShowMonitoringModal = ({ url, token }: Props) => {
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<DropdownMenuItem <Button variant="outline" size="icon" className="h-9 w-9">
className="w-full cursor-pointer " <BarChartHorizontalBigIcon className="h-4 w-4" />
onSelect={(e) => e.preventDefault()} </Button>
>
Show Monitoring
</DropdownMenuItem>
</DialogTrigger> </DialogTrigger>
<DialogContent className="sm:max-w-7xl "> <DialogContent className="sm:max-w-7xl ">
<div className="flex gap-4 py-4 w-full"> <div className="flex gap-4 py-4 w-full">

View File

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

View File

@@ -4,7 +4,6 @@ import {
Key, Key,
KeyIcon, KeyIcon,
Loader2, Loader2,
MoreHorizontal,
Network, Network,
ServerIcon, ServerIcon,
Terminal, Terminal,
@@ -25,12 +24,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "@/components/ui/card"; } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@@ -38,16 +31,11 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { ShowNodesModal } from "../cluster/nodes/show-nodes-modal";
import { TerminalModal } from "../web-server/terminal-modal"; import { TerminalModal } from "../web-server/terminal-modal";
import { ShowServerActions } from "./actions/show-server-actions"; import { ShowServerActions } from "./actions/show-server-actions";
import { HandleServers } from "./handle-servers"; import { HandleServers } from "./handle-servers";
import { SetupServer } from "./setup-server"; import { SetupServer } from "./setup-server";
import { ShowDockerContainersModal } from "./show-docker-containers-modal";
import { ShowMonitoringModal } from "./show-monitoring-modal"; import { ShowMonitoringModal } from "./show-monitoring-modal";
import { ShowSchedulesModal } from "./show-schedules-modal";
import { ShowSwarmOverviewModal } from "./show-swarm-overview-modal";
import { ShowTraefikFileSystemModal } from "./show-traefik-file-system-modal";
import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription"; import { WelcomeSubscription } from "./welcome-stripe/welcome-subscription";
export const ShowServers = () => { export const ShowServers = () => {
@@ -138,52 +126,6 @@ export const ShowServers = () => {
{server.name} {server.name}
</CardTitle> </CardTitle>
</div> </div>
{isActive &&
server.sshKeyId &&
!isBuildServer && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 shrink-0 p-0"
>
<span className="sr-only">
More options
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
Advanced
</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
}
/>
)}
<ShowSwarmOverviewModal
serverId={server.serverId}
/>
<ShowNodesModal
serverId={server.serverId}
/>
<ShowSchedulesModal
serverId={server.serverId}
/>
</DropdownMenuContent>
</DropdownMenu>
)}
</div> </div>
<TooltipProvider> <TooltipProvider>
<div className="flex gap-2 mt-2 flex-wrap"> <div className="flex gap-2 mt-2 flex-wrap">
@@ -361,6 +303,27 @@ export const ShowServers = () => {
</Tooltip> </Tooltip>
)} )}
{isCloud &&
server.sshKeyId &&
!isBuildServer && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<ShowMonitoringModal
url={`http://${server.ipAddress}:${server?.metricsConfig?.server?.port}/metrics`}
token={
server?.metricsConfig
?.server?.token
}
/>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Monitoring</p>
</TooltipContent>
</Tooltip>
)}
<div className="flex-1" /> <div className="flex-1" />
{permissions?.server.delete && ( {permissions?.server.delete && (

View File

@@ -1,48 +0,0 @@
import { useState } from "react";
import { Card } from "@/components/ui/card";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ShowSwarmContainers } from "../../swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "../../swarm/monitoring-card";
interface Props {
serverId: string;
}
export const ShowSwarmOverviewModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Swarm Overview
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<Tabs defaultValue="overview">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="containers">Containers</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid w-full gap-1">
<SwarmMonitorCard serverId={serverId} />
</div>
</TabsContent>
<TabsContent value="containers">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md p-6">
<ShowSwarmContainers serverId={serverId} />
</div>
</Card>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,28 +0,0 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { ShowTraefikSystem } from "../../file-system/show-traefik-system";
interface Props {
serverId: string;
}
export const ShowTraefikFileSystemModal = ({ serverId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer "
onSelect={(e) => e.preventDefault()}
>
Show Traefik File System
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-7xl ">
<ShowTraefikSystem serverId={serverId} />
</DialogContent>
</Dialog>
);
};

View File

@@ -182,36 +182,31 @@ const MENU: Menu = {
title: "Schedules", title: "Schedules",
url: "/dashboard/schedules", url: "/dashboard/schedules",
icon: Clock, icon: Clock,
// Only enabled in non-cloud environments isEnabled: ({ permissions }) => !!permissions?.organization.update,
isEnabled: ({ isCloud, permissions }) =>
!isCloud && !!permissions?.organization.update,
}, },
{ {
isSingle: true, isSingle: true,
title: "Traefik File System", title: "Traefik File System",
url: "/dashboard/traefik", url: "/dashboard/traefik",
icon: GalleryVerticalEnd, icon: GalleryVerticalEnd,
// Only enabled for users with access to Traefik files in non-cloud environments // Only enabled for users with access to Traefik files
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ permissions }) => !!permissions?.traefikFiles.read,
!!(permissions?.traefikFiles.read && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
title: "Docker", title: "Docker",
url: "/dashboard/docker", url: "/dashboard/docker",
icon: BlocksIcon, icon: BlocksIcon,
// Only enabled for users with access to Docker in non-cloud environments // Only enabled for users with access to Docker
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ permissions }) => !!permissions?.docker.read,
!!(permissions?.docker.read && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
title: "Swarm", title: "Swarm",
url: "/dashboard/swarm", url: "/dashboard/swarm",
icon: PieChart, icon: PieChart,
// Only enabled for users with access to Docker in non-cloud environments // Only enabled for users with access to Docker
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ permissions }) => !!permissions?.docker.read,
!!(permissions?.docker.read && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,
@@ -303,6 +298,14 @@ const MENU: Menu = {
icon: Server, icon: Server,
isEnabled: ({ permissions }) => !!permissions?.server.read, isEnabled: ({ permissions }) => !!permissions?.server.read,
}, },
{
isSingle: true,
title: "Deployments",
url: "/dashboard/settings/deployments",
icon: Boxes,
isEnabled: ({ permissions, isCloud }) =>
!!(permissions?.server.read && !isCloud),
},
{ {
isSingle: true, isSingle: true,
title: "Users", title: "Users",
@@ -375,9 +378,8 @@ const MENU: Menu = {
title: "Cluster", title: "Cluster",
url: "/dashboard/settings/cluster", url: "/dashboard/settings/cluster",
icon: Boxes, icon: Boxes,
// Only enabled for admins in non-cloud environments // Only enabled for admins
isEnabled: ({ permissions, isCloud }) => isEnabled: ({ permissions }) => !!permissions?.organization.update,
!!(permissions?.organization.update && !isCloud),
}, },
{ {
isSingle: true, isSingle: true,

View File

@@ -0,0 +1,156 @@
import { Loader2, PlusIcon, ServerIcon } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/router";
import { Fragment, type ReactNode } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
const DOKPLOY_SERVER = "dokploy-server";
interface Props {
children: (serverId?: string) => ReactNode;
}
export const ServerFilter = ({ children }: Props) => {
const router = useRouter();
const { data: servers, isLoading: isLoadingServers } =
api.server.withSSHKey.useQuery();
const { data: isCloud, isLoading: isLoadingCloud } =
api.settings.isCloud.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const queryServerId =
typeof router.query.serverId === "string"
? router.query.serverId
: undefined;
const selectedServer = servers?.find(
(server) => server.serverId === queryServerId,
);
// Cloud has no local Dokploy server, so fall back to the first remote server
const serverId = selectedServer
? selectedServer.serverId
: isCloud
? servers?.[0]?.serverId
: undefined;
const setServerId = (value: string) => {
const { serverId: _current, ...query } = router.query;
router.replace(
{
pathname: router.pathname,
query: value === DOKPLOY_SERVER ? query : { ...query, serverId: value },
},
undefined,
{ shallow: true },
);
};
if (isLoadingServers || isLoadingCloud) {
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md flex flex-col gap-2 items-center justify-center min-h-[60vh]">
<span className="text-muted-foreground text-lg font-medium">
Loading...
</span>
<Loader2 className="animate-spin size-8 text-muted-foreground" />
</div>
</Card>
);
}
if (isCloud && !servers?.length) {
return (
<Card className="bg-sidebar p-2.5 rounded-xl w-full">
<div className="rounded-xl bg-background shadow-md flex flex-col items-center justify-center gap-5 min-h-[60vh] border border-dashed px-4">
<div className="flex items-center justify-center size-16 rounded-full bg-muted">
<ServerIcon className="size-8 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1.5 text-center max-w-md">
<span className="text-lg font-medium">No servers yet</span>
<span className="text-sm text-muted-foreground">
{permissions?.server.create
? "This section works on your remote servers. Add your first server to start managing it from here."
: "This section works on your remote servers. Ask an administrator to add a server to your organization."}
</span>
</div>
{permissions?.server.create && (
<Button asChild>
<Link href="/dashboard/settings/servers">
<PlusIcon className="size-4" />
Add Server
</Link>
</Button>
)}
</div>
</Card>
);
}
return (
<div className="flex flex-col gap-4 w-full">
{!!servers?.length && (
<div className="flex w-full items-center justify-end gap-3">
<Label
htmlFor="server-filter"
className="text-sm text-muted-foreground whitespace-nowrap"
>
Viewing server
</Label>
<Select
value={serverId ?? DOKPLOY_SERVER}
onValueChange={setServerId}
>
<SelectTrigger id="server-filter" className="w-fit min-w-[220px]">
<div className="flex items-center gap-2">
<ServerIcon className="size-4 text-muted-foreground" />
<SelectValue placeholder="Select a server" />
</div>
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Servers</SelectLabel>
{!isCloud && (
<SelectItem value={DOKPLOY_SERVER}>
<div className="flex items-center gap-2">
<span>Dokploy Server</span>
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0"
>
Local
</Badge>
</div>
</SelectItem>
)}
{servers.map((server) => (
<SelectItem key={server.serverId} value={server.serverId}>
<div className="flex items-center gap-2">
<span>{server.name}</span>
<span className="text-xs text-muted-foreground">
{server.ipAddress}
</span>
</div>
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
<Fragment key={serverId ?? DOKPLOY_SERVER}>{children(serverId)}</Fragment>
</div>
);
};

View File

@@ -0,0 +1,2 @@
ALTER TABLE "server" ADD COLUMN "buildsConcurrency" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
ALTER TABLE "webServerSettings" ADD COLUMN "buildsConcurrency" integer DEFAULT 1 NOT NULL;

File diff suppressed because it is too large Load Diff

View File

@@ -1205,6 +1205,13 @@
"when": 1780775037209, "when": 1780775037209,
"tag": "0171_lucky_echo", "tag": "0171_lucky_echo",
"breakpoints": true "breakpoints": true
},
{
"idx": 172,
"version": "7",
"when": 1781045439162,
"tag": "0172_quick_the_professor",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth"; import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server"; import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
import { ShowContainers } from "@/components/dashboard/docker/show/show-containers"; import { ShowContainers } from "@/components/dashboard/docker/show/show-containers";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
const Dashboard = () => { const Dashboard = () => {
return <ShowContainers />; return (
<ServerFilter>
{(serverId) => <ShowContainers serverId={serverId} />}
</ServerFilter>
);
}; };
export default Dashboard; export default Dashboard;
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps( export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>, ctx: GetServerSidePropsContext<{ serviceId: string }>,
) { ) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req); const { user, session } = await validateRequest(ctx.req);
if (!user) { if (!user) {
return { return {

View File

@@ -1,20 +1,27 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth"; import { validateRequest } from "@dokploy/server/lib/auth";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules"; import { ShowSchedules } from "@/components/dashboard/application/schedules/show-schedules";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
function SchedulesPage() { function SchedulesPage() {
return ( return (
<div className="w-full"> <ServerFilter>
<Card className="h-full bg-sidebar p-2.5 rounded-xl max-w-8xl mx-auto min-h-[45vh]"> {(serverId) => (
<div className="rounded-xl bg-background shadow-md h-full"> <div className="w-full">
<ShowSchedules scheduleType="dokploy-server" id="dokploy-server" /> <Card className="h-full bg-sidebar p-2.5 rounded-xl w-full min-h-[45vh]">
<div className="rounded-xl bg-background shadow-md h-full">
<ShowSchedules
scheduleType={serverId ? "server" : "dokploy-server"}
id={serverId ?? "dokploy-server"}
/>
</div>
</Card>
</div> </div>
</Card> )}
</div> </ServerFilter>
); );
} }
export default SchedulesPage; export default SchedulesPage;
@@ -26,14 +33,6 @@ SchedulesPage.getLayout = (page: ReactElement) => {
export async function getServerSideProps( export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>, ctx: GetServerSidePropsContext<{ serviceId: string }>,
) { ) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user } = await validateRequest(ctx.req); const { user } = await validateRequest(ctx.req);
if (!user || (user.role !== "owner" && user.role !== "admin")) { if (!user || (user.role !== "owner" && user.role !== "admin")) {
return { return {

View File

@@ -1,17 +1,22 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server"; import { validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server"; import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes"; import { ShowNodes } from "@/components/dashboard/settings/cluster/nodes/show-nodes";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
const Page = () => { const Page = () => {
return ( return (
<div className="flex flex-col gap-4 w-full"> <ServerFilter>
<ShowNodes /> {(serverId) => (
</div> <div className="flex flex-col gap-4 w-full">
<ShowNodes serverId={serverId} />
</div>
)}
</ServerFilter>
); );
}; };
@@ -24,14 +29,6 @@ export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>, ctx: GetServerSidePropsContext<{ serviceId: string }>,
) { ) {
const { req, res } = ctx; const { req, res } = ctx;
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req); const { user, session } = await validateRequest(ctx.req);
if (!user || user.role === "member") { if (!user || user.role === "member") {
return { return {

View File

@@ -0,0 +1,134 @@
import { IS_CLOUD, validateRequest } from "@dokploy/server";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { BuildsConcurrency } from "@/components/dashboard/settings/servers/actions/builds-concurrency";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AlertBlock } from "@/components/shared/alert-block";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { appRouter } from "@/server/api/root";
import { api } from "@/utils/api";
const Page = () => {
const { data: servers } = api.server.all.useQuery();
return (
<div className="w-full">
<div className="h-full rounded-xl max-w-5xl mx-auto flex flex-col gap-4">
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
<div className="rounded-xl bg-background shadow-md">
<CardHeader>
<CardTitle className="text-xl">Concurrent Builds</CardTitle>
<CardDescription>
Configure how many deployments can build at the same time on
each server. Builds of the same service are always serialized.
Free plan allows up to 2 concurrent builds; an enterprise
license unlocks more.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-6">
<AlertBlock type="warning">
Running multiple builds at once increases CPU, memory and disk
usage on each server. Each concurrent build runs its own builder
and image build, so set this based on the resources the machine
can handle too high a value can exhaust memory and make
deployments fail.
</AlertBlock>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">
Dokploy Server
</p>
<BuildsConcurrency />
</div>
<div className="flex flex-col gap-2">
<p className="text-sm font-medium text-muted-foreground">
Remote Servers
</p>
{servers && servers.length > 0 ? (
<div className="flex flex-col gap-3">
{servers.map((server) => (
<BuildsConcurrency
key={server.serverId}
serverId={server.serverId}
label={server.name}
/>
))}
</div>
) : (
<p className="text-sm text-muted-foreground rounded-lg border border-dashed p-4 text-center">
No remote servers added yet.
</p>
)}
</div>
</CardContent>
</div>
</Card>
</div>
</div>
);
};
export default Page;
Page.getLayout = (page: ReactElement) => {
return <DashboardLayout metaName="Deployments">{page}</DashboardLayout>;
};
export async function getServerSideProps(ctx: GetServerSidePropsContext) {
const { req, res } = ctx;
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: false,
destination: "/",
},
};
}
if (user.role === "member") {
return {
redirect: {
permanent: false,
destination: "/dashboard/settings/profile",
},
};
}
// Concurrent builds is a self-hosted feature only.
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/settings/profile",
},
};
}
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
await helpers.user.get.prefetch();
await helpers.server.all.prefetch();
return {
props: {
trpcState: helpers.dehydrate(),
isCloud: IS_CLOUD,
},
};
}

View File

@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth"; import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server"; import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
@@ -7,30 +6,35 @@ import superjson from "superjson";
import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers"; import { ShowSwarmContainers } from "@/components/dashboard/swarm/containers/show-swarm-containers";
import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card"; import SwarmMonitorCard from "@/components/dashboard/swarm/monitoring-card";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
const Dashboard = () => { const Dashboard = () => {
return ( return (
<div className="space-y-4"> <ServerFilter>
<Tabs defaultValue="overview"> {(serverId) => (
<TabsList> <div className="space-y-4">
<TabsTrigger value="overview">Overview</TabsTrigger> <Tabs defaultValue="overview">
<TabsTrigger value="containers">Containers</TabsTrigger> <TabsList>
</TabsList> <TabsTrigger value="overview">Overview</TabsTrigger>
<TabsContent value="overview"> <TabsTrigger value="containers">Containers</TabsTrigger>
<SwarmMonitorCard /> </TabsList>
</TabsContent> <TabsContent value="overview">
<TabsContent value="containers"> <SwarmMonitorCard serverId={serverId} />
<Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full"> </TabsContent>
<div className="rounded-xl bg-background shadow-md p-6"> <TabsContent value="containers">
<ShowSwarmContainers /> <Card className="h-full bg-sidebar p-2.5 rounded-xl mx-auto w-full">
</div> <div className="rounded-xl bg-background shadow-md p-6">
</Card> <ShowSwarmContainers serverId={serverId} />
</TabsContent> </div>
</Tabs> </Card>
</div> </TabsContent>
</Tabs>
</div>
)}
</ServerFilter>
); );
}; };
@@ -42,14 +46,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps( export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>, ctx: GetServerSidePropsContext<{ serviceId: string }>,
) { ) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req); const { user, session } = await validateRequest(ctx.req);
if (!user) { if (!user) {
return { return {

View File

@@ -1,4 +1,3 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth"; import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server"; import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next"; import type { GetServerSidePropsContext } from "next";
@@ -6,10 +5,15 @@ import type { ReactElement } from "react";
import superjson from "superjson"; import superjson from "superjson";
import { ShowTraefikSystem } from "@/components/dashboard/file-system/show-traefik-system"; import { ShowTraefikSystem } from "@/components/dashboard/file-system/show-traefik-system";
import { DashboardLayout } from "@/components/layouts/dashboard-layout"; import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root"; import { appRouter } from "@/server/api/root";
const Dashboard = () => { const Dashboard = () => {
return <ShowTraefikSystem />; return (
<ServerFilter>
{(serverId) => <ShowTraefikSystem serverId={serverId} />}
</ServerFilter>
);
}; };
export default Dashboard; export default Dashboard;
@@ -20,14 +24,6 @@ Dashboard.getLayout = (page: ReactElement) => {
export async function getServerSideProps( export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>, ctx: GetServerSidePropsContext<{ serviceId: string }>,
) { ) {
if (IS_CLOUD) {
return {
redirect: {
permanent: false,
destination: "/dashboard/home",
},
};
}
const { user, session } = await validateRequest(ctx.req); const { user, session } = await validateRequest(ctx.req);
if (!user) { if (!user) {
return { return {

View File

@@ -68,11 +68,9 @@ import {
environments, environments,
projects, projects,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { import {
cleanQueuesByApplication, cleanQueuesByApplication,
getJobsByApplicationId,
killDockerBuild, killDockerBuild,
myQueue, myQueue,
} from "@/server/queues/queueSetup"; } from "@/server/queues/queueSetup";
@@ -242,12 +240,7 @@ export const applicationRouter = createTRPCRouter({
.returning(); .returning();
if (!IS_CLOUD) { if (!IS_CLOUD) {
const queueJobs = await getJobsByApplicationId(input.applicationId); await cleanQueuesByApplication(input.applicationId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
} }
const cleanupOperations = [ const cleanupOperations = [
@@ -339,10 +332,10 @@ export const applicationRouter = createTRPCRouter({
type: "redeploy", type: "redeploy",
applicationType: "application", applicationType: "application",
server: !!application.serverId, server: !!application.serverId,
serverId: application.serverId ?? undefined,
}; };
if (IS_CLOUD && application.serverId) { if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });
@@ -707,9 +700,9 @@ export const applicationRouter = createTRPCRouter({
type: "deploy", type: "deploy",
applicationType: "application", applicationType: "application",
server: !!application.serverId, server: !!application.serverId,
serverId: application.serverId ?? undefined,
}; };
if (IS_CLOUD && application.serverId) { if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });
@@ -826,9 +819,9 @@ export const applicationRouter = createTRPCRouter({
type: "deploy", type: "deploy",
applicationType: "application", applicationType: "application",
server: !!app.serverId, server: !!app.serverId,
serverId: app.serverId ?? undefined,
}; };
if (IS_CLOUD && app.serverId) { if (IS_CLOUD && app.serverId) {
jobData.serverId = app.serverId;
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });

View File

@@ -68,11 +68,9 @@ import {
environments, environments,
projects, projects,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { deploymentWorker } from "@/server/queues/deployments-queue";
import type { DeploymentJob } from "@/server/queues/queue-types"; import type { DeploymentJob } from "@/server/queues/queue-types";
import { import {
cleanQueuesByCompose, cleanQueuesByCompose,
getJobsByComposeId,
killDockerBuild, killDockerBuild,
myQueue, myQueue,
} from "@/server/queues/queueSetup"; } from "@/server/queues/queueSetup";
@@ -252,12 +250,7 @@ export const composeRouter = createTRPCRouter({
.returning(); .returning();
if (!IS_CLOUD) { if (!IS_CLOUD) {
const queueJobs = await getJobsByComposeId(input.composeId); await cleanQueuesByCompose(input.composeId);
for (const job of queueJobs) {
if (job.id) {
deploymentWorker.cancelJob(job.id, "User requested cancellation");
}
}
} }
const cleanupOperations = [ const cleanupOperations = [
@@ -430,10 +423,10 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose", applicationType: "compose",
descriptionLog: input.description || "", descriptionLog: input.description || "",
server: !!compose.serverId, server: !!compose.serverId,
serverId: compose.serverId ?? undefined,
}; };
if (IS_CLOUD && compose.serverId) { if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });
@@ -479,9 +472,9 @@ export const composeRouter = createTRPCRouter({
applicationType: "compose", applicationType: "compose",
descriptionLog: input.description || "", descriptionLog: input.description || "",
server: !!compose.serverId, server: !!compose.serverId,
serverId: compose.serverId ?? undefined,
}; };
if (IS_CLOUD && compose.serverId) { if (IS_CLOUD && compose.serverId) {
jobData.serverId = compose.serverId;
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });

View File

@@ -86,10 +86,10 @@ export const previewDeploymentRouter = createTRPCRouter({
applicationType: "application-preview", applicationType: "application-preview",
previewDeploymentId: input.previewDeploymentId, previewDeploymentId: input.previewDeploymentId,
server: !!application.serverId, server: !!application.serverId,
serverId: application.serverId ?? undefined,
}; };
if (IS_CLOUD && application.serverId) { if (IS_CLOUD && application.serverId) {
jobData.serverId = application.serverId;
deploy(jobData).catch((error) => { deploy(jobData).catch((error) => {
console.error("Background deployment failed:", error); console.error("Background deployment failed:", error);
}); });

View File

@@ -34,6 +34,7 @@ import {
apiFindOneServer, apiFindOneServer,
apiRemoveServer, apiRemoveServer,
apiUpdateServer, apiUpdateServer,
apiUpdateServerBuildsConcurrency,
apiUpdateServerMonitoring, apiUpdateServerMonitoring,
applications, applications,
compose, compose,
@@ -45,6 +46,7 @@ import {
redis, redis,
server, server,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup"; import { applyDockerCleanupSchedule } from "@/server/utils/docker-cleanup";
export const serverRouter = createTRPCRouter({ export const serverRouter = createTRPCRouter({
@@ -479,6 +481,24 @@ export const serverRouter = createTRPCRouter({
throw error; throw error;
} }
}), }),
updateBuildsConcurrency: withPermission("server", "create")
.input(apiUpdateServerBuildsConcurrency)
.mutation(async ({ input, ctx }) => {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to update this server",
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
return await updateServerById(input.serverId, {
buildsConcurrency: input.buildsConcurrency,
});
}),
publicIp: protectedProcedure.query(async () => { publicIp: protectedProcedure.query(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return ""; return "";

View File

@@ -67,9 +67,11 @@ import {
apiServerSchema, apiServerSchema,
apiTraefikConfig, apiTraefikConfig,
apiUpdateDockerCleanup, apiUpdateDockerCleanup,
apiUpdateWebServerBuildsConcurrency,
projects, projects,
server, server,
} from "@/server/db/schema"; } from "@/server/db/schema";
import { assertBuildsConcurrencyAllowed } from "@/server/queues/concurrency";
import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup"; import { cleanAllDeploymentQueue } from "@/server/queues/queueSetup";
import { removeJob, schedule } from "@/server/utils/backup"; import { removeJob, schedule } from "@/server/utils/backup";
import packageInfo from "../../../package.json"; import packageInfo from "../../../package.json";
@@ -468,6 +470,33 @@ export const settingsRouter = createTRPCRouter({
return true; return true;
}), }),
updateBuildsConcurrency: adminProcedure
.input(apiUpdateWebServerBuildsConcurrency)
.mutation(async ({ input, ctx }) => {
if (IS_CLOUD) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "This feature is only available for self-hosted instances",
});
}
await assertBuildsConcurrencyAllowed(
input.buildsConcurrency,
ctx.session.activeOrganizationId,
);
await updateWebServerSettings({
buildsConcurrency: input.buildsConcurrency,
});
await audit(ctx, {
action: "update",
resourceType: "settings",
resourceName: "builds-concurrency",
});
return true;
}),
updateEnforceSSO: enterpriseProcedure updateEnforceSSO: enterpriseProcedure
.input(z.object({ enforceSSO: z.boolean() })) .input(z.object({ enforceSSO: z.boolean() }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {

View File

@@ -0,0 +1,90 @@
import { db } from "@dokploy/server/db";
import { organization, server } from "@dokploy/server/db/schema";
import { hasValidLicense } from "@dokploy/server/services/proprietary/license-key";
import { getWebServerSettings } from "@dokploy/server/services/web-server-settings";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { LOCAL_PARTITION } from "./in-memory-queue";
/**
* Resolve the effective builds concurrency for a queue partition.
*
* Concurrent deployments (concurrency > 1) are an enterprise feature: without a
* valid license the effective concurrency is always clamped to 1, so the
* community experience is unchanged and an expired license degrades gracefully
* back to sequential deployments instead of breaking anything.
*
* - `LOCAL_PARTITION` -> concurrency stored on the web server settings (the
* local Dokploy web server), gated by the owner organization's license.
* - any other partition -> concurrency stored on the matching `server` row,
* gated by that server's organization license.
*/
export const resolveBuildsConcurrency = async (
partition: string,
): Promise<number> => {
try {
if (partition === LOCAL_PARTITION) {
return await resolveLocalConcurrency();
}
return await resolveServerConcurrency(partition);
} catch (error) {
console.error(
"Failed to resolve builds concurrency, defaulting to 1",
error,
);
return 1;
}
};
// Max concurrent builds allowed without an enterprise license. With a valid
// license the value is unbounded (N) — only the free tier is capped.
export const FREE_MAX_CONCURRENCY = 2;
const clamp = (value: number, licensed: boolean): number => {
const min = Math.max(1, Math.floor(value));
return licensed ? min : Math.min(FREE_MAX_CONCURRENCY, min);
};
/**
* Validate a requested builds-concurrency value before persisting it. Free tier
* may set up to FREE_MAX_CONCURRENCY; anything higher requires a valid
* enterprise license. Throws a TRPCError when the value is not allowed.
*/
export const assertBuildsConcurrencyAllowed = async (
value: number,
organizationId: string,
): Promise<void> => {
if (value <= FREE_MAX_CONCURRENCY) return;
const licensed = await hasValidLicense(organizationId);
if (!licensed) {
throw new TRPCError({
code: "FORBIDDEN",
message: `A valid enterprise license is required to set more than ${FREE_MAX_CONCURRENCY} concurrent builds.`,
});
}
};
const resolveLocalConcurrency = async (): Promise<number> => {
const settings = await getWebServerSettings();
const buildsConcurrency = settings?.buildsConcurrency ?? 1;
// Self-hosted is single-tenant; gate on any organization's license.
const anyOrg = await db.query.organization.findFirst({
columns: { id: true },
});
const licensed = anyOrg ? await hasValidLicense(anyOrg.id) : false;
return clamp(buildsConcurrency, licensed);
};
const resolveServerConcurrency = async (serverId: string): Promise<number> => {
const currentServer = await db.query.server.findFirst({
where: eq(server.serverId, serverId),
columns: { buildsConcurrency: true, organizationId: true },
});
if (!currentServer) return 1;
const licensed = await hasValidLicense(currentServer.organizationId);
return clamp(currentServer.buildsConcurrency, licensed);
};

View File

@@ -2,7 +2,6 @@ import {
deployApplication, deployApplication,
deployCompose, deployCompose,
deployPreviewApplication, deployPreviewApplication,
IS_CLOUD,
rebuildApplication, rebuildApplication,
rebuildCompose, rebuildCompose,
rebuildPreviewApplication, rebuildPreviewApplication,
@@ -10,87 +9,69 @@ import {
updateCompose, updateCompose,
updatePreviewDeployment, updatePreviewDeployment,
} from "@dokploy/server"; } from "@dokploy/server";
import { type Job, Worker } from "bullmq"; import type { InMemoryJob } from "./in-memory-queue";
import type { DeploymentJob } from "./queue-types";
import { redisConfig } from "./redis-connection";
const createDeploymentWorker = () => /**
new Worker( * Processes a single deployment job. Shared by the in-memory queue worker and
"deployments", * (in cloud) the direct background execution path.
async (job: Job<DeploymentJob>) => { */
try { export const processDeploymentJob = async (job: InMemoryJob) => {
if (job.data.applicationType === "application") { try {
await updateApplicationStatus(job.data.applicationId, "running"); if (job.data.applicationType === "application") {
await updateApplicationStatus(job.data.applicationId, "running");
if (job.data.type === "redeploy") { if (job.data.type === "redeploy") {
await rebuildApplication({ await rebuildApplication({
applicationId: job.data.applicationId, applicationId: job.data.applicationId,
titleLog: job.data.titleLog, titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog, descriptionLog: job.data.descriptionLog,
}); });
} else if (job.data.type === "deploy") { } else if (job.data.type === "deploy") {
await deployApplication({ await deployApplication({
applicationId: job.data.applicationId, applicationId: job.data.applicationId,
titleLog: job.data.titleLog, titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog, descriptionLog: job.data.descriptionLog,
}); });
}
} else if (job.data.applicationType === "compose") {
await updateCompose(job.data.composeId, {
composeStatus: "running",
});
if (job.data.type === "deploy") {
await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
if (job.data.type === "redeploy") {
await rebuildPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
} catch (error) {
console.log("Error", error);
} }
}, } else if (job.data.applicationType === "compose") {
{ await updateCompose(job.data.composeId, {
autorun: false, composeStatus: "running",
connection: redisConfig, });
}, if (job.data.type === "deploy") {
); await deployCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
} else if (job.data.type === "redeploy") {
await rebuildCompose({
composeId: job.data.composeId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
});
}
} else if (job.data.applicationType === "application-preview") {
await updatePreviewDeployment(job.data.previewDeploymentId, {
previewStatus: "running",
});
/** No-op worker when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */ if (job.data.type === "redeploy") {
const noopWorker = { await rebuildPreviewApplication({
run: () => Promise.resolve(), applicationId: job.data.applicationId,
close: () => Promise.resolve(), titleLog: job.data.titleLog,
cancelJob: () => Promise.resolve(), descriptionLog: job.data.descriptionLog,
cancelAllJobs: () => Promise.resolve(), previewDeploymentId: job.data.previewDeploymentId,
});
} else if (job.data.type === "deploy") {
await deployPreviewApplication({
applicationId: job.data.applicationId,
titleLog: job.data.titleLog,
descriptionLog: job.data.descriptionLog,
previewDeploymentId: job.data.previewDeploymentId,
});
}
}
} catch (error) {
console.log("Error", error);
}
}; };
export const deploymentWorker = !IS_CLOUD
? createDeploymentWorker()
: (noopWorker as unknown as Worker<DeploymentJob>);

View File

@@ -0,0 +1,262 @@
import type { DeploymentJob } from "./queue-types";
/**
* In-memory deployment queue for self-hosted instances.
*
* Replaces BullMQ/Redis for deployments. The model is per-group FIFO with a
* configurable concurrency per partition (server):
*
* - Jobs are partitioned by `serverId` (the local web server uses the
* `LOCAL_PARTITION` key). Each partition runs up to `concurrency` jobs at
* the same time, so two different applications can build concurrently.
* - Within a partition, jobs that belong to the same group (same application
* or compose) never run in parallel — they are serialized FIFO. This avoids
* two builds of the same service stepping on each other (same code dir,
* same container name, etc).
*
* The concurrency is resolved lazily per partition through `resolveConcurrency`
* so it can be gated by the enterprise license at run time (a non-licensed
* instance always resolves to 1).
*
* The public surface (`add`, `getJobs`, `close`, `on`) mirrors the subset of
* BullMQ used by the routers so it can be a drop-in replacement.
*/
export const LOCAL_PARTITION = "__local__";
export type JobState = "waiting" | "active";
export interface InMemoryJob {
id: string;
name: string;
data: DeploymentJob;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
getState: () => Promise<JobState>;
remove: () => Promise<void>;
}
type Processor = (job: InMemoryJob) => Promise<void>;
/** Resolve the partition key (serverId) a job belongs to. */
export const getPartition = (data: DeploymentJob): string =>
data.serverId ?? LOCAL_PARTITION;
/** Resolve the FIFO group a job belongs to (the service being deployed). */
export const getGroup = (data: DeploymentJob): string => {
if (data.applicationType === "compose") {
return `compose:${data.composeId}`;
}
return `application:${data.applicationId}`;
};
interface InternalJob {
id: string;
name: string;
data: DeploymentJob;
timestamp: number;
processedOn?: number;
finishedOn?: number;
failedReason?: string;
state: JobState;
partition: string;
group: string;
}
interface Partition {
waiting: InternalJob[];
/** Groups currently running in this partition. */
activeGroups: Set<string>;
active: InternalJob[];
}
export interface InMemoryQueueOptions {
/**
* Returns the max number of jobs that may run in parallel for a given
* partition. Called on every scheduling tick so license/config changes are
* picked up without restarting the queue. Must return a value >= 1.
*/
resolveConcurrency: (partition: string) => Promise<number> | number;
/** Monotonic clock; injectable for tests. Defaults to Date.now. */
now?: () => number;
}
export class InMemoryQueue {
private partitions = new Map<string, Partition>();
private processor: Processor | null = null;
private running = false;
private seq = 0;
private readonly resolveConcurrency: InMemoryQueueOptions["resolveConcurrency"];
private readonly now: () => number;
constructor(options: InMemoryQueueOptions) {
this.resolveConcurrency = options.resolveConcurrency;
this.now = options.now ?? (() => Date.now());
}
private getPartitionState(key: string): Partition {
let partition = this.partitions.get(key);
if (!partition) {
partition = { waiting: [], activeGroups: new Set(), active: [] };
this.partitions.set(key, partition);
}
return partition;
}
/**
* Register the worker that processes each job. Registering a processor also
* starts the queue: in dev (tsx/Next) the module that calls `run()` and the
* module that calls `add()` can resolve to different instances, so we must
* not depend on a separate `run()` call to flip `running` on.
*/
process(processor: Processor) {
this.processor = processor;
this.running = true;
this.schedule();
}
run() {
this.running = true;
this.schedule();
return Promise.resolve();
}
async add(data: DeploymentJob): Promise<{ id: string }> {
const id = `job-${++this.seq}`;
const partitionKey = getPartition(data);
const job: InternalJob = {
id,
name: "deployments",
data,
timestamp: this.now(),
state: "waiting",
partition: partitionKey,
group: getGroup(data),
};
this.getPartitionState(partitionKey).waiting.push(job);
this.schedule();
return { id };
}
private toPublic(job: InternalJob): InMemoryJob {
return {
id: job.id,
name: job.name,
data: job.data,
timestamp: job.timestamp,
processedOn: job.processedOn,
finishedOn: job.finishedOn,
getState: () => Promise.resolve(job.state),
remove: () => this.remove(job.id),
};
}
/** Snapshot of jobs in the requested states (defaults to waiting + active). */
getJobs(states?: JobState[]): Promise<InMemoryJob[]> {
const wantWaiting = !states || states.includes("waiting");
const wantActive = !states || states.includes("active");
const jobs: InMemoryJob[] = [];
for (const partition of this.partitions.values()) {
if (wantWaiting) {
jobs.push(...partition.waiting.map((job) => this.toPublic(job)));
}
if (wantActive) {
jobs.push(...partition.active.map((job) => this.toPublic(job)));
}
}
return Promise.resolve(jobs);
}
/** Remove a single waiting job by id. Active jobs cannot be removed. */
remove(id: string): Promise<void> {
for (const partition of this.partitions.values()) {
const before = partition.waiting.length;
partition.waiting = partition.waiting.filter((job) => job.id !== id);
if (partition.waiting.length !== before) break;
}
return Promise.resolve();
}
/** Remove waiting jobs matching a predicate. Active jobs are not affected. */
removeWaiting(predicate: (data: DeploymentJob) => boolean): number {
let removed = 0;
for (const partition of this.partitions.values()) {
partition.waiting = partition.waiting.filter((job) => {
const match = predicate(job.data);
if (match) removed++;
return !match;
});
}
return removed;
}
/** Drop every waiting job across all partitions. */
clearWaiting(): number {
let removed = 0;
for (const partition of this.partitions.values()) {
removed += partition.waiting.length;
partition.waiting = [];
}
return removed;
}
on() {
// No-op: kept for BullMQ API compatibility (error events, etc).
}
close() {
this.running = false;
return Promise.resolve();
}
private schedule() {
if (!this.running || !this.processor) return;
for (const key of this.partitions.keys()) {
void this.drainPartition(key);
}
}
private async drainPartition(key: string) {
const partition = this.partitions.get(key);
if (!partition || !this.processor) return;
const concurrency = Math.max(1, await this.resolveConcurrency(key));
while (partition.active.length < concurrency) {
// First waiting job whose group is not already running.
const index = partition.waiting.findIndex(
(job) => !partition.activeGroups.has(job.group),
);
if (index === -1) break;
const job = partition.waiting.splice(index, 1)[0];
if (!job) break;
job.state = "active";
job.processedOn = this.now();
partition.activeGroups.add(job.group);
partition.active.push(job);
void this.runJob(job);
}
}
private async runJob(job: InternalJob) {
try {
await this.processor?.(this.toPublic(job));
} catch (error) {
job.failedReason = error instanceof Error ? error.message : String(error);
console.error("In-memory deployment job failed", error);
} finally {
job.finishedOn = this.now();
const partition = this.partitions.get(job.partition);
if (partition) {
partition.active = partition.active.filter((j) => j.id !== job.id);
partition.activeGroups.delete(job.group);
}
// A slot (and possibly the group) freed up — try to schedule more.
void this.drainPartition(job.partition);
}
}
}

View File

@@ -3,32 +3,89 @@ import {
execAsync, execAsync,
execAsyncRemote, execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync"; } from "@dokploy/server/utils/process/execAsync";
import type { Job } from "bullmq"; import { resolveBuildsConcurrency } from "./concurrency";
import { Queue } from "bullmq"; import { processDeploymentJob } from "./deployments-queue";
import { deploymentWorker } from "./deployments-queue"; import { type InMemoryJob, InMemoryQueue } from "./in-memory-queue";
import { redisConfig } from "./redis-connection"; import type { DeploymentJob } from "./queue-types";
/** No-op queue when Redis is disabled (e.g. IS_CLOUD). Avoids BullMQ connection errors. */ /**
const createNoopQueue = () => ({ * Deployment queue.
getJobs: () => Promise.resolve([] as Job[]), *
add: () => * Self-hosted uses an in-memory, per-group FIFO queue with configurable
Promise.resolve({ id: "noop", remove: () => Promise.resolve() } as Job), * concurrency per server (enterprise-gated). Cloud does not use the queue at
* all — deployments run directly in the background — so we expose a no-op.
*/
interface DeploymentQueue {
add: (
name: string,
data: DeploymentJob,
opts?: Record<string, unknown>,
) => Promise<{ id: string }>;
getJobs: (states?: Array<"waiting" | "active">) => Promise<InMemoryJob[]>;
close: () => Promise<void>;
on: (...args: unknown[]) => void;
run: () => Promise<void>;
removeWaiting: (predicate: (data: DeploymentJob) => boolean) => number;
clearWaiting: () => number;
}
const createNoopQueue = (): DeploymentQueue => ({
add: () => Promise.resolve({ id: "noop" }),
getJobs: () => Promise.resolve([]),
close: () => Promise.resolve(), close: () => Promise.resolve(),
on: () => {}, on: () => {},
run: () => Promise.resolve(),
removeWaiting: () => 0,
clearWaiting: () => 0,
}); });
const myQueue = !IS_CLOUD const createInMemoryQueue = (): DeploymentQueue => {
? new Queue("deployments", { connection: redisConfig }) const queue = new InMemoryQueue({
: (createNoopQueue() as unknown as Queue); resolveConcurrency: resolveBuildsConcurrency,
});
queue.process(processDeploymentJob);
return {
add: (_name, data) => queue.add(data),
getJobs: (states) => queue.getJobs(states),
close: () => queue.close(),
on: () => {},
run: () => queue.run(),
removeWaiting: (predicate) => queue.removeWaiting(predicate),
clearWaiting: () => queue.clearWaiting(),
};
};
// Use a global singleton so the deployment queue is shared across every module
// instance. In dev (tsx/Next) the same file can be evaluated more than once
// (relative import in server.ts vs `@/` alias in the routers); without this the
// worker and the `add()` calls would land on different queue instances.
const globalForQueue = globalThis as unknown as {
__dokployDeploymentQueue?: DeploymentQueue;
};
if (!globalForQueue.__dokployDeploymentQueue) {
globalForQueue.__dokployDeploymentQueue = !IS_CLOUD
? createInMemoryQueue()
: createNoopQueue();
}
const myQueue: DeploymentQueue = globalForQueue.__dokployDeploymentQueue;
/** Start processing jobs. Called once on server startup (self-hosted). */
export const startDeploymentWorker = () => myQueue.run();
export const getJobsByApplicationId = async (applicationId: string) => { export const getJobsByApplicationId = async (applicationId: string) => {
const jobs = await myQueue.getJobs(); const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.applicationId === applicationId); return jobs.filter(
(job) => (job.data as any)?.applicationId === applicationId,
);
}; };
export const getJobsByComposeId = async (composeId: string) => { export const getJobsByComposeId = async (composeId: string) => {
const jobs = await myQueue.getJobs(); const jobs = await myQueue.getJobs();
return jobs.filter((job) => job?.data?.composeId === composeId); return jobs.filter((job) => (job.data as any)?.composeId === composeId);
}; };
if (!IS_CLOUD) { if (!IS_CLOUD) {
@@ -36,44 +93,33 @@ if (!IS_CLOUD) {
myQueue.close(); myQueue.close();
process.exit(0); process.exit(0);
}); });
myQueue.on("error", (error) => {
if ((error as any).code === "ECONNREFUSED") {
console.error(
"Make sure you have installed Redis and it is running.",
error,
);
}
});
} }
export const cleanQueuesByApplication = async (applicationId: string) => { export const cleanQueuesByApplication = async (applicationId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]); const removed = myQueue.removeWaiting(
(data) => (data as any)?.applicationId === applicationId,
);
if (removed > 0) {
console.log(
`Removed ${removed} waiting job(s) for application ${applicationId}`,
);
}
};
for (const job of jobs) { export const cleanQueuesByCompose = async (composeId: string) => {
if (job?.data?.applicationId === applicationId) { const removed = myQueue.removeWaiting(
await job.remove(); (data) => (data as any)?.composeId === composeId,
console.log(`Removed job ${job.id} for application ${applicationId}`); );
} if (removed > 0) {
console.log(`Removed ${removed} waiting job(s) for compose ${composeId}`);
} }
}; };
export const cleanAllDeploymentQueue = async () => { export const cleanAllDeploymentQueue = async () => {
deploymentWorker.cancelAllJobs("User requested cancellation"); myQueue.clearWaiting();
return true; return true;
}; };
export const cleanQueuesByCompose = async (composeId: string) => {
const jobs = await myQueue.getJobs(["waiting", "delayed"]);
for (const job of jobs) {
if (job?.data?.composeId === composeId) {
await job.remove();
console.log(`Removed job ${job.id} for compose ${composeId}`);
}
}
};
export const killDockerBuild = async ( export const killDockerBuild = async (
type: "application" | "compose", type: "application" | "compose",
serverId: string | null, serverId: string | null,

View File

@@ -71,8 +71,8 @@ void app.prepare().then(async () => {
if (!IS_CLOUD) { if (!IS_CLOUD) {
console.log("Starting Deployment Worker"); console.log("Starting Deployment Worker");
const { deploymentWorker } = await import("./queues/deployments-queue"); const { startDeploymentWorker } = await import("./queues/queueSetup");
await deploymentWorker.run(); await startDeploymentWorker();
} }
} catch (e) { } catch (e) {
console.error("Main Server Error", e); console.error("Main Server Error", e);

View File

@@ -41,6 +41,7 @@ export const server = pgTable("server", {
.notNull() .notNull()
.$defaultFn(() => generateAppName("server")), .$defaultFn(() => generateAppName("server")),
enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false), enableDockerCleanup: boolean("enableDockerCleanup").notNull().default(false),
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
createdAt: text("createdAt").notNull(), createdAt: text("createdAt").notNull(),
organizationId: text("organizationId") organizationId: text("organizationId")
.notNull() .notNull()
@@ -182,6 +183,11 @@ export const apiUpdateServer = createSchema
enableDockerCleanup: z.boolean().default(true), enableDockerCleanup: z.boolean().default(true),
}); });
export const apiUpdateServerBuildsConcurrency = z.object({
serverId: z.string().min(1),
buildsConcurrency: z.number().int().min(1).max(100),
});
export const apiUpdateServerMonitoring = createSchema export const apiUpdateServerMonitoring = createSchema
.pick({ .pick({
serverId: true, serverId: true,

View File

@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { boolean, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core"; import {
boolean,
integer,
jsonb,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod"; import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { z } from "zod"; import { z } from "zod";
@@ -98,6 +105,8 @@ export const webServerSettings = pgTable("webServerSettings", {
}), }),
// Deployment Configuration (self-hosted only) // Deployment Configuration (self-hosted only)
remoteServersOnly: boolean("remoteServersOnly").notNull().default(false), remoteServersOnly: boolean("remoteServersOnly").notNull().default(false),
// Concurrent builds on the local web server (enterprise-gated to > 1)
buildsConcurrency: integer("buildsConcurrency").notNull().default(1),
// Auth Configuration (self-hosted only) // Auth Configuration (self-hosted only)
enforceSSO: boolean("enforceSSO").notNull().default(false), enforceSSO: boolean("enforceSSO").notNull().default(false),
// Cache Cleanup Configuration // Cache Cleanup Configuration
@@ -161,6 +170,11 @@ export const apiUpdateWebServerSettings = createSchema.partial().extend({
cleanupCacheOnCompose: z.boolean().optional(), cleanupCacheOnCompose: z.boolean().optional(),
remoteServersOnly: z.boolean().optional(), remoteServersOnly: z.boolean().optional(),
enforceSSO: z.boolean().optional(), enforceSSO: z.boolean().optional(),
buildsConcurrency: z.number().int().min(1).max(100).optional(),
});
export const apiUpdateWebServerBuildsConcurrency = z.object({
buildsConcurrency: z.number().int().min(1).max(100),
}); });
export const apiAssignDomain = z export const apiAssignDomain = z

View File

@@ -44,10 +44,15 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const secretsHash = calculateSecretsHash(envVariables); const secretsHash = calculateSecretsHash(envVariables);
const cacheKey = cleanCache ? nanoid(10) : undefined; const cacheKey = cleanCache ? nanoid(10) : undefined;
// Build command // Build command.
// Use a unique builder name per build so concurrent deployments don't race
// on a shared "builder-containerd" instance (create/use/rm collisions).
const builderName = `railpack-${appName}-${nanoid(6)}`;
const buildArgs = [ const buildArgs = [
"buildx", "buildx",
"build", "build",
"--builder",
builderName,
...(cacheKey ...(cacheKey
? [ ? [
"--build-arg", "--build-arg",
@@ -84,17 +89,16 @@ export const getRailpackCommand = (application: ApplicationNested) => {
const bashCommand = ` const bashCommand = `
# Ensure we have a builder with containerd # Ensure we have a builder with containerd (isolated per build)
export RAILPACK_VERSION=${application.railpackVersion} export RAILPACK_VERSION=${application.railpackVersion}
bash -c "$(curl -fsSL https://railpack.com/install.sh)" bash -c "$(curl -fsSL https://railpack.com/install.sh)"
docker buildx create --use --name builder-containerd --driver docker-container || true docker buildx create --name ${builderName} --driver docker-container || true
docker buildx use builder-containerd
echo "Preparing Railpack build plan..." ; echo "Preparing Railpack build plan..." ;
railpack ${prepareArgs.join(" ")} || { railpack ${prepareArgs.join(" ")} || {
echo "❌ Railpack prepare failed" ; echo "❌ Railpack prepare failed" ;
docker buildx rm builder-containerd || true docker buildx rm ${builderName} || true
exit 1; exit 1;
} }
echo "✅ Railpack prepare completed." ; echo "✅ Railpack prepare completed." ;
@@ -102,13 +106,13 @@ echo "✅ Railpack prepare completed." ;
echo "Building with Railpack frontend..." ; echo "Building with Railpack frontend..." ;
# Export environment variables for secrets # Export environment variables for secrets
${exportEnvs.join("\n")} ${exportEnvs.join("\n")}
docker ${buildArgs.join(" ")} || { docker ${buildArgs.join(" ")} || {
echo "❌ Railpack build failed" ; echo "❌ Railpack build failed" ;
docker buildx rm builder-containerd || true docker buildx rm ${builderName} || true
exit 1; exit 1;
} }
echo "✅ Railpack build completed." ; echo "✅ Railpack build completed." ;
docker buildx rm builder-containerd docker buildx rm ${builderName} || true
`; `;
return bashCommand; return bashCommand;

View File

@@ -1,5 +1,5 @@
import { deployments } from "@dokploy/server/db/schema"; import { applications, compose, deployments } from "@dokploy/server/db/schema";
import { eq } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { db } from "../../db/index"; import { db } from "../../db/index";
export const initCancelDeployments = async () => { export const initCancelDeployments = async () => {
@@ -14,6 +14,36 @@ export const initCancelDeployments = async () => {
.where(eq(deployments.status, "running")) .where(eq(deployments.status, "running"))
.returning(); .returning();
// Reset the related services so they don't stay stuck in "running".
const applicationIds = [
...new Set(
result
.map((deployment) => deployment.applicationId)
.filter((id): id is string => !!id),
),
];
const composeIds = [
...new Set(
result
.map((deployment) => deployment.composeId)
.filter((id): id is string => !!id),
),
];
if (applicationIds.length > 0) {
await db
.update(applications)
.set({ applicationStatus: "idle" })
.where(inArray(applications.applicationId, applicationIds));
}
if (composeIds.length > 0) {
await db
.update(compose)
.set({ composeStatus: "idle" })
.where(inArray(compose.composeId, composeIds));
}
console.log(`Cancelled ${result.length} deployments`); console.log(`Cancelled ${result.length} deployments`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);