Compare commits

...

533 Commits

Author SHA1 Message Date
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 6a0acd9cad fix: update schedule scoping from user to organization
Changed the schedule scoping in the schedule utility to use organizationId instead of userId, ensuring that schedules are shared across all owners and admins within the same organization. This aligns with the recent changes to enhance organizational resource management.
2026-06-02 02:17:51 -06:00
Mauricio Siu 64a606ffa4 refactor: streamline imports and enhance permission test readability
Consolidated import statements across multiple provider components by removing duplicate imports of VALID_BRANCH_REGEX. Improved the readability of the permission test for denied access by simplifying the expect statement. Additionally, added copy-to-clipboard functionality in relevant components to enhance user experience.
2026-06-02 02:17:51 -06:00
Mauricio Siu 29851491f6 chore: update version to v0.29.7 in package.json and enhance permission tests
Bumped the version of dokploy to v0.29.7. Updated test descriptions for clarity, specifically renaming the test suite to reflect the roles of "owner" and "admin." Added new tests to ensure that members are denied access to various org-level enterprise resources, improving coverage and validation of permission checks.
2026-06-02 02:17:50 -06:00
Mauricio Siu 95633b4122 fix: refine permission check for privileged static roles in permission service
Updated the permission check logic to specifically identify "owner" and "admin" roles as privileged static roles, enhancing clarity and accuracy in permission validation. This change ensures that only users with these roles are granted access to enterprise-only resources.
2026-06-02 02:17:50 -06:00
Mauricio Siu c73632cbe0 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-06-02 02:17:49 -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
github-actions[bot] 30b3e1fe48 🚀 Release v0.29.6 (#4514)
* 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

* 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.

* [autofix.ci] apply automated fixes

* 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.

* 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

* 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.

* 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

* 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

* feat(settings): add copy button to server IP in web server settings (#4397)

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

* fix: responsive layout (#4391)

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>

* fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382)

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

* 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.

* fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>

* fix: enable comment toggle shortcut in env variable editor (#4402) (#4473)

* 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)

* chore: update version to v0.29.5 in package.json

* 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.

* 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

* fix: grant create and delete SSH key permissions when canAccessToSSHKeys is enabled for members (#4512)

* fix: use create permission for basic auth delete instead of delete (#4513)

* 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.

* chore: update version to v0.29.6 in package.json

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

---------

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com>
Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com>
Co-authored-by: Francis <9560564+Baker@users.noreply.github.com>
Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com>
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
Co-authored-by: Jasael <67719321+jasael@users.noreply.github.com>
Co-authored-by: Philippe Parage <69145356+pparage@users.noreply.github.com>
Co-authored-by: youcef zr <93142224+youcefzemmar@users.noreply.github.com>
2026-05-30 16:01:52 -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
github-actions[bot] a07106d649 🚀 Release v0.29.5 (#4475)
* 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

* 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.

* [autofix.ci] apply automated fixes

* 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.

* 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

* 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.

* 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

* 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

* feat(settings): add copy button to server IP in web server settings (#4397)

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

* fix: responsive layout (#4391)

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>

* fix: automatically converting username to lowercase both in creation of register, and build for extra. (#4382)

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

* 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.

* fix: add type="button" to TooltipTrigger in form components to prevent accidental submission (#4422)

Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>

* fix: enable comment toggle shortcut in env variable editor (#4402) (#4473)

* 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)

* chore: update version to v0.29.5 in package.json

---------

Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
Co-authored-by: ngenohkevin <ngenohkevin19@gmail.com>
Co-authored-by: Mauricio Siu <47042324+Siumauricio@users.noreply.github.com>
Co-authored-by: Mauricio Siu <siumauricio@icloud.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Volodymyr Kravchuk <volodymyr.kravch@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Nahidujjaman Hridoy <75487507+nhridoy@users.noreply.github.com>
Co-authored-by: Francis <9560564+Baker@users.noreply.github.com>
Co-authored-by: mixelburg <52622705+mixelburg@users.noreply.github.com>
Co-authored-by: Maks Pikov <mixelburg@users.noreply.github.com>
2026-05-22 17:21:12 -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
Mauricio Siu df98cea19f Merge pull request #4381 from Dokploy/canary
🚀 Release v0.29.4
2026-05-11 13:46:38 -06:00
Mauricio Siu b109e0ebc4 Merge pull request #4380 from Dokploy/4379-deployments-does-not-load-in-v0293
fix(wss): add colon to directory validation regex to fix deployment logs loading
2026-05-11 13:34:55 -06:00
Mauricio Siu 282d358d04 fix(validation): update regex for directory validation in WebSocket utility
- Modified the regex pattern in the `readValidDirectory` function to allow for a wider range of characters, including colons, improving the validation of directory names.
- This change enhances input integrity by ensuring valid directory formats are accepted.
2026-05-11 13:34:13 -06:00
Mauricio Siu 2f08b33931 feat(sync): add job to sync OpenAPI specification to SDK repository
- Implemented a new workflow step to clone the SDK repository and update the OpenAPI specification.
- Configured Git user details for the sync operation and added a commit message format that includes source and update timestamp.
- Ensured successful synchronization of OpenAPI documentation to the SDK repository.
2026-05-11 13:12:08 -06:00
Mauricio Siu ccc8f6d047 Merge pull request #4372 from Dokploy/canary
🚀 Release v0.29.3
2026-05-11 11:57:12 -06:00
Mauricio Siu 62aeed5aed fix(esbuild): update path for migrate-auth-secret script
- Changed the path of the `migrate-auth-secret` script from the root directory to the `scripts` folder for better organization and clarity in the project structure.
2026-05-11 11:34:21 -06:00
Mauricio Siu 5e021797f3 feat(validation): standardize branch name validation across provider schemas
- Added a regex validation for branch names in Bitbucket, Git, Gitea, GitHub, and GitLab provider schemas to ensure consistent and valid branch formats.
- Refactored the branch validation logic to improve readability and maintainability across the schemas.
- Enhanced input integrity by ensuring all provider schemas adhere to the same branch name validation rules.
2026-05-11 11:22:05 -06:00
Mauricio Siu 1c6fdc1b43 Merge pull request #4374 from Dokploy/fix/better-auth-secret-hardcoded
fix(security): replace hardcoded BETTER_AUTH_SECRET with Docker secret support
2026-05-09 02:10:41 -06:00
autofix-ci[bot] 6270bad9af [autofix.ci] apply automated fixes 2026-05-09 08:08:34 +00:00
Mauricio Siu 9c71458eff feat(auth): implement migration script for auth secret and refactor secret handling
- Added a new script `migrate-auth-secret.ts` to facilitate the migration of 2FA secrets when changing the BETTER_AUTH_SECRET.
- Updated `package.json` to include a command for running the migration script.
- Refactored the handling of BETTER_AUTH_SECRET to improve security by removing the hardcoded default and introducing a fallback mechanism using environment variables or Docker secrets.
- Updated the authentication logic to utilize the new `betterAuthSecret` function for retrieving the secret.
2026-05-09 02:08:04 -06:00
Mauricio Siu 547ba2d04b feat(validation): enhance registry URL validation in schema
- Introduced a new validation schema for registry URLs, ensuring they conform to a specific format (hostname[:port]) and disallow shell metacharacters.
- Updated the `createSchema`, `apiCreateRegistry`, and `apiTestRegistry` functions to utilize the new registry URL validation.
- Improved security and input integrity for registry URL fields.
- Updated the `removeRegistry` function to escape the registry URL during logout to prevent command injection vulnerabilities.
2026-05-09 01:09:50 -06:00
Mauricio Siu b9e97eb321 feat(validation): enhance destination path validation in file upload schema
- Updated the `destinationPath` field in the upload file schema to include a regex validation, ensuring only alphanumeric characters, dots, dashes, underscores, and forward slashes are allowed.
- Added a corresponding regex check in the `uploadFileToContainer` function to validate the destination path before processing, improving input integrity and preventing errors.
2026-05-09 00:57:12 -06:00
Mauricio Siu a4e2317f3e feat(deployment): enhance log retrieval by encoding log path in base64
- Updated the WebSocket server to encode the log path in base64 before executing the tail command on the remote server.
- Added validation to ensure the directory name adheres to a specified regex pattern, improving input integrity for directory paths.
2026-05-09 00:01:45 -06:00
Mauricio Siu 06a349152f fix(traefik): update remote config writing to use base64 encoding
- Modified the `writeConfigRemote` function to encode the Traefik configuration in base64 before saving it to the remote YAML file.
- This change ensures that the configuration is correctly handled and prevents potential issues with special characters in the config.
2026-05-08 23:54:40 -06:00
Mauricio Siu fef2de1ec5 feat(validation): add branch name validation across provider schemas
- Introduced a regex validation for branch names in Bitbucket, Git, Gitea, GitHub, and GitLab provider schemas to ensure valid branch formats.
- Updated the corresponding schemas to include the new validation rule, enhancing input integrity and preventing potential errors.
- Added a utility for branch validation in the server utils.
2026-05-08 23:50:38 -06:00
Mauricio Siu b20ff64cbf chore(package): bump version to v0.29.3 2026-05-08 23:27:47 -06:00
Mauricio Siu 5177580d51 Merge pull request #4371 from Dokploy/feat/schedule-description
feat(schedules): add optional description field
2026-05-08 23:19:12 -06:00
Mauricio Siu d3292a2810 feat(schedules): add optional description field to schedule form and display
- Updated the schedule form schema to include an optional 'description' field.
- Enhanced the form to allow users to input a description for each schedule.
- Modified the schedule display component to show the description if available.
- Added a database migration to include the 'description' column in the schedule table.
2026-05-08 23:15:04 -06:00
Mauricio Siu 0f526af2c8 Merge pull request #4370 from Dokploy/fix/template-isolated-deployment
feat(templates): support isolated = false opt-out in template.toml
2026-05-08 19:34:07 -06:00
autofix-ci[bot] 72f5d711c8 [autofix.ci] apply automated fixes 2026-05-09 01:32:34 +00:00
Mauricio Siu ffd51cf32f feat(templates): add isolated deployment configuration to CompleteTemplate
Introduced an optional 'isolated' boolean property in the CompleteTemplate interface to manage isolated deployment settings. Added tests to verify default behavior (isolated=true) and explicit settings (isolated=true/false) in the deployment configuration.

This change enhances template flexibility for deployment configurations.
2026-05-08 19:32:05 -06:00
Mauricio Siu e8b3d7ba7d test(templates): add unit tests for isolated deployment config field 2026-05-08 19:26:34 -06:00
Mauricio Siu c182755591 feat(templates): support isolated = false opt-out in template.toml
Templates using network_mode: host (e.g. cloudflared) can now declare
isolated = false in their [config] section to prevent Dokploy from
injecting networks into the compose, which would cause a Docker error.

Default behavior (isolated = true) is unchanged for all existing templates.

Fixes #4366
2026-05-08 19:22:00 -06:00
Mauricio Siu 8227a48ef4 Merge pull request #4368 from Dokploy/fix/replace-traefik-me-with-sslip-io
fix: replace traefik.me with sslip.io for auto-generated domains
2026-05-08 19:05:33 -06:00
Mauricio Siu f5ddc36f24 fix: replace traefik.me with sslip.io for auto-generated domains
Fixes #4365 — traefik.me had availability issues. sslip.io uses the same
IP-in-subdomain format, supports both IPv4 and IPv6, and is more reliable.
2026-05-08 19:04:24 -06:00
Mauricio Siu d5d8914bf6 Merge pull request #4358 from nhridoy/fix/layout
fix: UI Responsiveness for both mobile, Tab and desktop Screens
2026-05-08 18:49:32 -06:00
autofix-ci[bot] bf0890a6b0 [autofix.ci] apply automated fixes 2026-05-09 00:48:16 +00:00
Mauricio Siu 4e07669464 Merge branch 'canary' into fix/layout 2026-05-08 18:47:39 -06:00
Mauricio Siu 4a3fa6e63f fix: reorder imports and clean up unused ones across various components 2026-05-08 18:45:44 -06:00
autofix-ci[bot] 14af5d293a [autofix.ci] apply automated fixes 2026-05-07 20:41:26 +00:00
Mauricio Siu 746bb3ddc6 Merge pull request #4338 from BradPerbs/fix/remove-debug-console-logs
fix: remove leftover debug console.log statements
2026-05-07 14:35:28 -06:00
Mauricio Siu b13308dc69 Merge pull request #4294 from berkay-digital/feat/copy-ai-log-analysis
feat: add copy button to AI log analysis result
2026-05-07 13:46:07 -06:00
Mauricio Siu 16746a1609 Merge pull request #4345 from amit-y11/fix/project-service-card-alignment
fix: align card footers to bottom on project and service cards
2026-05-07 13:35:26 -06:00
Nahidujjaman Hridoy bca62d43d2 fix: ui responsiveness for mobile, tab and desktop screens
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 17:49:49 +06:00
Nahidujjaman Hridoy d502f4a206 fix: ui responsiveness for mobile, tab and desktop screens
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 17:49:16 +06:00
Nahidujjaman Hridoy de7d6f8147 fix: responsiveness in components/dashboard/settings/web-domain.tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 13:38:10 +06:00
Nahidujjaman Hridoy 9d6bc4cd18 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 12:26:27 +06:00
Nahidujjaman Hridoy 65b27af0f5 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 12:26:17 +06:00
Nahidujjaman Hridoy 6165114bc3 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-07 12:24:13 +06:00
Nahidujjaman Hridoy d3109359fb fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-06 22:57:17 +06:00
Nahidujjaman Hridoy 58f527d029 fix: broken layout in project/[projectId]/environment/[environmentId].tsx
Signed-off-by: Nahidujjaman Hridoy <hridoyboss12@gmail.com>
2026-05-06 22:23:44 +06:00
Amit 1ed41fe2f8 fix: align card footers to bottom on project and service cards
Cards with descriptions appeared taller than those without, causing
the 'Created at' and service count text to be misaligned across cards
in the same row.

- Add flex-col and mt-auto to project cards so footer sticks to bottom
- Add h-full to service card Link and Card so they fill the grid cell
2026-05-02 22:40:38 +05:30
Mauricio Siu 9b416b3699 Merge pull request #4341 from Dokploy/fix/sidebar-mobile-close-on-navigation
fix(sidebar): close mobile sidebar on navigation
2026-05-01 13:15:45 -06:00
Mauricio Siu 096b8b33fc fix(sidebar): close mobile sidebar on navigation
Closes #4340
2026-05-01 13:15:20 -06:00
BPx33 741792883a fix: remove leftover debug console.log statements
Three stray debug console.log calls had been left in production code paths:

- restore-backup.tsx: logged the full form payload on every backup restore
  submission.
- add-database.tsx: logged the libsql `enableNamespaces` field value from
  inside a `render` callback, firing on every re-render of the parent form.
- project.ts (duplicateProject mutation): logged the freshly-created
  target project/environment row on the server on every duplicate.

No behaviour change; strictly removes log noise.
2026-05-01 16:24:28 +08:00
Mauricio Siu e0c6ed699d Merge pull request #4336 from Dokploy/fix/template-fetch-timeout-error-handling
fix(templates): add fetch timeout and handle network errors gracefully
2026-04-30 18:52:45 -06:00
Mauricio Siu 5f5ed0f2c2 fix(templates): add fetch timeout and handle network errors gracefully
Add 10s AbortSignal timeout to all template fetch calls so they fail
cleanly instead of hanging indefinitely when templates.dokploy.com is
unreachable. Add try/catch to getTags endpoint which was missing error
handling, causing a 500 instead of returning an empty list.

Closes #4282
2026-04-30 18:52:16 -06:00
Mauricio Siu b9ff576682 Merge pull request #4335 from Dokploy/fix/auth-redirect-permanent-cache
fix: use temporary redirects for auth checks in getServerSideProps
2026-04-30 18:47:17 -06:00
Mauricio Siu c854a38adb fix: use temporary redirects for auth checks in getServerSideProps
Replace permanent (301) redirects with temporary (302) redirects across
all pages that check authentication state in getServerSideProps.

Permanent redirects are cached by the browser indefinitely, causing a
bug where users had to manually refresh after login: the browser would
cache the unauthenticated redirect (dashboard → login page) and replay
it even after a successful login, preventing navigation to the dashboard.

Fixes #4220
2026-04-30 18:46:12 -06:00
Mauricio Siu 5fb365c08b chore(package.json): add peerDependencyRules to ignore missing dependencies 2026-04-30 18:31:07 -06:00
Mauricio Siu 15296d5c85 fix(compose-file-editor): simplify form reset logic in ComposeFileEditor component 2026-04-30 09:50:27 -06:00
Mauricio Siu 0e5fc584b2 Merge pull request #4278 from mixelburg/fix/webhook-401-missing-signature
fix(webhook): return 401 when signature header is missing
2026-04-28 18:30:48 -06:00
Mauricio Siu cc7ea5108b Merge pull request #4325 from Dokploy/fix/3909-healthcheck-interval
fix: reduce healthcheck frequency to lower memory pressure
2026-04-28 18:27:20 -06:00
Mauricio Siu 8f3d824ea6 fix: reduce healthcheck frequency to lower memory pressure
Closes #3909

The previous 10s interval triggered a DB query (SELECT 1) 360 times/hour,
keeping Node.js heap under constant pressure and preventing the GC from
reclaiming memory efficiently. Increasing to 30s reduces query load by 3x.
Also adds start-period=60s to avoid false failures during startup.
2026-04-28 18:26:52 -06:00
Mauricio Siu 0bdcbf5827 Merge pull request #4323 from Dokploy/fix/forgot-password-email-max-length
fix: enforce 255-char max length on forgot password email field
2026-04-28 18:09:35 -06:00
autofix-ci[bot] 34564aec84 [autofix.ci] apply automated fixes 2026-04-29 00:09:17 +00:00
Mauricio Siu ed006dc5f9 fix: enforce email length validation in reset password form
- Added a maximum length constraint of 255 characters for the email input field.
- Updated validation schema to include a message for exceeding the maximum length.
2026-04-28 18:08:37 -06:00
Mauricio Siu 222b167a76 Merge pull request #4299 from Dokploy/canary
🚀 Release v0.29.2
2026-04-24 22:46:42 -06:00
Mauricio Siu fb6b06f064 chore: add push trigger for version sync on tag creation 2026-04-24 22:46:18 -06:00
Mauricio Siu 09824facf8 refactor: improve Badge component formatting in requests table 2026-04-24 22:34:48 -06:00
Mauricio Siu bd46eaec5c Merge pull request #4303 from Dokploy/fix/requests-status-fallback-downstream
fix: fallback to DownstreamStatus when OriginStatus is 0 in requests table
2026-04-24 22:33:52 -06:00
Mauricio Siu e9fdc19b96 fix: fallback to DownstreamStatus when OriginStatus is 0 in requests table
Closes #4250
2026-04-24 22:33:24 -06:00
Mauricio Siu 3e81cdac4d Merge pull request #4255 from manalkaff/fix/requests-filter-by-hostname
fix: filter requests by hostname instead of path
2026-04-24 22:01:35 -06:00
Mauricio Siu e72c51444c Merge pull request #4281 from sajdakabir/fix/4276-sanitize-webhook-error-responses
fix: stop leaking Drizzle SQL queries in webhook error responses (#4276)
2026-04-24 21:59:50 -06:00
Mauricio Siu 940d18ad25 Merge pull request #4302 from Dokploy/fix/send-email-cloud-version
feat: implement invitation email functionality for organization creation
2026-04-24 21:51:53 -06:00
autofix-ci[bot] c41b69c925 [autofix.ci] apply automated fixes 2026-04-25 03:40:50 +00:00
Mauricio Siu b610f7aeff feat: implement invitation email functionality for organization creation
- Added `sendInvitationEmail` function to send invitation emails when a new organization is created in the cloud environment.
- Updated email template to enhance the invitation message and included a direct link for users to accept the invitation.
- Refactored email sending logic in the user router to utilize the new invitation email rendering function.
- Improved organization invitation email design for better user experience.
2026-04-24 21:40:08 -06:00
Mauricio Siu cdd77a04dc Merge pull request #4129 from NomisCZ/fix/ssh2-isdate-nodejs23
fix: drop .zip deployment - isDate is not a function
2026-04-24 12:58:03 -06:00
Mauricio Siu 05f22edfe5 chore: bump version to v0.29.2 in package.json 2026-04-24 12:53:03 -06:00
Mauricio Siu 29480cde90 Merge pull request #4298 from Dokploy/fix/GHSA-f8wj-5c4w-frhg-cross-org-idor
Fix/ghsa f8wj 5c4w frhg cross org idor
2026-04-24 12:49:24 -06:00
Mauricio Siu 232ccc9139 feat: add organization-level authorization checks to WebSocket servers
- Implemented checks in the WebSocket server setups for Docker container logs, terminal, and deployment logs to ensure users can only access resources associated with their active organization.
- Enhanced security by closing WebSocket connections if the organization ID does not match the session's active organization ID.
2026-04-24 12:47:51 -06:00
Mauricio Siu 018e2b153e fix: add cross-org ownership checks to cluster, deployment, backup, and WebSocket endpoints
Prevents owner/admin users of one organization from accessing servers,
destinations, and Docker Swarm join tokens belonging to other organizations
by validating organizationId on all endpoints that accept serverId or
destinationId as direct input.

- cluster: validate serverId org on getNodes, addWorker, addManager, removeWorker
- deployment: validate serverId org on allByServer
- backup: validate destinationId + serverId org on listBackupFiles
- volume-backups: validate destinationId + serverId org on restoreVolumeBackupWithLogs
- wss: validate server org on docker-container-logs, docker-container-terminal,
  listen-deployment, and terminal WebSocket handlers
- auth: fix TypeScript type for API key metadata parsing
2026-04-24 12:44:42 -06:00
berkay-digital ad490dca3f feat: add copy button to AI log analysis result
Allows users to quickly copy the AI-generated log analysis to their
clipboard from the analyze-logs popover, matching the copy UX used in
the deployment and docker logs views.

Made-with: Cursor
2026-04-23 15:19:13 +02:00
sajdakabir f8c6c8f7cc fix: stop leaking Drizzle SQL queries in webhook error responses (#4276) 2026-04-22 13:06:22 +05:30
Mauricio Siu d7af82731c Merge pull request #4279 from Dokploy/fix/GHSA-7wmr-57mg-h5q6-schedule-authz
fix(schedule): add authz checks for server and host-level schedules
2026-04-21 21:38:26 -06:00
Mauricio Siu c3fa638a56 feat: enhance schedule management with permission checks and cloud restrictions
- Added comprehensive permission checks for creating, updating, and deleting schedules based on user roles (owner/admin) and schedule types (server/dokploy-server).
- Implemented restrictions for cloud users to prevent managing host-level schedules and changing schedule types.
- Improved access control for server-level schedules to ensure users can only manage schedules associated with their organization.
2026-04-21 21:36:44 -06:00
autofix-ci[bot] ce703ef478 [autofix.ci] apply automated fixes 2026-04-22 00:05:08 +00:00
OpenClaw Bot fc6df3ae05 fix(webhook): cast signature to string to fix TS2345 2026-04-22 00:04:44 +00:00
autofix-ci[bot] 8fb517152a [autofix.ci] apply automated fixes 2026-04-21 22:04:36 +00:00
Maks Pikov ba3591b3ac fix(webhook): return 401 when signature header is missing 2026-04-21 22:03:55 +00:00
Mauricio Siu bad9731878 Merge pull request #4261 from Dokploy/canary
🚀 Release v0.29.1
2026-04-20 07:16:11 -06:00
Mauricio Siu 98a586478e chore: bump version to v0.29.1 in package.json 2026-04-19 12:07:02 -06:00
Mauricio Siu 13248c8d8a Merge pull request #4257 from colocated/fix/4256-preview-deployment-too-many-args
fix: preview deployments broken on v0.29.0 — postgres 100-arg limit
2026-04-19 12:06:17 -06:00
Jack 54417ca8e7 fix: limit application columns in findPreviewDeploymentById to avoid postgres 100-arg limit
Closes #4256
2026-04-19 11:14:47 +01:00
manalkaff 598fae0e92 fix: filter requests by hostname instead of path
The search filter on the Requests tab was incorrectly filtering by
RequestPath instead of RequestHost, causing "filter by name" to match
URL paths rather than hostnames. Updated the placeholder text to
reflect the correct field being searched.

Fixes #4249
2026-04-19 17:30:42 +08:00
Mauricio Siu b392e58001 Merge pull request #4244 from Dokploy/feat/dashboard-home
feat: add dashboard home page
2026-04-17 22:40:50 -06:00
Mauricio Siu d9945c0a4f style: update ShowHome component layout for improved responsiveness
- Adjusted the Card component to have a minimum height of 85vh for better visual consistency.
- Ensured the inner div has a full height to enhance the layout structure.
2026-04-17 22:24:08 -06:00
autofix-ci[bot] f6e2c033ba [autofix.ci] apply automated fixes 2026-04-18 04:18:44 +00:00
Mauricio Siu 5c787adae1 feat: implement homeStats query for dashboard overview
- Replace individual project and server queries with a consolidated homeStats query to streamline data retrieval for the dashboard.
- Update the ShowHome component to utilize homeStats for displaying project, environment, application, and service counts, along with their status breakdown.
- Enhance data handling for user permissions to ensure accurate statistics based on user access levels.
2026-04-17 22:18:14 -06:00
Mauricio Siu 2ba1df1eaa feat: refine home page and fix libsql in bulk actions
- Home: 4 KPI cards (projects, services, deploys/7d, status list),
  server column with icon in recent deployments, empty state with
  icon, dashboard card frame to match other pages.
- Include libsql in project services count sort.
- Fix bulk actions in environment page: libsql was missing from
  start, stop, move, delete and deploy handlers.
2026-04-17 22:11:04 -06:00
autofix-ci[bot] e7859395b1 [autofix.ci] apply automated fixes 2026-04-18 03:37:12 +00:00
Mauricio Siu 6f0ed89ce7 feat: add dashboard home page with overview and recent deployments
Adds a new /dashboard/home landing with welcome header, KPI cards
(deploys/24h, build, CPU, memory) and a recent deployments list.

Home is now the post-login landing and the destination for permission
fallback redirects across the app. Projects remains accessible from
the sidebar.
2026-04-17 21:36:37 -06:00
Mauricio Siu 4277a509b2 Merge pull request #4241 from sancho1952007/patch-1
style: Fix typo in custom entrypoint description
2026-04-17 21:06:48 -06:00
Sancho Godinho f7b576cbf3 Fix typo in custom entrypoint description 2026-04-18 04:23:15 +05:30
Mauricio Siu 425fef6e28 fix: remove 'v' prefix from version in synchronization workflow
Update the version retrieval command in the GitHub Actions workflow to strip the 'v' prefix from the version number in package.json. This change ensures that the version format is consistent for downstream processes.
2026-04-17 14:49:14 -06:00
Mauricio Siu 958372c5f9 chore: update paths in version synchronization workflow for MCP and CLI repositories
Modify the GitHub Actions workflow to clone the MCP and CLI repositories into temporary directories instead of the current directory. This change improves the organization of the workflow and ensures that the latest OpenAPI specification is correctly referenced during the synchronization process.
2026-04-17 14:46:20 -06:00
Mauricio Siu e7c581476e feat: add workflow dispatch trigger to version synchronization workflow
Enhance the GitHub Actions workflow by adding a workflow_dispatch trigger, allowing manual execution of the version synchronization process. This provides greater flexibility in managing version updates for MCP and CLI repositories.
2026-04-17 14:44:04 -06:00
Mauricio Siu 0cae8330e2 chore: adjust version bump timing in synchronization workflow
Update the GitHub Actions workflow to bump the version in package.json after installing dependencies, ensuring that the version is not overwritten by pnpm install. This change enhances the reliability of version synchronization for both MCP and CLI repositories.
2026-04-17 14:42:14 -06:00
Mauricio Siu 7e13243c1d Merge pull request #4155 from Dokploy/canary
🚀 Release v0.29.0
2026-04-17 14:10:37 -06:00
Mauricio Siu 4a271c11e7 Merge pull request #4239 from Dokploy/feat/resend-verification-email-on-signin
feat: resend verification email on sign-in and improve template
2026-04-17 14:02:01 -06:00
Mauricio Siu fda367b2c5 fix: update logger configuration to disable in production environment
Change the logger's disabled property to be dependent on the NODE_ENV variable, ensuring logging is disabled in production for improved performance and security.
2026-04-17 14:01:46 -06:00
Mauricio Siu ea1238b1d1 feat: resend verification email on sign-in and improve email template
- Add `sendOnSignIn: true` to emailVerification config so unverified users
  receive a new verification email when they attempt to sign in
- Create styled verification email template matching the invoice email design
- Extract `sendVerificationEmail` helper to keep auth.ts clean
- Show friendly message on login when email is not verified
2026-04-17 13:59:50 -06:00
Mauricio Siu b060f80932 feat: add no tags message to tag selector component
Enhance the TagSelector component to display a message when no tags are created, prompting users to add tags. This improves user experience by providing clear feedback in the UI.
2026-04-16 12:21:17 -06:00
Mauricio Siu 04b9f56333 chore: enhance version synchronization workflow for MCP and CLI repositories
Update the GitHub Actions workflow to include regeneration of tools from the latest OpenAPI specification and ensure the latest openapi.json is copied to the CLI repository. This improves the consistency and accuracy of the versioning and API documentation across both repositories.
2026-04-15 20:55:37 -06:00
Mauricio Siu 599b97da51 feat: add version synchronization workflow for MCP and CLI repositories
Implement a GitHub Actions workflow to automatically sync the version from the Dokploy repository to the MCP and CLI repositories upon release. This includes cloning the repositories, updating the package.json version, and committing the changes with relevant metadata, ensuring consistent versioning across platforms.
2026-04-15 18:50:54 -06:00
Mauricio Siu 415298fddb feat: add OpenAPI sync to MCP and CLI repositories
Implement workflows to sync the OpenAPI specification to both the MCP and CLI repositories. This includes cloning the repositories, updating the openapi.json file, and committing the changes with relevant metadata. The process ensures that the OpenAPI documentation is consistently updated across multiple platforms.
2026-04-15 18:32:20 -06:00
Mauricio Siu ddff8b9de7 feat: add container networks view to dashboard
Integrate a new component, ShowContainerNetworks, to display network details for each container in the dashboard. This includes a dialog that shows network information such as IP address, gateway, and MAC address, enhancing the container management capabilities.
2026-04-13 22:04:46 -06:00
Mauricio Siu 90f97912a4 Merge pull request #4221 from Dokploy/feat/container-view-mounts
feat: add view mounts, config, and terminal to container actions
2026-04-13 21:58:20 -06:00
Mauricio Siu 9af745ce67 feat: add view mounts, view config, and terminal to container actions
Add a new "View Mounts" action to the container dropdown that displays
volume and bind mounts in a formatted table (type, source, destination,
mode, read/write). Also add "View Config" and "Terminal" actions to the
compose containers tab which previously only had logs and lifecycle actions.
2026-04-13 21:56:53 -06:00
Mauricio Siu d99f2cd460 Merge pull request #4216 from nizepart/fix/server-ip-override-on-user-creation
fix: prevent serverIp from being overwritten on every user registration
2026-04-13 20:59:26 -06:00
Mauricio Siu d234558822 Merge pull request #4219 from Dokploy/feat/service-cards-context-menu
feat: add context menu to service cards
2026-04-13 20:52:22 -06:00
Mauricio Siu 7f25ddca44 fix: add loading feedback and invalidation to context menu actions
Use toast.promise for loading/success/error states and invalidate
environment query after actions complete to update service status.
2026-04-13 20:51:22 -06:00
Mauricio Siu 638b3dd546 feat: add context menu to service cards
Right-click on service cards to quickly Start, Deploy, Stop, or Delete
a service without navigating into it. Uses shadcn/ui ContextMenu
component built on @radix-ui/react-context-menu. Delete action shows
a confirmation dialog. LibSQL services are excluded since they lack
standard mutation endpoints.
2026-04-13 20:48:17 -06:00
Mauricio Siu 1a8fd8396d Merge pull request #4218 from Dokploy/feat/compose-containers-tab
feat: add containers tab to compose services
2026-04-13 20:36:19 -06:00
Mauricio Siu 385850f354 fix: update audit action for container termination
Change the audit action from "kill" to "stop" for the containerKill function to better reflect the operation being performed. This aligns the logging with the intended action and improves clarity in audit records.
2026-04-13 20:36:04 -06:00
Mauricio Siu a48306a2c6 fix: address PR review feedback
- Use "kill" audit action for killContainer instead of "stop"
- Pass undefined instead of empty string for optional serverId
2026-04-13 20:34:06 -06:00
Mauricio Siu 89737e7b65 refactor: remove duplicate import of ShowComposeContainers component
Eliminate redundant import statement for ShowComposeContainers in the compose service page, streamlining the code and improving readability.
2026-04-13 20:32:11 -06:00
Mauricio Siu 00c708483e fix: use service.read permission for compose container actions
Change restartContainer, startContainer, stopContainer, and killContainer
endpoints to use service.read instead of docker.read so members with
access to the compose can use container lifecycle actions.
2026-04-13 20:31:58 -06:00
autofix-ci[bot] ddf570a807 [autofix.ci] apply automated fixes 2026-04-14 02:15:37 +00:00
Mauricio Siu f8eb2ba4ba feat: add containers tab to compose services
Add a Containers tab to the compose service page that lists all
containers with their state, status, and container ID. Each container
has a dropdown menu with lifecycle actions: View Logs, Restart, Start,
Stop, and Kill.

- Add containerStart, containerStop, containerKill functions to docker service
- Add corresponding tRPC procedures with server ownership checks and audit logging
- Update containerRestart to support remote servers via serverId
- Create ShowComposeContainers component with table view and action menu
- Add Containers tab between Deployments and Backups, gated by docker.read permission
2026-04-13 20:11:21 -06:00
Трапезин Андрей Александрович 9f07f8e9e1 fix: prevent serverIp from being overwritten on every user registration 2026-04-13 19:57:31 +03:00
Mauricio Siu 3cefa43a21 Merge pull request #4031 from difagume/style/deployments-remove-max-w-8xl
style(dashboard): remove max-width constraint from deployments card
2026-04-12 14:00:46 -06:00
autofix-ci[bot] 0941ec9f3e [autofix.ci] apply automated fixes 2026-04-12 20:00:08 +00:00
Mauricio Siu 879218a8b1 Merge branch 'canary' into style/deployments-remove-max-w-8xl 2026-04-12 13:59:24 -06:00
Mauricio Siu d6124aae81 refactor: clean up code formatting and improve error handling in job scheduling
- Simplified code formatting for better readability in various components.
- Updated job scheduling functions to handle errors gracefully, ensuring that failures in scheduling do not disrupt the overall process.
- Enhanced logging for better traceability of job scheduling issues.

These changes improve code maintainability and user experience by providing clearer error messages and more organized code structure.
2026-04-11 10:04:29 -06:00
Mauricio Siu f404b231a6 Merge pull request #4198 from Dokploy/feat/billing-cloud-improvements
Feat/billing cloud improvements
2026-04-11 00:50:50 -06:00
Mauricio Siu 7a986e5fb3 feat: enhance Stripe integration with customer updates and billing requirements
- Added customer update fields for automatic name and address handling during subscription creation.
- Enabled billing address collection and tax ID collection for improved compliance and billing accuracy.

These changes enhance the Stripe payment process by ensuring necessary customer information is captured and managed effectively.
2026-04-11 00:25:07 -06:00
Mauricio Siu 9687ed0d83 feat: add invoice notification settings and email notifications for payments
- Introduced a new feature allowing users to enable or disable invoice email notifications in the billing settings.
- Implemented email notifications for successful invoice payments and payment failures, enhancing user communication regarding billing.
- Updated the database schema to include a new column for storing user preferences on invoice notifications.
- Added corresponding email templates for invoice notifications and payment failure alerts.

These changes improve user experience by keeping users informed about their billing status and actions required.
2026-04-11 00:18:23 -06:00
Mauricio Siu b4c57b6326 Merge pull request #4190 from Dokploy/fix/traefik-strip-path-middleware-order
fix: correct stripPath and addPrefix middleware order
2026-04-09 17:40:40 -06:00
Mauricio Siu f8eb3c2b76 fix: swap stripPrefix and addPrefix middleware order in Traefik domain config
When both stripPath and internalPath are configured, addPrefix was pushed
before stripPrefix causing incorrect path rewriting (e.g. /app/v2/public/api
instead of /app/v2/api). Traefik executes middlewares in array order, so
stripPrefix must come first.

Closes #4061
2026-04-09 17:35:42 -06:00
Mauricio Siu a30617d85d Merge pull request #4189 from Dokploy/fix/monitoring-cpu-value-type-guard
fix: add runtime type guard for cpu.value in monitoring tab
2026-04-09 17:25:44 -06:00
Mauricio Siu b079cbd427 fix: add runtime type guard for cpu.value in monitoring tab
Closes #4062
2026-04-09 17:25:04 -06:00
Mauricio Siu aeda19db8a Merge pull request #4188 from Dokploy/fix/compose-project-name-orphan-containers
fix: inject COMPOSE_PROJECT_NAME to prevent orphaned containers on redeploy
2026-04-09 17:09:52 -06:00
Mauricio Siu cb64482649 fix: inject COMPOSE_PROJECT_NAME to prevent orphaned containers on redeploy
When users set a custom docker compose command without the -p flag,
Docker Compose defaults to using the directory name (code) as the
project name. If the custom command is later removed, Dokploy uses
-p appName, creating a new stack while the old one remains running.

Injecting COMPOSE_PROJECT_NAME=appName into the .env ensures the
project name is always consistent regardless of the command used.

Closes #4019
2026-04-09 17:06:09 -06:00
Mauricio Siu f4cae5f775 Merge pull request #4185 from Dokploy/fix/compose-delete-orphaned-containers
fix: prevent orphaned containers when deleting compose services
2026-04-09 16:26:31 -06:00
Mauricio Siu 825e6b654c fix: prevent orphaned containers when deleting compose services
Commands were chained with && so if the project directory was missing,
cd would fail and docker compose down would never execute — leaving
containers and volumes running. Use semicolons to run each command
independently, matching the existing stack deletion pattern.

Closes #4064
2026-04-09 16:25:36 -06:00
Mauricio Siu c1b19376a9 Merge pull request #4183 from Dokploy/feat/ai-improvements
feat: add AI log analysis component and integrate into deployment views
2026-04-09 11:45:07 -06:00
Mauricio Siu 6c3578a475 feat: enhance AnalyzeLogs component with AI provider configuration prompt
- Updated the AnalyzeLogs component to display a message and button for configuring AI providers when none are available, improving user guidance.
- Added a link to the settings page for easy access to AI provider configuration.
- Integrated new icon for the configuration button to enhance UI clarity.

These changes improve the user experience by ensuring users are informed about the need to set up AI providers for log analysis.
2026-04-09 11:44:55 -06:00
Mauricio Siu b8db120432 refactor: enhance getContainerLogs function to support app name or ID
- Updated the `getContainerLogs` function to accept either an application name or container ID, improving flexibility in log retrieval.
- Simplified the command execution logic by consolidating the remote and local execution paths.
- Added a new parameter to directly use container IDs, streamlining the process for users.

These changes enhance the usability of the logging feature, allowing for more efficient access to container logs.
2026-04-09 11:41:01 -06:00
Mauricio Siu 7c10610a5a feat: add readLogs procedure to multiple routers for container log retrieval
- Implemented a new `readLogs` procedure across various routers (application, compose, libsql, mariadb, mongo, mysql, postgres, redis) to enable users to retrieve logs from containers.
- Each procedure includes input validation for parameters such as `tail`, `since`, and `search`, ensuring robust access control and authorization checks.
- Enhanced the `getContainerLogs` service to support fetching logs from both Docker containers and services, improving the logging capabilities of the application.

This feature enhances observability and troubleshooting for users by providing direct access to container logs.
2026-04-09 11:40:02 -06:00
Mauricio Siu 8d8658a478 fix: update Z.AI API URL and enhance AI router access control
- Corrected the API URL for Z.AI by removing the trailing slash.
- Modified the AI router mutation to include context and added access control to ensure users can only access their organization's AI settings.

These changes improve the accuracy of the API integration and enhance security by enforcing organizational access restrictions.
2026-04-09 11:27:19 -06:00
autofix-ci[bot] fbde5be02c [autofix.ci] apply automated fixes 2026-04-09 17:20:44 +00:00
Mauricio Siu 090c0226ed feat: add AI log analysis component and integrate into deployment views
- Introduced the AnalyzeLogs component for analyzing logs using AI, allowing users to select AI providers and view analysis results.
- Integrated AnalyzeLogs into the ShowDeployment and DockerLogsId components, enabling log analysis for both build and runtime contexts.
- Updated the AI router to include a new endpoint for log analysis, which processes logs and returns structured insights.
- Enhanced the AI provider selection logic to support new providers, including Z.AI and MiniMax.

This feature enhances the user experience by providing actionable insights from logs, improving troubleshooting and operational efficiency.
2026-04-09 09:27:31 -06:00
Mauricio Siu 4a1b42899b Merge pull request #4168 from Dokploy/fix/ssh-key-member-access
fix: allow members to use SSH keys for deployments without full access
2026-04-05 18:17:10 -06:00
Mauricio Siu 343514d4eb fix: allow members to use SSH keys for deployments without full SSH key access
Add allForApps endpoint that returns only sshKeyId and name using protectedProcedure instead of withPermission, so members can select SSH keys in the git provider dropdown without needing access to the SSH Keys management panel.

closes #4069
2026-04-05 18:12:13 -06:00
Mauricio Siu 36067618f4 Merge pull request #4167 from Dokploy/fix/server-listen-before-init
fix: start server listener before initialization to prevent healthcheck failures
2026-04-05 17:37:13 -06:00
Mauricio Siu cc74f9e38c fix: start server listener before initialization to prevent healthcheck failures
Move server.listen() before the initialization block so the HTTP server
is already responding when Docker healthchecks begin. Previously, slow
operations like SMTP timeouts in sendDokployRestartNotifications() could
block the server from listening, causing healthcheck failures and
container restarts.

Closes #4049
2026-04-05 17:36:18 -06:00
Mauricio Siu df7e1da776 Merge pull request #4112 from manalkaff/fix/mongodb-connection-url-missing-auth-params
fix: add authSource and directConnection params to MongoDB connection URLs
2026-04-05 17:21:53 -06:00
Mauricio Siu df9aa50ece Merge pull request #4166 from Dokploy/feat/docker-cleanup-tooltip
feat: add tooltip to Daily Docker Cleanup toggle
2026-04-05 17:20:09 -06:00
autofix-ci[bot] ebbc008dbe [autofix.ci] apply automated fixes 2026-04-05 23:17:33 +00:00
Mauricio Siu 645a81b2ce feat: add tooltip to Daily Docker Cleanup toggle
Add an informative tooltip explaining the cleanup behavior and linking
to Schedule Jobs docs for custom cleanup strategies.

Closes #3973
2026-04-05 17:16:51 -06:00
Mauricio Siu a6db83c758 Merge pull request #4165 from Dokploy/fix/ntfy-test-error-message
fix: surface actual error message in ntfy test connection
2026-04-05 14:11:39 -06:00
Mauricio Siu ac65cc97f4 fix: surface actual error message in ntfy test connection
The catch block was swallowing the real error from the ntfy server,
making it impossible to diagnose connection failures (e.g. SSL, DNS,
auth issues). Now the underlying error message is included in the
tRPC error response.

Closes #4047
2026-04-05 14:08:55 -06:00
Mauricio Siu 30d5493281 Merge pull request #4164 from Dokploy/fix/permission-checks-env-and-load-services
fix: correct permission checks for compose loadServices and env editing
2026-04-05 13:59:11 -06:00
Mauricio Siu 91b44720ef fix: correct permission checks for compose loadServices and env editing
- Change compose.loadServices permission from service:create to service:read
  since loading services from a compose file is a read-only operation
- Add saveEnvironment endpoint to compose router with envVars:write permission
- Update show-environment.tsx to use saveEnvironment mutations instead of
  generic update mutations for all service types (compose, databases)

Closes #4052
2026-04-05 13:52:53 -06:00
Mauricio Siu f700017ccf Merge pull request #4163 from Dokploy/fix/slack-notification-mrkdwn
fix: replace deprecated Slack actions with mrkdwn link field
2026-04-05 13:46:00 -06:00
Mauricio Siu 9287721dbf Merge pull request #4054 from vincent-tarrit/4053-fix-slack-notifications-content
fix: actions in slack notification
2026-04-05 13:45:33 -06:00
Mauricio Siu 6cde04ea39 fix: replace deprecated Slack actions with mrkdwn link field
The actions array in Slack attachments requires Interactive Components
to be configured on the Slack app, which causes notifications to fail.
Replaces with a Details field using mrkdwn hyperlink syntax and adds
mrkdwn_in to ensure the link renders as clickable.

Closes #4053
2026-04-05 13:44:30 -06:00
Mauricio Siu 283eeeb3e6 Merge pull request #4161 from Dokploy/fix/compose-patch-ordering
fix: compose patches overwritten by domain injection
2026-04-05 13:35:40 -06:00
Mauricio Siu 19ae575fa8 fix: patches not applied to compose services
writeDomainsToCompose reads the compose file in Node.js before the
shell script runs, so patches applied as shell commands were being
overwritten by the stale pre-patch content.

Split patch execution into a separate step that runs before
getBuildComposeCommand, so the file is already patched when Node.js
reads it for domain injection.

Also added missing patch support to rebuildCompose which was skipping
patches entirely on redeploys.

Closes #4113
2026-04-05 13:28:18 -06:00
Mauricio Siu 4077af1308 Merge pull request #4160 from Dokploy/fix/extract-image-tag-port
fix: extractImageTag misidentifies registry port as tag
2026-04-05 13:06:12 -06:00
autofix-ci[bot] 8a043dcc5c [autofix.ci] apply automated fixes 2026-04-05 19:04:17 +00:00
Mauricio Siu 46204831f7 fix: extractImageTag misidentifies registry port as tag
The naive split(":").pop() approach treated the port number in
registry URLs (e.g. registry:5000/image) as the image tag.
Now uses lastIndexOf(":") and checks if the suffix matches a port
followed by a path (digits + slash), consistent with extractImageName.

Closes #4082
2026-04-05 13:03:41 -06:00
Mauricio Siu c854d4eb01 Merge pull request #4159 from Dokploy/fix/invitation-email-validation
fix: validate invitation email matches signup email
2026-04-05 12:45:00 -06:00
autofix-ci[bot] b8812dd7f2 [autofix.ci] apply automated fixes 2026-04-05 18:42:34 +00:00
Mauricio Siu ddde6a7bcb fix: address PR review — case-insensitive email check and proper error handling
- Normalize emails with toLowerCase().trim() before comparing
- Wrap getUserByToken in try/catch since it throws TRPCError on miss,
  rethrow as APIError for consistent error responses
2026-04-05 12:42:09 -06:00
Mauricio Siu 04ffa43008 fix: validate invitation expiry and status on signup
Also checks that the invitation is not expired and has not already been
used before allowing account creation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:39:43 -06:00
Mauricio Siu 17393af717 fix: enhance invitation validation in authentication logic
- Updated the authentication process to check if the email of the user matches the email associated with the invitation token.
- Improved error handling for cases where the user is not found or the email does not match the invitation.
2026-04-05 12:35:23 -06:00
Mauricio Siu 24b56c868d Merge pull request #4037 from snitramodranoel/feat/add-rhel-flavors
feat: add RHEL flavors to server setup script
2026-04-05 01:11:15 -06:00
Mauricio Siu be871a0c59 Merge branch 'canary' into feat/add-rhel-flavors 2026-04-05 01:10:34 -06:00
Mauricio Siu 2d6136a633 Merge pull request #3949 from lasseveenliese/3946-remove-free-gb-from-disk-space-monitoring-graph
feat: show only used disk space in server monitoring chart
2026-04-05 00:52:27 -06:00
Mauricio Siu acfab54810 Merge branch 'canary' into 3946-remove-free-gb-from-disk-space-monitoring-graph 2026-04-05 00:48:09 -06:00
Mauricio Siu 5e7328b00d Merge pull request #3937 from AlexDev404/fix/broken-install-instructions
fix: Broken install instructions
2026-04-05 00:45:48 -06:00
Mauricio Siu 882acd5c4c Merge pull request #4158 from Dokploy/feat/prevent-billing-checks-to-enterprise-users
feat: add isEnterpriseCloud field and update billing logic
2026-04-05 00:44:53 -06:00
Mauricio Siu e7c7d6a7cf feat: add isEnterpriseCloud field to user schema
- Added `isEnterpriseCloud` field to the user schema to enhance user differentiation for enterprise cloud services.
- This change supports the ongoing updates to billing and subscription management for enterprise users.
2026-04-05 00:41:53 -06:00
Mauricio Siu 45f2f52cf0 feat: add isEnterpriseCloud field and update billing logic
- Introduced `isEnterpriseCloud` boolean field in the user schema to differentiate enterprise users.
- Updated billing UI to display specific information for enterprise cloud users, including a dedicated section for managing subscriptions.
- Modified API webhook logic to handle subscription updates and server management based on the `isEnterpriseCloud` status.
2026-04-05 00:40:48 -06:00
Mauricio Siu 31f53197eb Merge pull request #4156 from Dokploy/feat/add-server-access-control-for-enterprise
feat: add accessedServers permission handling and server access valid…
2026-04-05 00:14:03 -06:00
autofix-ci[bot] cfed61fb96 [autofix.ci] apply automated fixes 2026-04-05 06:07:06 +00:00
Mauricio Siu bfa4ebc801 feat: add accessedServers permission handling and server access validation
- Introduced `accessedServers` field in user permissions schema and member table.
- Implemented server access validation across various API routers to ensure users can only access permitted servers.
- Added a new query to fetch accessible server IDs based on user roles and licenses.
- Updated UI components to support server selection in user permissions.
2026-04-05 00:06:27 -06:00
Mauricio Siu c160f24765 Merge pull request #3902 from naturedamends/patch-1
fix: Replace tooltip trigger button for help icon (provider compose ui)
2026-04-04 23:27:18 -06:00
Mauricio Siu b445e05202 chore: update dokploy version to v0.29.0 2026-04-04 23:25:22 -06:00
Mauricio Siu 239e2d4d96 Merge pull request #3810 from jaimehgb/fix/swarm-convergence
fix: set FailureAction=rollback for swarm services default UpdateConfig
2026-04-04 23:19:36 -06:00
Mauricio Siu 791c9d1268 Merge pull request #3794 from vcode-sh/fix/openapi-bigint-serialization
fix: resolve OpenAPI 500 error caused by BigInt serialization
2026-04-04 23:13:34 -06:00
Mauricio Siu f076e72046 refactor: remove unused API configuration for bodyParser and sizeLimit 2026-04-04 23:11:14 -06:00
Mauricio Siu 32758b29a7 fix: change stopGracePeriodSwarm type from bigint to number in schema 2026-04-04 23:09:55 -06:00
autofix-ci[bot] 6e9c5c79dc [autofix.ci] apply automated fixes 2026-04-05 05:06:42 +00:00
Mauricio Siu 182bbf43c8 Merge branch 'canary' into fix/openapi-bigint-serialization 2026-04-04 23:06:07 -06:00
Mauricio Siu 760edc6d5d Merge pull request #3764 from difagume/feature/enhanced-log-type-detection
feat: classify logs based on HTTP statusCode
2026-04-04 22:58:22 -06:00
Mauricio Siu a1a5141da6 Merge pull request #3748 from xob0t/fix/keyboard-shortcuts-non-english-layouts
fix: use event.code instead of event.key for keyboard shortcuts
2026-04-04 22:48:54 -06:00
autofix-ci[bot] b573ccc90c [autofix.ci] apply automated fixes 2026-04-05 04:48:38 +00:00
Mauricio Siu 6c28451ca1 Merge branch 'canary' into fix/keyboard-shortcuts-non-english-layouts 2026-04-04 22:47:34 -06:00
Mauricio Siu 6c834a9127 Merge pull request #3687 from mhbdev/invite-user-with-initial-credentials
feat: add credentials-based user provisioning alongside invitation flow
2026-04-04 22:41:17 -06:00
Mauricio Siu 2af420ef77 Merge branch 'canary' into invite-user-with-initial-credentials
Resolve conflicts:
- Integrate credentials-based user provisioning with canary changes
- Use withPermission("member", "create") instead of adminProcedure
- Adopt standardSchemaResolver, inviteMember mutation, and custom roles from canary
- Restrict credentials flow to non-cloud environments
2026-04-04 22:36:43 -06:00
Mauricio Siu 87c7305cb2 Merge branch 'canary' into invite-user-with-initial-credentials 2026-04-04 22:34:31 -06:00
Mauricio Siu 31fdf69286 Merge pull request #3633 from physikal/claude/swarm-container-breakdown-VJhK7
feat(swarm): add container breakdown by node with live metrics
2026-04-04 21:26:53 -06:00
Mauricio Siu f1bc3758b2 fix: improve size formatting functions for better robustness
- Enhanced the `formatSizeValue`, `formatMemUsage`, and `formatIOValue` functions to handle edge cases more effectively.
- Updated regex and condition checks to ensure proper parsing of input strings, improving overall reliability in formatting size values.
2026-04-04 21:21:41 -06:00
Mauricio Siu 396fb9f57f feat: enhance ShowSwarmOverviewModal with tabbed interface for containers and overview
- Introduced a tabbed layout in the ShowSwarmOverviewModal to separate the overview and containers views.
- Added ShowSwarmContainers component to the containers tab, improving the organization of information.
- Integrated Card component for better styling and presentation of the containers section.
2026-04-04 21:16:34 -06:00
Mauricio Siu 8e54e88370 feat: add empty states and summary cards for Swarm containers dashboard
- Introduced new components for handling various empty states in the Swarm containers dashboard, including `SwarmNotAvailable`, `ServicesError`, `NoServices`, and `NoRunningContainers`.
- Added `SummaryCards` component to display key metrics such as node count, down nodes, service count, and running containers.
- Enhanced the `ShowSwarmContainers` component to integrate the new empty states and summary cards, improving user feedback and overall experience.
2026-04-04 21:09:16 -06:00
Mauricio Siu 7e0fde8041 Merge branch 'canary' into claude/swarm-container-breakdown-VJhK7 2026-04-04 20:53:53 -06:00
Mauricio Siu 3969d2d2fe Merge pull request #3611 from Statsly-org/feat/application-icon-upload
Feat/application icon upload
2026-04-04 20:35:55 -06:00
autofix-ci[bot] b6ec2d510e [autofix.ci] apply automated fixes 2026-04-05 02:33:02 +00:00
Mauricio Siu 1753ac6605 feat: add icon field to application schema with size validation
- Introduced a new optional `icon` field to the application schema, allowing for icon uploads.
- Implemented validation to ensure the icon size does not exceed 2MB, enhancing data integrity.
2026-04-04 20:32:31 -06:00
autofix-ci[bot] 8dd970674d [autofix.ci] apply automated fixes 2026-04-05 02:29:17 +00:00
Mauricio Siu b3919be628 feat: enhance ShowIconSettings component with dialog and file upload functionality
- Updated the ShowIconSettings component to include a dialog for icon selection and upload.
- Added functionality to handle file uploads, including validation for file types and sizes.
- Implemented icon removal feature within the dialog.
- Refactored icon selection logic to improve user experience and maintainability.
- Adjusted the application page to integrate the updated ShowIconSettings component.
2026-04-04 20:25:47 -06:00
Mauricio Siu 5a0ec2c9dc feat: integrate dompurify and simple-icons for enhanced icon management
- Added `dompurify` for sanitizing SVG icons to prevent XSS vulnerabilities.
- Introduced `simple-icons` for a collection of SVG icons, enhancing the icon selection feature.
- Updated the `ShowIconSettings` component to utilize the new icon management logic.
- Removed the obsolete `icons.json` file and replaced it with a new `bundled-icons.ts` file for better structure and maintainability.
- Adjusted related API and component files to accommodate the new icon handling approach.
2026-04-04 20:16:47 -06:00
Mauricio Siu 012b67a491 Merge branch 'canary' into feat/application-icon-upload
# Conflicts:
#	apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId].tsx
#	apps/dokploy/pages/dashboard/project/[projectId]/environment/[environmentId]/services/application/[applicationId].tsx
#	apps/dokploy/server/api/routers/application.ts
2026-04-04 18:29:52 -06:00
Mauricio Siu b003fb4ffe chore: remove obsolete SQL and snapshot files related to application icon 2026-04-04 18:25:08 -06:00
Mauricio Siu 85c409e748 Merge pull request #3607 from OthmanHaba/canary
fix(postgres): add default StopGracePeriod to prevent WAL corruption
2026-04-04 18:23:07 -06:00
Mauricio Siu 745cf9d979 Merge pull request #3374 from david-dev-de/feat/middleware-configuration
feat: add configurable middlewares for domains
2026-04-04 10:29:11 -06:00
Mauricio Siu 70c611964e test(host-rule-format): add middlewares property to host rule format regression tests 2026-04-04 10:25:36 -06:00
autofix-ci[bot] 0f02c4dfc3 [autofix.ci] apply automated fixes 2026-04-04 16:20:23 +00:00
Mauricio Siu 8557432db0 feat(domain-handling): enhance custom entry point handling in AddDomain component
- Added logic to conditionally set the custom entry point based on the useCustomEntrypoint flag.
- Updated the onCheckedChange handler to clear the custom entry point value when the switch is turned off, improving form state management.
2026-04-04 10:17:06 -06:00
Mauricio Siu e36ae4b4d6 feat(database-migration): add new SQL migration for solid newton destine
- Introduced a new SQL script to add a "middlewares" column to the "domain" table with a default value of an empty text array.
- Updated the journal to include the new migration entry for version 0161.
- Added a snapshot file for version 7, detailing the schema changes for the "account" and "apikey" tables.
2026-04-04 09:47:11 -06:00
Mauricio Siu ed5e483f0b Merge branch 'canary' into feat/middleware-configuration 2026-04-04 09:39:21 -06:00
Mauricio Siu 2e027a7da5 chore: remove 0134_whole_dazzler SQL migration and associated metadata 2026-04-04 09:34:39 -06:00
Mauricio Siu 791ca657a3 Merge pull request #3386 from stripsior/chore/bump-mongo
chore(databases): update mongodb version, to patch latest cve
2026-04-04 09:32:15 -06:00
Mauricio Siu 1bf4f56ae6 feat(database-migration): add SQL script and metadata for Burly Odin migration
- Introduced a new SQL script to set the default value for the "dockerImage" column in the "mongo" table.
- Updated the journal to include the new migration entry for version 0160.
- Added a snapshot file for version 7, detailing the schema for the "account" and "apikey" tables.
2026-04-04 09:31:09 -06:00
Mauricio Siu 02f2829af9 Merge branch 'canary' into chore/bump-mongo 2026-04-04 09:29:53 -06:00
Mauricio Siu b2ca51cee7 Merge pull request #4144 from Dokploy/4041-add-ability-to-change-services-password-like-mysql-and-whatnot
feat(database-credentials): add password update functionality for Mar…
2026-04-04 09:28:07 -06:00
autofix-ci[bot] 1cfc15ca0b [autofix.ci] apply automated fixes 2026-04-04 15:27:30 +00:00
Mauricio Siu 0cb5ee49e0 feat(password-validation): enhance password validation across database routers
- Updated password validation in MariaDB, MongoDB, MySQL, Postgres, and Redis routers to enforce a regex pattern that restricts invalid characters.
- Introduced a consistent error message for invalid passwords to improve user guidance and ensure database compatibility.
- Refactored password validation logic in the schema files to utilize shared constants for regex and messages, promoting code reuse and maintainability.
2026-04-04 09:27:06 -06:00
Mauricio Siu 3d838aa074 feat(password-update): enhance password update functionality across database routers
- Added confirmation password field and validation to the `UpdateDatabasePassword` component.
- Refactored password update logic in MariaDB, MongoDB, MySQL, Postgres, and Redis routers to utilize database transactions for improved reliability.
- Ensured consistent handling of password updates across all database types, enhancing user experience and security.
2026-04-04 09:19:29 -06:00
Šimon Orság eafbd0353e fix: strictly use ssh2 1.16.0 package 2026-04-04 17:18:03 +02:00
Mauricio Siu 1506d8f21e fix(update-database-password): enhance error handling for password update failures
- Improved error messages when updating the database password to provide clearer guidance based on the error type.
- Added specific feedback for cases where the database container is not running, prompting users to start the service before attempting to change the password.
2026-04-04 00:22:19 -06:00
autofix-ci[bot] e1e175b1e0 [autofix.ci] apply automated fixes 2026-04-04 06:19:12 +00:00
Mauricio Siu 8001304e98 feat(database-credentials): add password update functionality for MariaDB, MongoDB, MySQL, Postgres, and Redis
- Introduced a new `UpdateDatabasePassword` component to facilitate password updates for database credentials.
- Implemented password change mutations in the respective API routers for MariaDB, MongoDB, MySQL, Postgres, and Redis.
- Enhanced user experience by providing success notifications upon successful password updates.
- Updated UI components to include the new password update functionality, ensuring consistency across different database types.
2026-04-04 00:18:19 -06:00
Mauricio Siu 987cb41bfc Merge pull request #3350 from Bima42/feat/3325-add-button-to-edit-certificates
feat: be able to edit certificate
2026-04-03 23:50:55 -06:00
Mauricio Siu 199589d42e feat(certificates): display server information in certificate details
- Added a new section to the certificate details view to show associated server information, including the server name and IP address, enhancing the visibility of server-related data for each certificate.
- Updated the API to include server data when fetching certificates.
2026-04-03 23:09:19 -06:00
Mauricio Siu 91d4fe2420 fix(certificates): improve error handling in certificate extraction functions
- Updated the `extractExpirationDate` and `extractCommonName` functions to return null instead of throwing errors when encountering unexpected structures in the certificate data. This change enhances the robustness of the certificate parsing logic.
2026-04-03 23:05:16 -06:00
Mauricio Siu 92caee5a77 refactor(certificate): remove auto-renew field from certificate handling
- Eliminated the `autoRenew` field from the certificate schema, API router, and related components to streamline certificate management.
- Updated form handling and validation logic accordingly to reflect the removal of the auto-renew feature.
2026-04-03 23:00:27 -06:00
Mauricio Siu 092212e225 feat(access-control): update certificate permissions to include 'update' action
- Modified the access control settings for the 'certificate' resource to allow 'update' permissions for admin and owner roles.
- Updated the certificate router to use the new permission structure for the update mutation.
2026-04-03 22:56:25 -06:00
Mauricio Siu 5c053777c5 Merge branch 'canary' into feat/3325-add-button-to-edit-certificates 2026-04-03 22:53:22 -06:00
Mauricio Siu eed36e52af Merge pull request #3348 from Bima42/feat/3345-allow-services-to-end-with-numbers
fix: update regex to allow number at the end of app name
2026-04-03 22:42:25 -06:00
Mauricio Siu dd28a8e703 feat(validation): centralize app name validation logic
- Introduced `APP_NAME_REGEX` and `APP_NAME_MESSAGE` constants in `schema.ts` for consistent app name validation across `add-application.tsx`, `add-compose.tsx`, and `add-database.tsx`.
- Updated regex and error message in the respective schemas to utilize the new constants, improving maintainability and readability.
2026-04-03 22:40:49 -06:00
Mauricio Siu e211feb801 Merge branch 'canary' into feat/3345-allow-services-to-end-with-numbers 2026-04-03 22:39:34 -06:00
Mauricio Siu da239675bd Merge pull request #2936 from Bima42/feat/2931-template-bookmarking
feat: be able to bookmark templates
2026-04-03 21:57:29 -06:00
Mauricio Siu 0d5f452494 refactor(compose): change templates procedure from public to protected
- Updated the `templates` procedure in the compose router to use `protectedProcedure` instead of `publicProcedure`, enhancing access control for this endpoint.
2026-04-03 21:54:37 -06:00
Mauricio Siu 2eb460ba63 feat(user): add bookmarkedTemplates column to user table and update related API methods
- Introduced a new column `bookmarkedTemplates` to the user table to store user-specific template bookmarks.
- Updated API methods to manage bookmarked templates, replacing the deprecated user_template_bookmarks table.
- Adjusted queries to retrieve and toggle bookmarks directly from the user record.
2026-04-03 21:50:12 -06:00
Mauricio Siu d8e15a60f0 Merge branch 'canary' into feat/2931-template-bookmarking 2026-04-03 21:32:36 -06:00
Mauricio Siu 1b3b439257 chore: remove deprecated user_template_bookmarks table and associated metadata 2026-04-03 21:31:40 -06:00
Mauricio Siu 964d79d552 Merge pull request #4142 from Dokploy/2267-add-a-disk-space-pie-chart
feat(dashboard): enhance monitoring charts with new Docker disk usage…
2026-04-03 21:29:28 -06:00
autofix-ci[bot] 1730f427df [autofix.ci] apply automated fixes 2026-04-04 03:29:02 +00:00
Mauricio Siu 28845c145e feat(dashboard): enhance monitoring charts with new Docker disk usage component and refactor existing charts for consistency
- Added DockerDiskUsageChart component to visualize Docker disk usage data.
- Refactored existing chart components (DockerBlockChart, DockerCpuChart, DockerDiskChart, DockerMemoryChart, DockerNetworkChart) to use a consistent ChartContainer and updated chart configurations.
- Improved tooltip functionality and styling across all charts for better user experience.
- Integrated new API endpoint for fetching Docker disk usage data.
2026-04-03 21:27:54 -06:00
Mauricio Siu b7adb7fb0a Merge pull request #3159 from quochuydev/feat/add-domains-grid-table-toggle
feat: add grid/table view toggle for domains page
2026-04-03 20:50:28 -06:00
autofix-ci[bot] e4f6e5ea54 [autofix.ci] apply automated fixes 2026-04-04 02:48:22 +00:00
Mauricio Siu 96d1abb4b6 feat(domains): enhance domain table with service name and entrypoint columns, and implement view mode persistence in local storage 2026-04-03 20:47:44 -06:00
Mauricio Siu c5f804421c Merge branch 'canary' into feat/add-domains-grid-table-toggle 2026-04-03 20:40:51 -06:00
Mauricio Siu c51d71848d feat(database): add customEntrypoint column to domain table and update journal and snapshot metadata for version 7 2026-04-03 20:40:06 -06:00
Mauricio Siu da5d9b2c75 Merge branch 'canary' into feat/add-domains-grid-table-toggle 2026-04-03 20:35:56 -06:00
Mauricio Siu e102876e4d Merge pull request #2959 from Harikrishnan1367709/Easier-Ways-to-Upload-Files-to-a-Docker-Container-#2920
feat: Add web UI file upload to Docker containers (#2920)
2026-04-03 17:32:08 -06:00
Mauricio Siu 4c06a72075 refactor(docker): update uploadFileToContainer permission handling
- Replaced protectedProcedure with withPermission for the uploadFileToContainer mutation to enhance permission management.
- Improved code clarity by removing unnecessary imports.
2026-04-03 17:30:19 -06:00
Mauricio Siu cfa60aa971 refactor(upload): simplify file upload process to Docker container
- Consolidated the file upload logic for both remote and local servers into a single command.
- Removed redundant temporary file handling and streamlined error management.
- Improved code readability by reducing complexity in the uploadFileToContainer function.
2026-04-03 17:29:24 -06:00
Mauricio Siu d2e4922c2f fix(upload): correct type import for UploadFileToContainer in upload file modal
- Restored the type import for UploadFileToContainer in the upload file modal component.
- Updated useForm to correctly utilize the UploadFileToContainer type for improved type safety.
2026-04-03 17:25:27 -06:00
Mauricio Siu 192716b8ae refactor(upload): update file upload modal and dropzone components
- Changed zodResolver import to standardSchemaResolver for improved schema handling.
- Adjusted Dropzone layout for better visual alignment and user experience.
- Removed unused uploadProcedure from the Docker router to streamline the API.
2026-04-03 17:23:26 -06:00
Mauricio Siu 13f1de5bd7 Merge branch 'canary' into Easier-Ways-to-Upload-Files-to-a-Docker-Container-#2920 2026-04-03 17:15:57 -06:00
Mauricio Siu 2e8e2dc2da Merge pull request #2956 from Harikrishnan1367709/Change-backup-file-naming-structure-#2955
Feat : Fix backup file naming for Windows 11 compatibility (#2955)
2026-04-03 17:14:00 -06:00
autofix-ci[bot] fd2097ea23 [autofix.ci] apply automated fixes 2026-04-03 23:13:32 +00:00
Mauricio Siu 71de71fb8a refactor(backups): standardize backup file naming using getBackupTimestamp utility
- Replaced inline timestamp generation with the new getBackupTimestamp function across various backup modules (compose, libsql, mariadb, mongo, mysql, postgres, web-server, and volume-backups).
- Improved code readability and maintainability by centralizing timestamp formatting logic.
2026-04-03 17:13:00 -06:00
Mauricio Siu 6192c08400 Merge branch 'canary' into Change-backup-file-naming-structure-#2955 2026-04-03 17:08:56 -06:00
Mauricio Siu 435d812e1d Merge pull request #2953 from leofilmon/feat/password-manager-compatible-otp-input
feat: add password manager compatible OTP input component
2026-04-03 17:05:49 -06:00
Mauricio Siu 18b8b2624b Merge branch 'canary' into feat/password-manager-compatible-otp-input 2026-04-03 17:02:16 -06:00
Mauricio Siu 3f1bf2b14e Merge pull request #2863 from KarpachMarko/feature/custom-entrypoint
feat: add support for custom entry point
2026-04-03 16:28:57 -06:00
autofix-ci[bot] 2683ac2a1b [autofix.ci] apply automated fixes 2026-04-03 22:23:55 +00:00
Mauricio Siu 4e11334940 refactor(domain): simplify custom entrypoint checks in Docker and Traefik utilities
- Updated conditional checks for customEntrypoint to use a more concise syntax.
- Ensured consistent handling of HTTPS configurations across domain management functions.
- Improved code readability and maintainability by streamlining logic in addDomainToCompose and manageDomain functions.
2026-04-03 16:21:41 -06:00
Mauricio Siu 82893598e0 test(labels): add tests for custom entrypoint handling in domain labels
- Implemented tests to verify the addition of stripPath and internalPath middlewares for custom entrypoints.
- Ensured correct path prefixing in router rules and combined middleware functionality.
- Added checks to confirm that redirect-to-https is not added for custom entrypoints even when HTTPS is enabled.
- Enhanced tests for router configuration with custom entrypoints, including path handling and TLS settings.
2026-04-03 16:17:06 -06:00
Mauricio Siu 86905fc5bf Merge branch 'canary' into feature/custom-entrypoint 2026-04-03 15:45:59 -06:00
Mauricio Siu c7814bb752 Merge pull request #3287 from faytranevozter/feat/enhance-certificate-view
feat(certificates): enhance certificate view
2026-04-03 15:37:48 -06:00
Mauricio Siu c0d6eac35d Merge branch 'canary' into feat/enhance-certificate-view 2026-04-03 15:34:51 -06:00
Mauricio Siu 6dfa762934 Merge pull request #4104 from nktnet1/typo-fix
fix: typos, grammar, spelling, style & format
2026-04-03 15:30:21 -06:00
Mauricio Siu 0e3bc444b9 Merge branch 'canary' into typo-fix 2026-04-03 15:26:54 -06:00
Mauricio Siu fb7b7cff66 Merge pull request #4136 from Dokploy/4066-git-clone-uses-external-url-instead-of-internal-url-when-oauth2-provider-has-internal-url-configured-causing-authelia-redirect-error
fix(git-provider): use internal URLs for Gitea and GitLab repository …
2026-04-03 15:24:38 -06:00
Mauricio Siu 5e999f1c3c Merge pull request #4067 from impcyber/patch-1
Update gitea.ts
2026-04-03 15:23:29 -06:00
Mauricio Siu 9e52b722f0 fix(git-provider): use internal URLs for Gitea and GitLab repository cloning
- Updated the repository cloning functions to prioritize internal URLs for Gitea and GitLab, enhancing security and access control.
- Ensured fallback to external URLs if internal ones are not available.
2026-04-03 15:23:00 -06:00
Mauricio Siu 70418dd09b Merge pull request #4128 from mixelburg/fix/subscription-done-flag
fix(subscriptions): set done=true when deployment/restore completes so the while loop can exit
2026-04-03 15:18:32 -06:00
Mauricio Siu df95766807 refactor(backup): rename async function for clarity and improve error logging
- Changed the anonymous async function to a named function `runRestore` for better readability.
- Enhanced error handling to log specific error messages during the restore process.
2026-04-03 15:13:09 -06:00
Mauricio Siu e5aae15310 Merge pull request #4125 from dpulpeiro/fix/sort-schedules-by-name
fix: sort schedules by name in list query
2026-04-03 14:54:46 -06:00
Mauricio Siu 964773b44c fix(schedule): change sorting of schedules to order by creation date
Updated the orderBy clause in the schedules query to sort by the createdAt field instead of the name, ensuring schedules are returned in the order they were created.
2026-04-03 14:54:34 -06:00
Mauricio Siu 7224436610 Merge pull request #4135 from Dokploy/feat/add-shared-git-providers
feat(git-provider): enhance sharing and permissions management
2026-04-03 14:48:56 -06:00
autofix-ci[bot] d6885c32ea [autofix.ci] apply automated fixes 2026-04-03 20:46:40 +00:00
Mauricio Siu 4da3c468eb refactor(schema): update API schemas for libsql and mount
- Replaced `createSchema.pick` with `z.object` for `apiFindOneLibsql` and `apiFindMountByApplicationId` to enforce stricter validation.
- Ensured `libsqlId`, `serviceType`, and `serviceId` are required strings with minimum length constraints.
2026-04-03 14:46:05 -06:00
Mauricio Siu 38a711776b feat(git-provider): improve sharing toggle and authorization checks
- Added loading state for the sharing toggle in the UI to prevent user interaction during processing.
- Enhanced authorization logic in the API to ensure that both user and organization ownership are validated before allowing sharing of Git providers.
- Improved error handling in the license key deactivation process to log failures for better debugging.
2026-04-03 14:38:14 -06:00
autofix-ci[bot] 4030049ee8 [autofix.ci] apply automated fixes 2026-04-03 20:30:20 +00:00
Mauricio Siu 06b18aca08 feat(git-provider): enhance sharing and permissions management
- Added functionality to toggle sharing of Git providers with the organization.
- Introduced a new column "sharedWithOrganization" in the git_provider table to track sharing status.
- Updated user permissions to include accessedGitProviders, allowing for more granular access control.
- Enhanced API routes to support fetching accessible Git providers based on user roles and permissions.
- Implemented UI components for managing Git provider sharing and permissions in the dashboard.
2026-04-03 14:29:48 -06:00
Mauricio Siu 86ba597d67 Merge pull request #2907 from WalidDevIO/feat/notifications/dokploy-backup
feat[notifications]: Add Dokploy Backup notification type support
2026-04-03 13:38:35 -06:00
Šimon Orság 91ebf3b6f5 fix: upgrade ssh2 from 1.15.0 to ^1.16.0 (util.isDate removed in Node.js v23+) 2026-04-03 01:09:28 +02:00
Maks Pikov 5978c4135e fix(subscriptions): change const done to let and resolve with finally to allow while loop to exit 2026-04-02 22:21:42 +00:00
Daniel García Pulpeiro e9202bfb15 fix: sort schedules by name in list query
Schedules were returned in arbitrary order from the database.
Add orderBy clause to sort them alphabetically by name.
2026-04-02 11:48:50 +02:00
Mauricio Siu 365e055005 feat(notifications): integrate dokployBackup into notification handling
- Added dokployBackup parameter to various notification functions and schemas to support backup notifications.
- Updated HandleNotifications component to include dokployBackup in notification payloads.
- Enhanced notification utilities to accommodate new backup notification types across multiple channels.
2026-04-01 08:26:24 -06:00
autofix-ci[bot] 9b108480a8 [autofix.ci] apply automated fixes 2026-03-30 22:49:52 +00:00
Mauricio Siu 450d591c1a feat(database): add dokployBackup column to notification table and update journal
- Introduced a new boolean column "dokployBackup" in the "notification" table with a default value of false.
- Added journal entry for version 7 tagged as "0156_fair_vargas" to track this schema change.
- Created a new snapshot file for version 7 to reflect the updated database schema.
2026-03-30 16:49:26 -06:00
Mauricio Siu d90722a174 feat(notifications): add switch for Dokploy backup notification trigger
- Introduced a new switch control in the notifications settings to enable or disable actions triggered by Dokploy backup creation.
- Enhanced user interface for better interaction with notification settings.
2026-03-30 16:49:04 -06:00
Mauricio Siu f9de42610c Merge branch 'canary' into feat/notifications/dokploy-backup 2026-03-30 16:48:00 -06:00
Mauricio Siu 780406f9ef Remove unused SQL file and related journal entries for '0119_wakeful_luke_cage' notification type 2026-03-30 16:45:49 -06:00
Mauricio Siu f49988498f Merge branch 'canary' into feature/custom-entrypoint 2026-03-30 16:10:53 -06:00
Mauricio Siu 565bc16f24 remove unused giant_korvac migration and related snapshot files 2026-03-30 12:00:12 -06:00
Mauricio Siu c7b5e73d1c Merge pull request #4115 from Dokploy/4086-stale-traefik-dynamic-config-files-not-cleaned-up-on-application-deletion
refactor(traefik): improve config removal logic and error handling
2026-03-30 09:11:09 -06:00
Mauricio Siu 8053ee7724 refactor(traefik): improve config removal logic and error handling
- Consolidated command execution for removing Traefik config files by using a single command string.
- Enhanced error handling to log issues encountered during the removal process for both local and remote configurations.
2026-03-30 08:05:47 -06:00
manalkaff d9b2b48643 fix: make directConnection conditional on replicaSets config 2026-03-30 20:58:43 +08:00
manalkaff 148c91bf5e fix: add authSource and directConnection params to MongoDB connection URLs
Fixes #4105 - MongoDB external and internal connection URLs were missing
required query parameters causing authentication failures.

Added ?authSource=admin&directConnection=true to both connection strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:50:55 +08:00
autofix-ci[bot] c4aca74aef [autofix.ci] apply automated fixes 2026-03-29 22:53:14 +00:00
Khiet Tam Nguyen dab13a52d6 fix: use slug instead of sluggish
Update apps/dokploy/components/dashboard/settings/git/gitlab/edit-gitlab-provider.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 09:52:51 +11:00
Khiet Tam Nguyen 4a7e9a200e fix: use slug instead of sluggish
Update apps/dokploy/components/dashboard/settings/git/gitlab/add-gitlab-provider.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-30 09:52:36 +11:00
Tam Nguyen f83ab2923d stlye: format and lint 2026-03-30 09:34:27 +11:00
Tam Nguyen 9a1bee5287 fix: more grammar and spelling mistakes 2026-03-30 09:34:27 +11:00
autofix-ci[bot] 6d17f62942 [autofix.ci] apply automated fixes 2026-03-29 22:02:53 +00:00
Tam Nguyen 815b8136fa fix: further typos 2026-03-30 09:01:50 +11:00
Mauricio Siu 290a03ccfb Merge pull request #4093 from Dokploy/4084-gotify-ntfy-lark-mattermost-and-custom-notification-providers-silently-drop-volumebackup-on-creation
feat(notification): add volumeBackup parameter to notification creati…
2026-03-29 09:10:28 -06:00
Mauricio Siu 63aa60f7e2 feat(notification): add volumeBackup parameter to notification creation functions
- Updated createCustomNotification, createLarkNotification, createMattermostNotification, and updateMattermostNotification to include volumeBackup as a parameter, enhancing notification capabilities.
2026-03-29 09:08:46 -06:00
Mauricio Siu fe9b0ebcea Merge pull request #4092 from Dokploy/2023-add-support-for-rclone-sign_accept_encoding-option-to-fix-s3-compatible-services-behind-proxies-blocked-until-rclone-170
feat(destinations): add additionalFlags field for destination settings
2026-03-29 09:06:44 -06:00
Mauricio Siu 8ccdb66ced feat(destinations): enhance validation for additionalFlags in destination settings
- Introduced regex validation for the `additionalFlags` field to ensure proper flag formatting.
- Updated error handling in the API router to provide clearer feedback on validation issues.
- Refactored the database schema to align with the new validation rules for additionalFlags.
- Added a new validation module for destination-related checks.
2026-03-29 08:58:42 -06:00
Mauricio Siu e38f07d286 fix(dashboard): handle optional serverId in RemoveContainerDialog
- Updated the serverId prop in RemoveContainerDialog to default to undefined if not provided, ensuring better handling of optional values.
2026-03-29 08:46:05 -06:00
autofix-ci[bot] 035d39e3b7 [autofix.ci] apply automated fixes 2026-03-29 14:43:41 +00:00
Mauricio Siu 82a908a865 feat(destinations): enhance additionalFlags handling in destination settings
- Refactored the `additionalFlags` field to use a structured object format, allowing for better validation and management of flag values.
- Replaced the textarea input with a dynamic list of input fields, enabling users to add or remove flags easily.
- Updated form handling to accommodate the new structure, ensuring proper data mapping during form submission.
2026-03-29 08:43:16 -06:00
Mauricio Siu 4bbb2ece49 feat(destinations): add additionalFlags field for destination settings
- Introduced an optional `additionalFlags` field in the destination schema to allow users to specify extra parameters.
- Updated the form in the dashboard to include a textarea for entering additional flags.
- Modified the API router to handle the new `additionalFlags` input when creating or updating destinations.
- Adjusted database schema to accommodate the new field in the destination table.
2026-03-29 08:39:27 -06:00
impcyber 8ee374dc6b Update gitea.ts
https://github.com/Dokploy/dokploy/issues/4066
2026-03-25 00:16:44 +03:00
Mauricio Siu ddfcd1a671 Merge pull request #2753 from MichalMaciejKowal/2731-wrong-extension-for-mongo-backup-file
fix: Remove .sql for mongo backup file name
2026-03-24 13:18:20 -06:00
Mauricio Siu 401b177a4e fix(backups): update backup file extension based on database type
- Changed the backup file name extension to use '.bson' for MongoDB and '.sql' for other database types, ensuring correct file formats for backups.
2026-03-24 13:17:03 -06:00
Mauricio Siu 88b56ca0a2 Merge branch 'canary' into 2731-wrong-extension-for-mongo-backup-file 2026-03-24 13:15:58 -06:00
Mauricio Siu 3d48b25f71 Merge pull request #4065 from Dokploy/2779-implement-removing-unsuedexited-containers
feat(docker): implement container removal functionality
2026-03-24 12:57:23 -06:00
autofix-ci[bot] b7e30d7ec3 [autofix.ci] apply automated fixes 2026-03-24 18:57:02 +00:00
Mauricio Siu b1ef5dc2c6 feat(docker): implement container removal functionality
- Added RemoveContainerDialog component for user confirmation before removing a Docker container.
- Integrated the dialog into the container management UI.
- Implemented server-side logic for container removal, including permission checks and error handling.
- Updated API router to include the new removeContainer mutation.
2026-03-24 12:56:25 -06:00
Mauricio Siu 3846e41d7f Merge pull request #2728 from hoootan/feat/add-mattermost-notification-provider
feat: add mattermost notification provider
2026-03-24 12:46:30 -06:00
autofix-ci[bot] ac76f2d97a [autofix.ci] apply automated fixes 2026-03-24 18:40:51 +00:00
Mauricio Siu d6056972f4 fix(notifications): update Mattermost notification handling
- Changed webhookUrl validation to ensure it is a valid URL.
- Updated input types for createMattermostNotification and updateMattermostNotification functions to use z.infer for better type inference.
- Refactored sendMattermostNotification to improve error handling and payload construction.
2026-03-24 12:39:38 -06:00
autofix-ci[bot] 58b9a0d3d0 [autofix.ci] apply automated fixes 2026-03-24 14:56:19 +00:00
Mauricio Siu fe78f282f8 feat(notifications): add Mattermost icon to notifications display
- Integrated MattermostIcon into the ShowNotifications component to support Mattermost notification type.
- Enhanced the user interface to visually represent Mattermost notifications alongside existing notification types.
2026-03-24 08:14:22 -06:00
autofix-ci[bot] 4941a80b50 [autofix.ci] apply automated fixes 2026-03-24 07:30:51 +00:00
Mauricio Siu 5ea2ee5dcd feat(database): add Mattermost notification support
- Introduced a new SQL file to alter the notificationType and create a Mattermost table.
- Added a foreign key relationship between the notification table and the new Mattermost table.
- Updated the journal and snapshot metadata to reflect these changes.
2026-03-24 01:29:34 -06:00
Mauricio Siu 76d6de5337 Merge branch 'canary' into feat/add-mattermost-notification-provider 2026-03-24 01:29:08 -06:00
Mauricio Siu 3374737db6 Merge pull request #4059 from Dokploy/feat/add-non-root-user
feat(servers): enhance server setup and validation for user privileges
2026-03-24 01:16:59 -06:00
autofix-ci[bot] 27a67af190 [autofix.ci] apply automated fixes 2026-03-24 07:12:46 +00:00
Mauricio Siu 7e6a7d2cd4 feat(servers): enhance server setup and validation for user privileges
- Added FormDescription to clarify user requirements in the server handling component.
- Updated alert messages to inform users about connecting as root or non-root with passwordless sudo access.
- Introduced new status rows in the validation component to display privilege mode and Docker group membership.
- Implemented validation functions for sudo access and Docker group membership in the server setup scripts, ensuring proper permissions are checked during setup.
2026-03-24 01:12:07 -06:00
Mauricio Siu 4f5f1ad841 Decrease max failures from 4 to 3
Reduce the maximum allowed failures in PR quality check.
2026-03-24 00:11:59 -06:00
vincent-tarrit c42a16d658 Merge branch 'Dokploy:canary' into 4053-fix-slack-notifications-content 2026-03-24 07:10:24 +01:00
vincent-tarrit b222409129 lint: fix linter 2026-03-24 07:09:35 +01:00
vincent-tarrit a322ac374c fix: actions in slack notification 2026-03-23 18:44:14 +01:00
Leonardo Martins 92975a6865 feat: add RHEL flavors to server setup script 2026-03-19 10:32:50 -03:00
Mauricio Siu 4d8a2a38e8 Merge pull request #4029 from Dokploy/canary
🚀 Release v0.28.8
2026-03-18 21:43:35 -06:00
Diego Fabricio 4ef8c94340 Apply suggestion from @greptile-apps[bot]
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-18 21:21:47 -05:00
Diego Fabricio ff369c9d3a style(dashboard): remove max-width constraint from deployments card
- Deleted max-w-8xl class to allow card width to adapt freely
2026-03-18 21:09:08 -05:00
Mauricio Siu de3db08e60 Merge pull request #4020 from Dokploy/canary
🚀 Release v0.28.7
2026-03-17 23:34:20 -06:00
Mauricio Siu a2d655083a Merge pull request #3965 from Dokploy/canary
🚀 Release v0.28.6
2026-03-10 10:18:15 -06:00
autofix-ci[bot] 2a2acbfe9a [autofix.ci] apply automated fixes 2026-03-09 06:17:05 +00:00
Mauricio Siu f3356cfe90 Merge pull request #3938 from Dokploy/canary
🚀 Release v0.28.5
2026-03-09 00:13:30 -06:00
lasseveenliese fc8a5153f1 feat: show only used disk space in monitoring graph 2026-03-08 23:46:06 +01:00
Immanuel Daviel A. Garcia 2da45d3ca9 fix: Broken install instructions
This fixes the issue where the installation instructions are instructing to have the user run the install script under the wrong shell.
2026-03-08 02:00:03 -06:00
Mauricio Siu 2362778fe1 Merge pull request #3907 from Dokploy/canary
🚀 Release v0.28.4
2026-03-06 11:51:08 -06:00
naturedamends bf9d2615c2 Better to not have a button 2026-03-05 22:06:51 +00:00
naturedamends 40d07357bc Update save-gitea-provider-compose.tsx 2026-03-05 20:48:37 +00:00
naturedamends 1e5e361094 Update save-gitea-provider-compose.tsx 2026-03-05 20:47:14 +00:00
naturedamends abc7014b61 Apply suggestions from code review
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 20:33:46 +00:00
naturedamends a8648607b8 Update save-gitea-provider-compose.tsx 2026-03-05 20:33:09 +00:00
naturedamends 453a7b12b6 Replace tooltip trigger with button for help icon
This is only in one provider but can be extened to other providers
2026-03-05 20:24:30 +00:00
Mauricio Siu 628f16e8cb fix: update import statements to include file extensions for consistency 2026-03-03 15:34:56 -06:00
Mauricio Siu ea8e99d76d Merge pull request #3875 from Dokploy/canary
🚀 Release v0.28.3
2026-03-03 15:05:02 -06:00
Mauricio Siu d4719ece58 Merge pull request #3845 from Dokploy/canary
🚀 Release v0.28.2
2026-03-01 00:36:46 -06:00
Jaime Herrero fadc7fede5 fix: set FailureAction=rollback for swarm services default UpdateConfig
Docker Swarm's default FailureAction is "pause". When a task fails or is
terminated early during a rolling update, Swarm pauses the update and
stops ALL reconciliation — orphan containers persist indefinitely, even
when healthy. This is the root cause of orphan container issues reported
in production (services showing Replicas: N/1 with multiple healthy
containers that never get cleaned up).

Setting FailureAction to "rollback" makes Swarm automatically revert to
the previous working service spec on failure, preventing orphans while
preserving service availability. Also adds a default RollbackConfig with
Order: "start-first" to match the update config (Docker defaults rollback
to "stop-first" otherwise).

Only affects the default config — users who have configured their own
updateConfigSwarm/rollbackConfigSwarm are not affected.

Relates to #1669, #2223, #2911, #2150
2026-02-28 18:20:12 -05:00
Hootan cbbf7f3a6d Merge branch 'canary' into feat/add-mattermost-notification-provider
Resolves merge conflicts between mattermost notification provider (this PR)
and new canary features (resend, teams, SSO, patches, etc).

All notification providers are now included:
- slack, telegram, discord, email, gotify, ntfy
- mattermost (this PR)
- resend, pushover, custom, lark, teams (from canary)
2026-02-28 00:49:31 +01:00
Mauricio Siu e679a322b9 Merge pull request #3825 from Dokploy/canary
🚀 Release v0.28.1
2026-02-27 14:02:42 -06:00
Mauricio Siu f24f1ada5f Merge pull request #3805 from Dokploy/canary
🚀 Release v0.28.0
2026-02-27 02:02:04 -06:00
Vibe Code d7886fb7c9 fix: resolve OpenAPI 500 error caused by BigInt serialization in stopGracePeriodSwarm
Change Drizzle column mode from "bigint" to "number" for stopGracePeriodSwarm
across all 6 service schemas. This fixes JSON.stringify failing silently in the
@dokploy/trpc-openapi adapter, which unlike the tRPC endpoint does not use
superjson and cannot serialize BigInt values.

No database migration needed — only the JS representation changes. The values
are nanosecond grace periods that fit safely within Number.MAX_SAFE_INTEGER.

Also adds onError logging and export const config to the OpenAPI route handler
to match the tRPC route and improve debuggability.

Fixes #3793
2026-02-25 00:20:25 +01:00
diego fabricio 939ff810a2 feat(logs): classify logs using HTTP statusCode when present 2026-02-20 15:28:26 -05:00
xob0t 1926417458 fix: use event.code instead of event.key for keyboard shortcuts
This fixes keyboard shortcuts (Ctrl+S, Ctrl+B, Ctrl+J, Ctrl+K) not working with non-English keyboard layouts (e.g., Russian, French AZERTY, German QWERTZ).

Fixes #3495
2026-02-19 01:32:23 +03:00
mhbdev 864e2299ee Merge branch 'canary' of https://github.com/Dokploy/dokploy into invite-user-with-initial-credentials 2026-02-18 16:03:33 +03:30
Mauricio Siu 5b6d80e177 Merge pull request #3682 from Dokploy/canary
🚀 Release v0.27.1
2026-02-18 01:54:44 -06:00
mhbdev be3d7825e1 fallback to empty string was redundant - Zod ensures this field exists when mode is "credentials" 2026-02-11 17:15:26 +03:30
mhbdev c6efe6f35b feat: add credentials-based user provisioning alongside invitation flow 2026-02-11 17:06:46 +03:30
Mauricio Siu 2c9ca651a8 Merge pull request #3668 from Dokploy/canary
refactor(deployments): enhance deployment worker and queue handling f…
2026-02-10 03:16:04 -06:00
Mauricio Siu 413ed9bd80 Merge pull request #3604 from Dokploy/canary
🚀 Release v0.27.0
2026-02-10 02:06:41 -06:00
Claude 84fb82ea99 fix(swarm): guard getApplicationInfo against empty appNames array
When called with an empty array, `docker service ps` receives no
SERVICE argument and fails with "requires at least 1 argument".
Return early with [] when appNames is empty.
2026-02-09 06:52:11 +00:00
Claude 1d96c4d534 fix(swarm): restore getSwarmNodes original error behavior
getSwarmNodes was changed to return [] on error, but the existing
SwarmMonitorCard checks `if (!nodes)` to detect failures. Since ![]
is false, the error state was silently skipped, breaking the Overview
tab for users without Docker Swarm initialized.

Reverted to return undefined on error (original behavior) so the
existing Overview tab error handling continues to work. The Containers
tab already handles nodes === undefined explicitly.
2026-02-09 06:39:27 +00:00
autofix-ci[bot] bb02de690b [autofix.ci] apply automated fixes 2026-02-09 00:06:56 +00:00
Claude c8fd999044 feat(swarm): add container breakdown by node with live metrics
TL;DR: New "Containers" tab on the Swarm page showing which containers
run on which nodes, with live CPU/Memory/Block I/O/Network I/O metrics
refreshing every 5 seconds. Comprehensive edge case handling guides
users through prerequisites (swarm init, registry, service deployment).

---

## New Files

- `apps/dokploy/components/dashboard/swarm/containers/show-swarm-containers.tsx`
  Main component: data fetching, error/empty states, summary cards,
  and the node-grouped container layout.

- `apps/dokploy/components/dashboard/swarm/containers/node-section.tsx`
  Collapsible per-node section with container table, role badge,
  and down-node indicator.

- `apps/dokploy/components/dashboard/swarm/containers/container-row.tsx`
  Table row for a single container: state badge with error tooltip,
  formatted CPU/memory/IO metrics.

- `apps/dokploy/components/dashboard/swarm/containers/utils.ts`
  Formatting helpers for docker stats values (CPU %, memory, I/O).

- `apps/dokploy/components/dashboard/swarm/containers/types.ts`
  Shared TypeScript interfaces (ContainerStat, ContainerInfo, SwarmNode,
  NodeGroup).

## Modified Files

- `apps/dokploy/pages/dashboard/swarm.tsx`
  Added Tabs (Overview / Containers) wrapping existing SwarmMonitorCard
  and the new ShowSwarmContainers component.

- `apps/dokploy/server/api/routers/swarm.ts`
  Added `getContainerStats` tRPC endpoint calling `getAllContainerStats`,
  following existing auth/validation patterns.

- `packages/server/src/services/docker.ts`
  - Added `getAllContainerStats()` — runs `docker stats --no-stream` for
    cluster-wide container metrics.
  - Fixed `getSwarmNodes`, `getNodeApplications`, `getApplicationInfo` to
    return `[]` instead of `undefined` on errors (prevents tRPC
    serialization crashes) and added `console.error` logging.
  - Added empty stdout guard (`if (!stdout.trim()) return []`) to prevent
    `JSON.parse("")` crashes when no services exist.

## Features

- Container table per node: name, image, state, CPU %, memory usage,
  block I/O, and network I/O
- Resource formatting: values rounded to 1 decimal (2.711MiB → 2.7 MiB),
  CPU to 1 decimal (0.00% → 0.0%)
- Node role badges (Leader / Reachable / Worker) on each section header
- Error tooltips: hover the status badge to see Docker error details
- Down/drained node detection with red indicator dot and warning banner
- Multi-node metrics banner explaining docker stats manager-only limitation
- Unscheduled services footer for services scaled to 0 replicas
- Contextual empty/error states with actionable guidance, doc links to
  Dokploy docs and Docker Swarm guide, and links to Cluster Settings

## Edge Cases Handled

1. Swarm not initialized (tRPC error or undefined data)
2. Docker command failures (stderr / non-zero exit)
3. Swarm active but no services deployed
4. Services exist but no running containers
5. Containers with Docker errors (shown in tooltip + error alert)
6. Nodes down or drained (cross-referenced from node list)
7. Multi-node setups (metrics only from manager node)
8. Services scaled to 0 replicas (separated from running containers)
9. Empty stdout from docker commands (no JSON.parse crash)
2026-02-07 19:19:07 +00:00
Statsly-org 0a4becb614 refactor: move icon tab content into ShowIconSettings component 2026-02-06 22:41:27 +01:00
Statsly-org e85adaedbd chore: remove Under Development notice from icon tab 2026-02-06 18:16:05 +01:00
Statsly-org 32657499ab feat: add icon upload functionality for applications 2026-02-06 18:00:49 +01:00
Othman Haba (ง'̀-'́)ง 67899c762c fix(postgres): set default StopGracePeriod to 30 seconds if not provided 2026-02-06 03:37:34 +02:00
Mauricio Siu 4f578516d6 Merge pull request #3570 from Dokploy/canary
🚀 Release v0.26.7
2026-01-31 05:06:50 -06:00
Hootan 68f6d4a558 Merge branch 'canary' into feat/add-mattermost-notification-provider
Resolves merge conflicts between mattermost notification provider (this PR)
and pushover/custom/lark notification providers (from canary).

All notification providers are now included:
- slack, telegram, discord, email, gotify, ntfy
- mattermost (this PR)
- pushover, custom, lark (from canary)
2026-01-30 23:51:09 +01:00
Mauricio Siu 1e57d48ab4 Merge pull request #3499 from Dokploy/canary
🚀 Release v0.26.6
2026-01-27 13:42:24 -06:00
Mauricio Siu a177d34dfd Merge pull request #3456 from Dokploy/canary
🚀 Release v0.26.5
2026-01-15 09:25:26 -06:00
Mauricio Siu 1034c79245 Merge pull request #3442 from Dokploy/canary
🚀 Release v0.26.4
2026-01-15 01:55:28 -06:00
stripsior ab69d782c7 fix: remove default from mariadb, make mongo 8 default 2026-01-03 17:53:10 +01:00
stripsior 405fc69df4 chore(databases): update mongodb version, to patch latest cve 2026-01-03 15:30:45 +01:00
Mauricio Siu 304454b22d Merge pull request #3312 from Dokploy/canary
🚀 Release v0.26.3
2026-01-01 22:37:09 -06:00
David Eiber ce5ad35981 feat: add middlewares to domains 2026-01-01 16:18:40 +01:00
autofix-ci[bot] c66902fb96 [autofix.ci] apply automated fixes 2025-12-26 13:03:51 +00:00
Bima42 70776ba8ca feat: be able to edit certificate 2025-12-26 14:00:58 +01:00
Bima42 2df1b42540 fix: update regex to allow number at the end of app name 2025-12-26 13:26:23 +01:00
quochuydev 48902c488f feat: add grid/table view toggle for domains page 2025-12-17 18:48:19 +07:00
faytranevozter e575e50979 feat(certificates): enhance certificate handling with common name extraction and chain details 2025-12-16 20:14:31 +07:00
autofix-ci[bot] efedec70d6 [autofix.ci] apply automated fixes 2025-12-15 21:02:33 +00:00
mkarpats 8d11fb4ee8 chore: update baseDomain used in host-rule-format tests 2025-12-15 14:39:48 +02:00
Mauricio Siu 42c2076281 Merge pull request #3254 from Dokploy/canary
🚀 Release v0.26.2
2025-12-13 01:41:50 -06:00
mkarpats b7f7027280 Merge branch 'refs/heads/canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0131_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-12-12 18:26:29 +02:00
Mauricio Siu 5cd7de8188 Merge pull request #3211 from Dokploy/canary
🚀 Release v0.26.1
2025-12-10 00:47:12 -06:00
mkarpats 5d078f1d9f Merge remote-tracking branch 'refs/remotes/origin/canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0130_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-12-09 12:07:46 +02:00
Mauricio Siu 1352b859e2 Merge pull request #3166 from Dokploy/canary
🚀 Release v0.26.0
2025-12-08 14:37:15 -06:00
Mauricio Siu ac27aa1bba feat(migration): add customEntrypoint column to domain table
- Introduced a new SQL migration script `0130_abandoned_dagger.sql` to add the `customEntrypoint` column to the `domain` table.
- Updated the journal entry in `_journal.json` to reflect this new migration.
- Created a snapshot file `0130_snapshot.json` to capture the current state of the database schema after this migration.
2025-12-07 20:46:50 -06:00
Mauricio Siu 6a79ce8ff1 Merge branch 'canary' into feature/custom-entrypoint 2025-12-07 20:46:32 -06:00
Mauricio Siu bf226f1af1 chore: remove unused SQL script and journal entry for sleepy aqueduct migration
- Deleted the SQL script `0122_sleepy_aqueduct.sql` which added a `customEntrypoint` column to the `domain` table.
- Removed the corresponding journal entry from `_journal.json` to clean up migration history.
- Deleted the snapshot file `0122_snapshot.json` as it is no longer needed.
2025-12-07 20:45:45 -06:00
Mauricio Siu 1c2307b86f Merge pull request #3114 from Dokploy/canary
🚀 Release v0.25.11
2025-11-26 03:41:51 -05:00
mkarpats 6b117551ae fix: add middlewares (stipPath and/or internalPath) when using custom entry point 2025-11-22 18:56:39 +02:00
mkarpats 8c1153370c Merge branch 'refs/heads/canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0120_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-11-22 18:54:36 +02:00
Mauricio Siu 4832fd929c Merge pull request #3072 from Dokploy/canary
🚀 Release v0.25.10
2025-11-20 08:59:37 -06:00
Mauricio Siu d1b639a55a Merge pull request #3063 from Dokploy/canary
🚀 Release v0.25.9
2025-11-19 23:10:38 -06:00
Mauricio Siu 40de13e4d4 Merge pull request #3055 from Dokploy/canary
🚀 Release v0.25.8
2025-11-19 02:45:33 -06:00
Mauricio Siu f0ea1c8796 Merge pull request #3043 from Dokploy/canary
🚀 Release v0.25.7
2025-11-18 23:01:38 -06:00
autofix-ci[bot] 09dd7cc938 [autofix.ci] apply automated fixes 2025-11-09 20:02:23 +00:00
Bima42 eae83674b0 chore: regenerate migration after merge 2025-11-09 21:02:01 +01:00
Bima42 d1ebc133aa chore: regenerate migrations 2025-11-09 20:52:48 +01:00
Bima42 d29fe437b9 fix: typo in modal, unify with bookmark 2025-11-09 20:51:23 +01:00
Bima42 27ad851d45 feat(ui): update template modal to use bookmarks 2025-11-09 20:51:23 +01:00
Bima42 42f8773c05 feat(api): add routes for template bookmarks 2025-11-09 20:51:23 +01:00
Bima42 5eef844e5f feat(db): add user bookmark table and migrations 2025-11-09 20:51:20 +01:00
mkarpats 21fa21e9c0 Merge branch 'canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0119_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-11-05 21:14:11 +02:00
autofix-ci[bot] 14dafa9a8a [autofix.ci] apply automated fixes 2025-11-05 06:27:27 +00:00
HarikrishnanD c5eb31ab90 feat: add web UI file upload to Docker containers 2025-11-05 11:55:40 +05:30
autofix-ci[bot] d6704dbd27 [autofix.ci] apply automated fixes 2025-11-04 10:38:15 +00:00
HarikrishnanD dcdbed047b fix: change backup file naming to Windows-compatible format 2025-11-04 16:06:01 +05:30
Léo FILMON c362b2c558 feat: add password manager compatible OTP input component
- Redesigned InputOTP component with modern visual boxes
- Added native password manager support (Dashlane, 1Password, etc.)
- Implemented automatic cursor movement on input/delete
- Removed legacy slot-based components (InputOTPGroup, InputOTPSlot, InputOTPSeparator)
- Updated login page and 2FA setup to use new component
- Enhanced UX with hover effects, focus states, and filled state styling

The new InputOTP component uses a hidden native input with autoComplete='one-time-code'
for password manager compatibility while displaying modern rounded boxes with smooth
animations and visual feedback.
2025-11-04 01:43:09 +00:00
autofix-ci[bot] 970905198b [autofix.ci] apply automated fixes 2025-10-28 23:00:17 +01:00
Hootan a0c87358eb feat: add mattermost notification provider
Add comprehensive Mattermost integration as a new notification provider:

## Backend Implementation:
- Add `mattermost` to notificationType enum and database schema
- Create mattermost table with webhookUrl, channel, username fields
- Implement CRUD operations: createMattermostNotification, updateMattermostNotification
- Add API routes: createMattermost, updateMattermost, testMattermostConnection
- Add sendMattermostNotification utility with proper payload formatting

## Frontend Implementation:
- Add MattermostIcon component with provided SVG logo
- Extend notification form with Mattermost schema validation
- Add webhook URL (required), channel and username (optional) form fields
- Integrate test connection functionality
- Add Mattermost to provider selection UI

## Notification Integration:
- Integrate across all notification types:
  - Build success/error notifications
  - Database backup notifications
  - Docker cleanup notifications
  - Dokploy restart notifications
  - Server threshold alerts
- Format messages using Markdown for Mattermost compatibility
- Handle optional channel (#prefix) and username override
- Graceful fallback for empty optional fields

## Features:
- Webhook-based messaging to Mattermost channels
- Optional channel targeting and custom username display
- Consistent formatting with other notification providers
- Full CRUD support with proper validation
- Test connection capability

Closes: Support for Mattermost team communication platform

# Conflicts:
#	apps/dokploy/components/dashboard/settings/notifications/handle-notifications.tsx
#	apps/dokploy/components/icons/notification-icons.tsx
#	apps/dokploy/server/api/routers/notification.ts
#	packages/server/src/db/schema/notification.ts
#	packages/server/src/services/notification.ts
#	packages/server/src/utils/notifications/build-error.ts
#	packages/server/src/utils/notifications/build-success.ts
#	packages/server/src/utils/notifications/database-backup.ts
#	packages/server/src/utils/notifications/docker-cleanup.ts
#	packages/server/src/utils/notifications/dokploy-restart.ts
#	packages/server/src/utils/notifications/server-threshold.ts
#	packages/server/src/utils/notifications/utils.ts
2025-10-28 22:50:04 +01:00
WalidDevIO 91a385c302 feat[notifications]: Add dokployBackup notification type support
This commit adds support for the dokployBackup notification type across all relevant services and schemas.
2025-10-27 22:43:45 +01:00
mkarpats 9627af9cda Merge branch 'canary' into feature/custom-entrypoint
# Conflicts:
#	apps/dokploy/drizzle/meta/0117_snapshot.json
#	apps/dokploy/drizzle/meta/_journal.json
2025-10-26 14:31:05 +02:00
Mauricio Siu b45e7e415c Merge pull request #2901 from Dokploy/canary
🚀 Release v0.25.6
2025-10-26 02:14:56 -06:00
autofix-ci[bot] 90bd276ad4 [autofix.ci] apply automated fixes 2025-10-25 07:11:54 +00:00
mkarpats 84d311802f feat: add support for custom entry point 2025-10-19 21:22:06 +03:00
Mauricio Siu 67d3e92aaf Merge pull request #2765 from Dokploy/canary
🚀 Release v0.25.5
2025-10-05 23:06:46 -06:00
Michał Kowal 8ee38a1463 Merge branch 'canary' into 2731-wrong-extension-for-mongo-backup-file 2025-10-05 13:02:16 -06:00
Michał Kowal e726bf31f6 Fix +n backup keep functionality 2025-10-05 13:02:00 -06:00
Michał f4248760a8 Update documentation 2025-10-04 19:22:19 -06:00
Michał Kowal b715e21236 Remove .sql for mongo backup file name 2025-10-03 17:54:31 -06:00
Mauricio Siu 76af74d8aa Merge pull request #2721 from Dokploy/canary
🚀 Release v0.25.4
2025-09-29 23:06:29 -06:00
Mauricio Siu b15ede8877 Merge pull request #2658 from Dokploy/canary
🚀 Release v0.25.3
2025-09-21 16:25:37 -06:00
Mauricio Siu ea805c1520 Merge pull request #2612 from Dokploy/canary
🚀 Release v0.25.2
2025-09-15 23:44:43 -06:00
Mauricio Siu 976932fb03 Merge pull request #2557 from Dokploy/canary
🚀 Release v0.25.1
2025-09-07 14:03:25 -06:00
Mauricio Siu ac8960efdd Merge pull request #2483 from Dokploy/canary
🚀 Release v0.25.0
2025-09-06 22:41:45 -06:00
Mauricio Siu d6050ce05a Merge pull request #2408 from Dokploy/canary
🚀 Release v0.24.12
2025-08-19 00:44:43 -06:00
Mauricio Siu 5a46b879f5 Merge pull request #2390 from Dokploy/canary
🚀 Release v0.24.11
2025-08-17 15:26:03 -06:00
Mauricio Siu 222e4878bd Merge pull request #2360 from Dokploy/canary
🚀 Release v0.24.10
2025-08-10 23:28:58 -06:00
Mauricio Siu fd267a64de Merge pull request #2354 from Dokploy/canary
🚀 Release v0.24.9
2025-08-10 06:13:01 -06:00
Mauricio Siu fa3cdf148b Merge pull request #2324 from Dokploy/canary
🚀 Release v0.24.8
2025-08-04 00:28:40 -06:00
Mauricio Siu 74caf141f4 Merge pull request #2323 from Dokploy/canary
🚀 Release v0.24.7
2025-08-03 18:58:05 -06:00
Mauricio Siu 8b7d9c0896 Merge pull request #2303 from Dokploy/canary
🚀 Release v0.24.6
2025-08-03 02:42:22 -06:00
Mauricio Siu 13e20e9ef8 Merge pull request #2253 from Dokploy/canary
🚀 Release v0.24.5
2025-07-28 02:15:50 -06:00
Mauricio Siu f9b0589070 Merge pull request #2219 from Dokploy/canary
🚀 Release v0.24.4
2025-07-20 20:40:02 -06:00
Mauricio Siu b615d04ad2 Merge pull request #2193 from Dokploy/canary
🚀 Release v0.24.3
2025-07-13 23:44:03 -06:00
Mauricio Siu 6c4efa48b1 Merge pull request #2191 from Dokploy/canary
🚀 Release v0.24.2
2025-07-13 20:46:50 -06:00
Mauricio Siu 85d48aba2b Merge pull request #2183 from Dokploy/canary
🚀 Release v0.24.1
2025-07-13 13:57:09 -06:00
Mauricio Siu 3b138f8e8a Merge pull request #2143 from Dokploy/canary
🚀 Release v0.24.0
2025-07-06 21:40:17 -06:00
Mauricio Siu b91067dc2a Merge pull request #2126 from Dokploy/canary
🚀 Release v0.23.7
2025-07-05 01:59:04 -06:00
Mauricio Siu 335a16b915 Merge pull request #2114 from Dokploy/canary
🚀 Release v0.23.6
2025-07-01 23:31:59 -06:00
Mauricio Siu 274f38029c Merge pull request #2103 from Dokploy/canary
🚀 Release v0.23.5
2025-06-29 14:14:02 -06:00
Mauricio Siu 4cbc91d3d0 Merge pull request #2091 from Dokploy/canary
🚀 Release v0.23.4
2025-06-27 00:09:51 -06:00
Mauricio Siu 10d17de186 Merge pull request #2070 from Dokploy/canary
🚀 Release v0.23.3
2025-06-22 18:36:40 +02:00
Mauricio Siu 65f0919fa7 Merge pull request #2068 from Dokploy/canary
🚀 Release v0.23.2
2025-06-22 16:46:53 +02:00
Mauricio Siu 9b7abfbed7 Merge pull request #2063 from Dokploy/canary
🚀 Release v0.23.1
2025-06-22 08:58:23 +02:00
Mauricio Siu 6676a86b34 Merge pull request #2061 from Dokploy/canary
🚀 Release v0.23.0
2025-06-22 08:27:01 +02:00
Mauricio Siu d603654ac1 Merge pull request #1965 from Dokploy/canary
🚀 Release v0.22.7
2025-05-28 02:52:40 -06:00
Mauricio Siu d9ffe519b0 Merge pull request #1920 from Dokploy/canary
🚀 Release v0.22.6
2025-05-18 02:32:42 -06:00
Mauricio Siu fa91a74462 Merge pull request #1911 from Dokploy/canary
🚀 Release v0.22.5
2025-05-17 16:19:10 -06:00
Mauricio Siu d7794286be Merge pull request #1871 from Dokploy/canary
🚀 Release v0.22.4
2025-05-10 20:59:48 -06:00
Mauricio Siu f337dd7e01 Merge pull request #1847 from Dokploy/canary
🚀 Release v0.22.3
2025-05-06 23:57:22 -06:00
Mauricio Siu 5d5d95bbd3 Merge pull request #1836 from Dokploy/canary
🚀 Release v0.22.2
2025-05-06 02:56:01 -06:00
Mauricio Siu 7be1084a10 Merge pull request #1828 from Dokploy/canary
🚀 Release v0.22.1
2025-05-05 03:10:22 -06:00
Mauricio Siu 19a525fac1 Merge pull request #1824 from Dokploy/canary
🚀 Release v0.22.0
2025-05-04 23:22:21 -06:00
Mauricio Siu 7984497398 Merge pull request #1785 from Dokploy/canary
🚀 Release v0.21.8
2025-04-27 00:18:55 -06:00
435 changed files with 168670 additions and 3352 deletions
+42
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.
+79
View File
@@ -138,6 +138,8 @@ jobs:
needs: [combine-manifests]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -160,3 +162,80 @@ jobs:
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
sync-version:
needs: [generate-release]
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Sync version to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
cd /tmp/mcp-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
npm install -g pnpm
pnpm install
pnpm run fetch-openapi
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ MCP repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
cd /tmp/cli-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ CLI repo synced to version ${{ needs.generate-release.outputs.version }}"
- name: Sync version to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
cd /tmp/sdk-repo
jq --arg v "${{ needs.generate-release.outputs.version }}" '.version = $v' package.json > package.json.tmp
mv package.json.tmp package.json
cp ${{ github.workspace }}/openapi.json ./openapi.json
npm install -g pnpm
pnpm install
pnpm run generate
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add -A
git commit -m "chore: bump version to ${{ needs.generate-release.outputs.version }}" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
--allow-empty
git push
echo "✅ SDK repo synced to version ${{ needs.generate-release.outputs.version }}"
-22
View File
@@ -1,22 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
max-failures: 4
blocked-commit-authors: "claude,copilot"
require-description: true
min-account-age: 5
+63
View File
@@ -68,3 +68,66 @@ jobs:
echo "✅ OpenAPI synced to website successfully"
- name: Sync to MCP repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git mcp-repo
cd mcp-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to MCP repository successfully"
- name: Sync to CLI repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git cli-repo
cd cli-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to CLI repository successfully"
- name: Sync to SDK repository
run: |
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
cd sdk-repo
cp -f ../openapi.json openapi.json
git config user.name "Dokploy Bot"
git config user.email "bot@dokploy.com"
git add openapi.json
git commit -m "chore: sync OpenAPI specification [skip ci]" \
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
--allow-empty
git push
echo "✅ OpenAPI synced to SDK repository successfully"
+3
View File
@@ -4,5 +4,8 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
}
}
+8 -1
View File
@@ -99,7 +99,14 @@ pnpm run dokploy:build
## Docker
To build the docker image
To build the docker image first run commands to copy .env files
```bash
cp apps/dokploy/.env.production.example .env.production
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
```
then run build command
```bash
pnpm run docker:build
+1 -1
View File
@@ -66,7 +66,7 @@ COPY --from=buildpacksio/pack:0.39.1 /usr/local/bin/pack /usr/local/bin/pack
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --retries=10 \
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
+1 -1
View File
@@ -39,7 +39,7 @@ To get started, run the following command on a VPS:
Want to skip the installation process? [Try the Dokploy Cloud](https://app.dokploy.com).
```bash
curl -sSL https://dokploy.com/install.sh | sh
curl -sSL https://dokploy.com/install.sh | bash
```
For detailed documentation, visit [docs.dokploy.com](https://docs.dokploy.com).
@@ -0,0 +1,52 @@
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
import { describe, expect, it, vi } from "vitest";
// Isolate the command builder from the compose-file I/O performed by
// writeDomainsToCompose; we only care about the docker invocation it emits.
vi.mock("@dokploy/server/utils/docker/domain", () => ({
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
}));
const baseCompose = {
appName: "my-app",
sourceType: "raw",
command: "",
composePath: "docker-compose.yml",
composeType: "stack",
isolatedDeployment: false,
randomize: false,
suffix: "",
serverId: null,
env: "",
mounts: [],
domains: [],
environment: { project: { env: "" }, env: "" },
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
// Regression coverage for #4401: the deploy command runs under `env -i`, which
// clears the environment except for the vars listed explicitly. HOME must be
// preserved so docker can resolve ~/.docker/config.json — otherwise
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
// and private-registry images fail to pull.
describe("getBuildComposeCommand registry auth (#4401)", () => {
it("preserves HOME for swarm stack deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "stack",
});
expect(command).toContain("stack deploy");
expect(command).toContain("--with-registry-auth");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
it("preserves HOME for docker compose deploys", async () => {
const command = await getBuildComposeCommand({
...baseCompose,
composeType: "docker-compose",
});
expect(command).toContain("compose -p my-app");
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
});
});
@@ -32,6 +32,9 @@ describe("Host rule format regression tests", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
customEntrypoint: null,
middlewares: null,
forwardAuthEnabled: false,
};
describe("Host rule format validation", () => {
@@ -7,6 +7,7 @@ describe("createDomainLabels", () => {
const baseDomain: Domain = {
host: "example.com",
port: 8080,
customEntrypoint: null,
https: false,
uniqueConfigKey: 1,
customCertResolver: null,
@@ -21,6 +22,8 @@ describe("createDomainLabels", () => {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
it("should create basic labels for web entrypoint", async () => {
@@ -101,6 +104,51 @@ describe("createDomainLabels", () => {
);
});
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "websecure");
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure.tls=true",
);
// no cert resolver should be set when relying on a default/custom cert
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
);
});
it("should not add tls=true for certificateType none on web entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
certificateType: "none" as const,
};
const labels = await createDomainLabels(appName, noneDomain, "web");
expect(labels).not.toContain(
"traefik.http.routers.test-app-1-web.tls=true",
);
});
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
const noneDomain = {
...baseDomain,
https: true,
customEntrypoint: "websecure-custom",
certificateType: "none" as const,
};
const labels = await createDomainLabels(
appName,
noneDomain,
"websecure-custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
);
});
it("should handle different ports correctly", async () => {
const customPortDomain = { ...baseDomain, port: 3000 };
const labels = await createDomainLabels(appName, customPortDomain, "web");
@@ -171,12 +219,12 @@ describe("createDomainLabels", () => {
"websecure",
);
// Web entrypoint should have both middlewares with redirect first
// Web entrypoint with HTTPS should only have redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,addprefix-test-app-1",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Websecure should only have the addprefix middleware
// Websecure should have the addprefix middleware
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=addprefix-test-app-1",
);
@@ -208,9 +256,9 @@ describe("createDomainLabels", () => {
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// Should have middlewares in correct order: redirect, stripprefix, addprefix
// Web router with HTTPS should only have redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file,stripprefix-test-app-1,addprefix-test-app-1",
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
});
@@ -240,4 +288,259 @@ describe("createDomainLabels", () => {
"traefik.http.routers.test-app-1-websecure.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should add single custom middleware to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file",
);
});
it("should add multiple custom middlewares to router", async () => {
const customMiddlewareDomain = {
...baseDomain,
middlewares: ["auth@file", "rate-limit@file"],
};
const labels = await createDomainLabels(
appName,
customMiddlewareDomain,
"web",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=auth@file,rate-limit@file",
);
});
it("should only have redirect on web router when HTTPS is enabled with custom middlewares", async () => {
const combinedDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// Web router with HTTPS should only redirect, custom middlewares go on websecure
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(labels).not.toContain("auth@file");
});
it("should combine custom middlewares with stripPath middleware (no HTTPS)", async () => {
const combinedDomain = {
...baseDomain,
path: "/api",
stripPath: true,
middlewares: ["auth@file"],
};
const labels = await createDomainLabels(appName, combinedDomain, "web");
// stripprefix should come before custom middleware
expect(labels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=stripprefix-test-app-1,auth@file",
);
});
it("should only have redirect on web router even with all built-in middlewares and custom middlewares", async () => {
const fullDomain = {
...baseDomain,
https: true,
path: "/api",
stripPath: true,
internalPath: "/hello",
middlewares: ["auth@file", "rate-limit@file"],
};
const webLabels = await createDomainLabels(appName, fullDomain, "web");
// Web router with HTTPS should only redirect
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
// Middleware definitions should still be present (Traefik needs them registered)
expect(webLabels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(webLabels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
// But they should NOT be attached to the router
expect(webLabels).not.toContain("stripprefix-test-app-1,");
expect(webLabels).not.toContain("auth@file");
expect(webLabels).not.toContain("rate-limit@file");
});
it("should include custom middlewares on websecure entrypoint", async () => {
const customMiddlewareDomain = {
...baseDomain,
https: true,
middlewares: ["auth@file"],
};
const websecureLabels = await createDomainLabels(
appName,
customMiddlewareDomain,
"websecure",
);
// Websecure should have custom middleware but not redirect-to-https
expect(websecureLabels).toContain(
"traefik.http.routers.test-app-1-websecure.middlewares=auth@file",
);
expect(websecureLabels).not.toContain("redirect-to-https");
});
it("should NOT include custom middlewares on web router when HTTPS is enabled (only redirect)", async () => {
const domain = {
...baseDomain,
https: true,
middlewares: ["rate-limit@file", "auth@file"],
};
const webLabels = await createDomainLabels(appName, domain, "web");
// Web router with HTTPS should ONLY have redirect, not custom middlewares
expect(webLabels).toContain(
"traefik.http.routers.test-app-1-web.middlewares=redirect-to-https@file",
);
expect(webLabels).not.toContain("rate-limit@file");
expect(webLabels).not.toContain("auth@file");
});
it("should create basic labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{ ...baseDomain, customEntrypoint: "custom" },
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
]);
});
it("should create https labels for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(labels).toEqual([
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`)",
"traefik.http.routers.test-app-1-custom.entrypoints=custom",
"traefik.http.services.test-app-1-custom.loadbalancer.server.port=8080",
"traefik.http.routers.test-app-1-custom.service=test-app-1-custom",
"traefik.http.routers.test-app-1-custom.tls.certresolver=letsencrypt",
]);
});
it("should add stripPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1",
);
});
it("should add internalPath middleware for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=addprefix-test-app-1",
);
});
it("should add path prefix in rule for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
},
"custom",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.rule=Host(`example.com`) && PathPrefix(`/api`)",
);
});
it("should combine all middlewares for custom entrypoint", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
internalPath: "/hello",
},
"custom",
);
expect(labels).toContain(
"traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api",
);
expect(labels).toContain(
"traefik.http.middlewares.addprefix-test-app-1.addprefix.prefix=/hello",
);
expect(labels).toContain(
"traefik.http.routers.test-app-1-custom.middlewares=stripprefix-test-app-1,addprefix-test-app-1",
);
});
it("should not add redirect-to-https for custom entrypoint even with https", async () => {
const labels = await createDomainLabels(
appName,
{
...baseDomain,
customEntrypoint: "custom",
https: true,
certificateType: "letsencrypt",
},
"custom",
);
const middlewareLabel = labels.find((l) => l.includes(".middlewares="));
// Should not contain redirect-to-https since there's only one router
expect(middlewareLabel).toBeUndefined();
});
});
@@ -292,7 +292,7 @@ networks:
dokploy-network:
`;
test("It shoudn't add suffix to dokploy-network", () => {
test("It shouldn't add suffix to dokploy-network", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -195,7 +195,7 @@ services:
- dokploy-network
`;
test("It shoudn't add suffix to dokploy-network in services", () => {
test("It shouldn't add suffix to dokploy-network in services", () => {
const composeData = parse(composeFile7) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -241,10 +241,10 @@ services:
dokploy-network:
aliases:
- apid
`;
test("It shoudn't add suffix to dokploy-network in services multiples cases", () => {
test("It shouldn't add suffix to dokploy-network in services multiples cases", () => {
const composeData = parse(composeFile8) as ComposeSpecification;
const suffix = generateRandomHash();
@@ -415,5 +415,24 @@ describe("Docker Image Name and Tag Extraction", () => {
expect(extractImageTag("my-image:123")).toBe("123");
expect(extractImageTag("my-image:1")).toBe("1");
});
it("should return 'latest' for registry with port but no tag", () => {
expect(extractImageTag("registry.example.com:5000/myimage")).toBe(
"latest",
);
expect(extractImageTag("registry:5000/fedora/httpd")).toBe("latest");
expect(extractImageTag("localhost:5000/myapp")).toBe("latest");
expect(extractImageTag("my-registry.io:443/org/app")).toBe("latest");
});
it("should extract tag from registry with port and tag", () => {
expect(extractImageTag("registry:5000/image:tag")).toBe("tag");
expect(extractImageTag("registry.example.com:5000/myimage:v2.0")).toBe(
"v2.0",
);
expect(extractImageTag("localhost:5000/app:sha-abc123")).toBe(
"sha-abc123",
);
});
});
});
@@ -0,0 +1,41 @@
import { shouldDeploy } from "@dokploy/server";
import { describe, expect, it } from "vitest";
describe("shouldDeploy", () => {
it("should deploy when no watch paths are configured", () => {
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
});
it("should deploy when watch paths match modified files", () => {
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
});
it("should not deploy when watch paths do not match", () => {
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
});
it("should not throw when modified files contain non-string values", () => {
expect(() =>
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).not.toThrow();
expect(
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
).toBe(true);
});
it("should not throw when modified files are undefined or null", () => {
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
expect(shouldDeploy(["src/**"], null)).toBe(false);
});
it("should not throw when every modified file is non-string", () => {
expect(() =>
shouldDeploy(["src/**"], [undefined, undefined] as any),
).not.toThrow();
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
});
});
+1
View File
@@ -120,6 +120,7 @@ const baseApp: ApplicationNested = {
environmentId: "",
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
+10 -10
View File
@@ -1,4 +1,4 @@
import { getEnviromentVariablesObject } from "@dokploy/server/index";
import { getEnvironmentVariablesObject } from "@dokploy/server/index";
import { describe, expect, it } from "vitest";
const projectEnv = `
@@ -15,7 +15,7 @@ DATABASE_NAME=dev_database
SECRET_KEY=env-secret-123
`;
describe("getEnviromentVariablesObject with environment variables (Stack compose)", () => {
describe("getEnvironmentVariablesObject with environment variables (Stack compose)", () => {
it("resolves environment variables correctly for Stack compose", () => {
const serviceEnv = `
FOO=\${{environment.NODE_ENV}}
@@ -23,7 +23,7 @@ BAR=\${{environment.API_URL}}
BAZ=test
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -45,7 +45,7 @@ DATABASE_URL=\${{project.DATABASE_URL}}
SERVICE_PORT=4000
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceEnv,
projectEnv,
environmentEnv,
@@ -72,7 +72,7 @@ PASSWORD=secret123
DATABASE_URL=postgresql://\${{environment.USERNAME}}:\${{environment.PASSWORD}}@\${{environment.HOST}}:\${{environment.PORT}}/mydb
`;
const result = getEnviromentVariablesObject(serviceEnv, "", multiRefEnv);
const result = getEnvironmentVariablesObject(serviceEnv, "", multiRefEnv);
expect(result).toEqual({
DATABASE_URL: "postgresql://postgres:secret123@localhost:5432/mydb",
@@ -85,7 +85,7 @@ UNDEFINED_VAR=\${{environment.UNDEFINED_VAR}}
`;
expect(() =>
getEnviromentVariablesObject(serviceWithUndefined, "", environmentEnv),
getEnvironmentVariablesObject(serviceWithUndefined, "", environmentEnv),
).toThrow("Invalid environment variable: environment.UNDEFINED_VAR");
});
@@ -95,7 +95,7 @@ NODE_ENV=production
API_URL=\${{environment.API_URL}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceOverrideEnv,
"",
environmentEnv,
@@ -115,7 +115,7 @@ SERVICE_NAME=my-service
COMPLEX_VAR=\${{SERVICE_NAME}}-\${{environment.NODE_ENV}}-\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
complexServiceEnv,
projectEnv,
environmentEnv,
@@ -150,7 +150,7 @@ ENV_VAR=\${{environment.API_URL}}
DB_NAME=\${{environment.DATABASE_NAME}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceWithConflicts,
conflictingProjectEnv,
conflictingEnvironmentEnv,
@@ -170,7 +170,7 @@ SERVICE_VAR=test
PROJECT_VAR=\${{project.ENVIRONMENT}}
`;
const result = getEnviromentVariablesObject(
const result = getEnvironmentVariablesObject(
serviceWithEmpty,
projectEnv,
"",
@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
canEditDeployGitSource,
getAccessibleGitProviderIds,
} from "@dokploy/server/services/git-provider";
const mockDb = vi.hoisted(() => ({
query: {
gitProvider: {
findMany: vi.fn(),
findFirst: vi.fn(),
},
member: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@dokploy/server/db", () => ({ db: mockDb }));
const mockHasValidLicense = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/proprietary/license-key", () => ({
hasValidLicense: mockHasValidLicense,
}));
const ORG_ID = "org-1";
const USER_OWNER = "user-owner";
const USER_ADMIN = "user-admin";
const USER_MEMBER = "user-member";
const USER_MEMBER_2 = "user-member-2";
const providerOwned = {
gitProviderId: "gp-owned",
userId: USER_MEMBER,
sharedWithOrganization: false,
};
const providerShared = {
gitProviderId: "gp-shared",
userId: USER_OWNER,
sharedWithOrganization: true,
};
const providerPrivate = {
gitProviderId: "gp-private",
userId: USER_OWNER,
sharedWithOrganization: false,
};
const providerOtherMember = {
gitProviderId: "gp-other",
userId: USER_MEMBER_2,
sharedWithOrganization: false,
};
const allProviders = [
providerOwned,
providerShared,
providerPrivate,
providerOtherMember,
];
function session(userId: string) {
return { userId, activeOrganizationId: ORG_ID };
}
beforeEach(() => {
vi.clearAllMocks();
mockDb.query.gitProvider.findMany.mockResolvedValue(allProviders);
mockHasValidLicense.mockResolvedValue(false);
});
describe("getAccessibleGitProviderIds", () => {
describe("owner", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "owner",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_OWNER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns all org providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids).toEqual(new Set(allProviders.map((p) => p.gitProviderId)));
});
it("includes providers owned by other members — fixes issue #4469", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(true);
});
});
describe("member without enterprise license", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
mockHasValidLicense.mockResolvedValue(false);
});
it("can access their own provider", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("can access shared providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("cannot access private providers of other users even if assigned (no license)", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
it("cannot access providers of other members", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with enterprise license", () => {
beforeEach(() => {
mockHasValidLicense.mockResolvedValue(true);
});
it("can access provider explicitly assigned to them", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("cannot access provider not assigned and not shared", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
it("can access shared provider even without explicit assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerShared.gitProviderId)).toBe(true);
});
it("can access own provider regardless of assignments", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
});
it("cannot access provider of other member even with license but no assignment", async () => {
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOtherMember.gitProviderId)).toBe(false);
});
});
describe("member with no member record", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue(null);
mockHasValidLicense.mockResolvedValue(true);
});
it("only returns own providers and shared ones", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerOwned.gitProviderId)).toBe(true);
expect(ids.has(providerShared.gitProviderId)).toBe(true);
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("enterprise license — member assigned to a provider they do not own", () => {
// getAccessibleGitProviderIds still returns the provider (member can connect NEW deploys)
it("member assigned to owner's private provider can USE the provider for new deploys", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [providerPrivate.gitProviderId],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(true);
});
it("member NOT assigned to owner's private provider cannot use it at all", async () => {
mockHasValidLicense.mockResolvedValue(true);
mockDb.query.member.findFirst.mockResolvedValue({
role: "member",
accessedGitProviders: [],
});
const ids = await getAccessibleGitProviderIds(session(USER_MEMBER));
expect(ids.has(providerPrivate.gitProviderId)).toBe(false);
});
});
describe("empty org", () => {
beforeEach(() => {
mockDb.query.gitProvider.findMany.mockResolvedValue([]);
mockDb.query.member.findFirst.mockResolvedValue({
role: "admin",
accessedGitProviders: [],
});
});
it("returns empty set when org has no providers", async () => {
const ids = await getAccessibleGitProviderIds(session(USER_ADMIN));
expect(ids.size).toBe(0);
});
});
});
describe("canEditDeployGitSource", () => {
beforeEach(() => {
vi.clearAllMocks();
mockHasValidLicense.mockResolvedValue(true);
});
describe("owner", () => {
it("can edit deploy using any provider", async () => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "owner" });
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_OWNER),
);
expect(result).toBe(true);
});
});
describe("admin", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "admin" });
});
it("cannot edit deploy using owner's private provider (not shared)", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(false);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_ADMIN),
);
expect(result).toBe(true);
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_ADMIN,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
"gp-admin-owned",
session(USER_ADMIN),
);
expect(result).toBe(true);
});
});
describe("member", () => {
beforeEach(() => {
mockDb.query.member.findFirst.mockResolvedValue({ role: "member" });
});
it("can edit deploy using their own provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOwned.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("can edit deploy using a provider shared with the org", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: true,
});
const result = await canEditDeployGitSource(
providerShared.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(true);
});
it("cannot edit deploy using owner's private provider even with enterprise license and assignment", async () => {
// This is the key case: enterprise, provider del owner, no compartido,
// member tiene accessedGitProviders asignado — pero NO puede cambiar la branch del deploy del owner
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_OWNER,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerPrivate.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("cannot edit deploy using another member's private provider", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue({
userId: USER_MEMBER_2,
sharedWithOrganization: false,
});
const result = await canEditDeployGitSource(
providerOtherMember.gitProviderId,
session(USER_MEMBER),
);
expect(result).toBe(false);
});
it("returns false if provider does not exist", async () => {
mockDb.query.gitProvider.findFirst.mockResolvedValue(null);
const result = await canEditDeployGitSource(
"nonexistent-id",
session(USER_MEMBER),
);
expect(result).toBe(false);
});
});
});
@@ -58,7 +58,7 @@ beforeEach(() => {
vi.clearAllMocks();
});
describe("static roles bypass enterprise resources", () => {
describe("owner and admin bypass enterprise resources", () => {
it("owner bypasses deployment.read", async () => {
memberToReturn = mockMemberData("owner");
await expect(
@@ -73,15 +73,8 @@ describe("static roles bypass enterprise resources", () => {
).resolves.toBeUndefined();
});
it("member bypasses schedule.delete", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { schedule: ["delete"] }),
).resolves.toBeUndefined();
});
it("member bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("member");
it("owner bypasses multiple enterprise permissions at once", async () => {
memberToReturn = mockMemberData("owner");
await expect(
checkPermission(ctx, {
deployment: ["read"],
@@ -92,6 +85,55 @@ describe("static roles bypass enterprise resources", () => {
});
});
describe("member is denied org-level enterprise resources (CVE: bypass via staticRoles)", () => {
it("member is denied registry.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { registry: ["read"] }),
).rejects.toThrow();
});
it("member is denied certificate.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { certificate: ["read"] }),
).rejects.toThrow();
});
it("member is denied destination.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { destination: ["read"] }),
).rejects.toThrow();
});
it("member is denied notification.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { notification: ["read"] }),
).rejects.toThrow();
});
it("member is denied auditLog.read", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { auditLog: ["read"] }),
).rejects.toThrow();
});
it("member is denied server.read", async () => {
memberToReturn = mockMemberData("member");
await expect(checkPermission(ctx, { server: ["read"] })).rejects.toThrow();
});
it("member is denied registry.create", async () => {
memberToReturn = mockMemberData("member");
await expect(
checkPermission(ctx, { registry: ["create"] }),
).rejects.toThrow();
});
});
describe("static roles validate free-tier resources", () => {
it("owner passes project.create", async () => {
memberToReturn = mockMemberData("owner");
@@ -1,8 +1,8 @@
import { describe, it, expect } from "vitest";
import {
enterpriseOnlyResources,
statements,
} from "@dokploy/server/lib/access-control";
import { describe, expect, it } from "vitest";
const FREE_TIER_RESOURCES = [
"organization",
@@ -57,7 +57,7 @@ const createApplication = (
env: null,
},
replicas: 1,
stopGracePeriodSwarm: 0n,
stopGracePeriodSwarm: 0,
ulimitsSwarm: null,
serverId: "server-id",
...overrides,
@@ -76,8 +76,8 @@ describe("mechanizeDockerContainer", () => {
});
});
it("converts bigint stopGracePeriodSwarm to a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0n });
it("passes stopGracePeriodSwarm as a number and keeps zero values", async () => {
const application = createApplication({ stopGracePeriodSwarm: 0 });
await mechanizeDockerContainer(application);
@@ -494,4 +494,49 @@ describe("processTemplate", () => {
expect(result.mounts).toHaveLength(1);
});
});
describe("isolated deployment config", () => {
it("should default to isolated=true when not specified", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
domains: [],
env: {},
},
};
expect(template.config.isolated).toBeUndefined();
// undefined !== false => isolatedDeployment = true
expect(template.config.isolated !== false).toBe(true);
});
it("should be isolated when isolated=true is explicitly set", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: true,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(true);
});
it("should disable isolated deployment when isolated=false", () => {
const template: CompleteTemplate = {
metadata: {} as any,
variables: {},
config: {
isolated: false,
domains: [],
env: {},
},
};
expect(template.config.isolated !== false).toBe(false);
});
});
});
@@ -30,9 +30,7 @@ describe("helpers functions", () => {
const domain = processValue("${domain}", {}, mockSchema);
expect(domain.startsWith(`${mockSchema.projectName}-`)).toBeTruthy();
expect(
domain.endsWith(
`${mockSchema.serverIp.replaceAll(".", "-")}.traefik.me`,
),
domain.endsWith(`${mockSchema.serverIp.replaceAll(".", "-")}.sslip.io`),
).toBeTruthy();
});
});
@@ -0,0 +1,233 @@
import type { ApplicationNested, Domain } from "@dokploy/server";
import {
buildForwardAuthEnv,
createRouterConfig,
deriveBaseDomain,
deriveCookieSecret,
forwardAuthCallbackUrl,
forwardAuthMiddlewareName,
} from "@dokploy/server";
import { beforeAll, describe, expect, test } from "vitest";
const app = {
appName: "my-app",
redirects: [],
security: [],
} as unknown as ApplicationNested;
const baseDomain: Domain = {
applicationId: "app-1",
certificateType: "none",
createdAt: "",
domainId: "domain-1",
host: "app.example.com",
https: false,
path: null,
port: 3000,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
domainType: "application",
uniqueConfigKey: 7,
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
describe("forwardAuthMiddlewareName", () => {
test("is stable and unique per app + uniqueConfigKey", () => {
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
"forward-auth-my-app-7",
);
expect(forwardAuthMiddlewareName("my-app", 7)).toBe(
forwardAuthMiddlewareName("my-app", 7),
);
expect(forwardAuthMiddlewareName("my-app", 7)).not.toBe(
forwardAuthMiddlewareName("my-app", 8),
);
});
});
describe("createRouterConfig forward-auth wiring", () => {
test("does NOT add forward-auth middleware when no provider is linked", async () => {
const config = await createRouterConfig(app, baseDomain, "websecure");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("adds forward-auth middleware when a provider is linked", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "websecure");
expect(config.middlewares).toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
test("forward-auth runs before custom domain middlewares", async () => {
const domain: Domain = {
...baseDomain,
forwardAuthEnabled: true,
middlewares: ["rate-limit@file"],
};
const config = await createRouterConfig(app, domain, "websecure");
const forwardAuthIdx = config.middlewares?.indexOf(
forwardAuthMiddlewareName("my-app", 7),
);
const customIdx = config.middlewares?.indexOf("rate-limit@file");
expect(forwardAuthIdx).toBeGreaterThanOrEqual(0);
expect(customIdx).toBeGreaterThan(forwardAuthIdx as number);
});
test("redirect-only web router does not get the forward-auth middleware", async () => {
const domain: Domain = {
...baseDomain,
https: true,
forwardAuthEnabled: true,
};
const config = await createRouterConfig(app, domain, "web");
expect(config.middlewares).toContain("redirect-to-https");
expect(config.middlewares).not.toContain(
forwardAuthMiddlewareName("my-app", 7),
);
});
});
describe("buildForwardAuthEnv", () => {
const baseOptions = {
oidc: {
clientId: "client-123",
clientSecret: "secret-xyz",
issuer: "https://idp.example.com",
},
cookieSecret: "cookie-secret-value",
authDomain: "auth.acme.com",
baseDomain: ".acme.com",
authDomainHttps: true,
};
test("emits the required oauth2-proxy OIDC env vars", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_PROVIDER=oidc");
expect(env).toContain(
"OAUTH2_PROXY_OIDC_ISSUER_URL=https://idp.example.com",
);
expect(env).toContain("OAUTH2_PROXY_CLIENT_ID=client-123");
expect(env).toContain("OAUTH2_PROXY_CLIENT_SECRET=secret-xyz");
expect(env).toContain("OAUTH2_PROXY_COOKIE_SECRET=cookie-secret-value");
expect(env).toContain("OAUTH2_PROXY_REVERSE_PROXY=true");
expect(env).toContain("OAUTH2_PROXY_HTTP_ADDRESS=0.0.0.0:4180");
});
test("uses the central auth domain for the single fixed callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_REDIRECT_URL=https://auth.acme.com/oauth2/callback",
);
});
test("shares cookie + whitelist on the base domain (no per-app redeploy)", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_COOKIE_DOMAINS=.acme.com");
expect(env).toContain("OAUTH2_PROXY_WHITELIST_DOMAINS=.acme.com");
});
test("matches cookie Secure flag and callback scheme to https setting", () => {
const https = buildForwardAuthEnv(baseOptions);
expect(https).toContain("OAUTH2_PROXY_COOKIE_SECURE=true");
const http = buildForwardAuthEnv({
...baseOptions,
authDomainHttps: false,
});
expect(http).toContain("OAUTH2_PROXY_COOKIE_SECURE=false");
expect(http).toContain(
"OAUTH2_PROXY_REDIRECT_URL=http://auth.acme.com/oauth2/callback",
);
});
test("allows unverified emails so OIDC providers don't 500 the callback", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain(
"OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL=true",
);
});
test("defaults to any authenticated user and standard scopes", () => {
const env = buildForwardAuthEnv(baseOptions);
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=*");
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid email profile");
});
test("honors custom scopes and email domains", () => {
const env = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, scopes: ["openid", "groups"] },
emailDomains: ["acme.com", "corp.com"],
});
expect(env).toContain("OAUTH2_PROXY_SCOPE=openid groups");
expect(env).toContain("OAUTH2_PROXY_EMAIL_DOMAINS=acme.com,corp.com");
});
test("sets skip-discovery flag only when requested", () => {
const withoutSkip = buildForwardAuthEnv(baseOptions);
expect(withoutSkip).not.toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
const withSkip = buildForwardAuthEnv({
...baseOptions,
oidc: { ...baseOptions.oidc, skipDiscovery: true },
});
expect(withSkip).toContain("OAUTH2_PROXY_SKIP_OIDC_DISCOVERY=true");
});
});
describe("deriveBaseDomain", () => {
test("strips the auth subdomain to the shared base", () => {
expect(deriveBaseDomain("auth.acme.com")).toBe(".acme.com");
expect(deriveBaseDomain("sso.apps.acme.com")).toBe(".apps.acme.com");
});
test("keeps a two-label apex as the base", () => {
expect(deriveBaseDomain("acme.com")).toBe(".acme.com");
});
});
describe("forwardAuthCallbackUrl", () => {
test("builds the single IdP callback per scheme", () => {
expect(forwardAuthCallbackUrl("auth.acme.com", true)).toBe(
"https://auth.acme.com/oauth2/callback",
);
expect(forwardAuthCallbackUrl("auth.acme.com", false)).toBe(
"http://auth.acme.com/oauth2/callback",
);
});
});
describe("deriveCookieSecret", () => {
beforeAll(() => {
process.env.BETTER_AUTH_SECRET = "test-root-secret";
});
test("is deterministic for the same salt (survives service updates)", () => {
expect(deriveCookieSecret(".acme.com")).toBe(
deriveCookieSecret(".acme.com"),
);
});
test("differs per salt", () => {
expect(deriveCookieSecret(".acme.com")).not.toBe(
deriveCookieSecret(".other.com"),
);
});
test("produces a 16-byte hex secret (oauth2-proxy requirement)", () => {
const secret = deriveCookieSecret(".acme.com");
expect(Buffer.from(secret, "hex")).toHaveLength(16);
});
});
@@ -65,6 +65,8 @@ const baseSettings: WebServerSettings = {
cleanupCacheApplications: false,
cleanupCacheOnCompose: false,
cleanupCacheOnPreviews: false,
remoteServersOnly: false,
enforceSSO: false,
createdAt: null,
updatedAt: new Date(),
};
@@ -95,6 +95,7 @@ const baseApp: ApplicationNested = {
dropBuildPath: null,
enabled: null,
env: null,
icon: null,
healthCheckSwarm: null,
labelsSwarm: null,
memoryLimit: null,
@@ -137,6 +138,7 @@ const baseDomain: Domain = {
https: false,
path: null,
port: null,
customEntrypoint: null,
serviceName: "",
composeId: "",
customCertResolver: null,
@@ -145,6 +147,8 @@ const baseDomain: Domain = {
previewDeploymentId: "",
internalPath: "/",
stripPath: false,
middlewares: null,
forwardAuthEnabled: false,
};
const baseRedirect: Redirect = {
@@ -264,6 +268,80 @@ test("Websecure entrypoint on https domain with redirect", async () => {
expect(router.middlewares).toContain("redirect-test-1");
});
/** Custom Middlewares */
test("Web entrypoint with single custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with multiple custom middlewares", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, middlewares: ["auth@file", "rate-limit@file"] },
"web",
);
expect(router.middlewares).toContain("auth@file");
expect(router.middlewares).toContain("rate-limit@file");
});
test("Web entrypoint on https domain with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"web",
);
// Should only have HTTPS redirect - custom middleware applies on websecure
expect(router.middlewares).toContain("redirect-to-https");
expect(router.middlewares).not.toContain("auth@file");
});
test("Websecure entrypoint with custom middleware", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: true, middlewares: ["auth@file"] },
"websecure",
);
// Should have custom middleware but not HTTPS redirect
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with redirect and custom middleware", async () => {
const router = await createRouterConfig(
{
...baseApp,
appName: "test",
redirects: [{ ...baseRedirect, uniqueConfigKey: 1 }],
},
{ ...baseDomain, middlewares: ["auth@file"] },
"web",
);
// Should have both redirect middleware and custom middleware
expect(router.middlewares).toContain("redirect-test-1");
expect(router.middlewares).toContain("auth@file");
});
test("Web entrypoint with empty middlewares array", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, middlewares: [] },
"web",
);
// Should behave same as no middlewares - no redirect for http
expect(router.middlewares).not.toContain("redirect-to-https");
});
/** Certificates */
test("CertificateType on websecure entrypoint", async () => {
@@ -276,6 +354,130 @@ test("CertificateType on websecure entrypoint", async () => {
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint on http domain", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, https: false, customEntrypoint: "custom" },
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls).toBeUndefined();
});
test("Custom entrypoint on https domain", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.middlewares).not.toContain("redirect-to-https");
expect(router.tls?.certResolver).toBe("letsencrypt");
});
test("Custom entrypoint with path includes PathPrefix in rule", async () => {
const router = await createRouterConfig(
baseApp,
{ ...baseDomain, customEntrypoint: "custom", path: "/api" },
"custom",
);
expect(router.rule).toContain("PathPrefix(`/api`)");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with stripPath adds stripprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
path: "/api",
stripPath: true,
},
"custom",
);
expect(router.middlewares).toContain("stripprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("Custom entrypoint with internalPath adds addprefix middleware", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
customEntrypoint: "custom",
internalPath: "/hello",
},
"custom",
);
expect(router.middlewares).toContain("addprefix--1");
expect(router.entryPoints).toEqual(["custom"]);
});
test("stripPath and internalPath together: stripprefix must come before addprefix", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
path: "/public",
stripPath: true,
internalPath: "/app/v2",
},
"web",
);
const stripIndex = router.middlewares?.indexOf("stripprefix--1") ?? -1;
const addIndex = router.middlewares?.indexOf("addprefix--1") ?? -1;
expect(stripIndex).toBeGreaterThanOrEqual(0);
expect(addIndex).toBeGreaterThanOrEqual(0);
expect(stripIndex).toBeLessThan(addIndex);
});
test("Custom entrypoint with https and custom cert resolver", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: true,
customEntrypoint: "custom",
certificateType: "custom",
customCertResolver: "myresolver",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls?.certResolver).toBe("myresolver");
});
test("Custom entrypoint without https should not have tls", async () => {
const router = await createRouterConfig(
baseApp,
{
...baseDomain,
https: false,
customEntrypoint: "custom",
certificateType: "letsencrypt",
},
"custom",
);
expect(router.entryPoints).toEqual(["custom"]);
expect(router.tls).toBeUndefined();
});
/** IDN/Punycode */
test("Internationalized domain name is converted to punycode", async () => {
@@ -78,4 +78,20 @@ describe("readValidDirectory (path traversal)", () => {
it("returns false for empty string (resolves to cwd)", () => {
expect(readValidDirectory("")).toBe(false);
});
it("returns true for Next.js dynamic route paths with square brackets", () => {
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/api/[id]/route.ts`,
),
).toBe(true);
expect(
readValidDirectory(`${BASE}/applications/myapp/code/pages/[slug].tsx`),
).toBe(true);
expect(
readValidDirectory(
`${BASE}/applications/myapp/code/app/[...catch]/page.tsx`,
),
).toBe(true);
});
});
@@ -110,6 +110,13 @@ const menuItems: MenuItem[] = [
},
];
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
interface Props {
id: string;
type:
@@ -40,12 +40,12 @@ interface Props {
type: "application" | "mariadb" | "mongo" | "mysql" | "postgres" | "redis";
}
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
replicas: z.number().min(1, "Replicas must be at least 1"),
registryId: z.string().optional(),
});
type AddCommand = z.infer<typeof AddRedirectchema>;
type AddCommand = z.infer<typeof AddRedirectSchema>;
export const ShowClusterSettings = ({ id, type }: Props) => {
const queryMap = {
@@ -87,7 +87,7 @@ export const ShowClusterSettings = ({ id, type }: Props) => {
: {}),
replicas: data?.replicas || 1,
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
@@ -16,12 +16,17 @@ import {
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const optionalNumber = z
.union([z.string(), z.number()])
.transform((val) => (val === "" ? undefined : Number(val)))
.optional();
export const healthCheckFormSchema = z.object({
Test: z.array(z.string()).optional(),
Interval: z.coerce.number().optional(),
Timeout: z.coerce.number().optional(),
StartPeriod: z.coerce.number().optional(),
Retries: z.coerce.number().optional(),
Interval: optionalNumber,
Timeout: optionalNumber,
StartPeriod: optionalNumber,
Retries: optionalNumber,
});
interface HealthCheckFormProps {
@@ -195,7 +200,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Time between health checks (e.g., 10000000000 for 10 seconds)
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -212,7 +222,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Maximum time to wait for health check response
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -229,7 +244,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
Initial grace period before health checks begin
</FormDescription>
<FormControl>
<Input type="number" placeholder="10000000000" {...field} />
<Input
type="number"
placeholder="10000000000"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -247,7 +267,12 @@ export const HealthCheckForm = ({ id, type }: HealthCheckFormProps) => {
unhealthy
</FormDescription>
<FormControl>
<Input type="number" placeholder="3" {...field} />
<Input
type="number"
placeholder="3"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
@@ -16,7 +16,7 @@ import { api } from "@/utils/api";
const hasStopGracePeriodSwarm = (
value: unknown,
): value is { stopGracePeriodSwarm: bigint | number | string | null } =>
): value is { stopGracePeriodSwarm: number | string | null } =>
typeof value === "object" &&
value !== null &&
"stopGracePeriodSwarm" in value;
@@ -68,7 +68,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
const form = useForm<any>({
defaultValues: {
value: null as bigint | null,
value: null as number | null,
},
});
@@ -76,11 +76,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
if (hasStopGracePeriodSwarm(data)) {
const value = data.stopGracePeriodSwarm;
const normalizedValue =
value === null || value === undefined
? null
: typeof value === "bigint"
? value
: BigInt(value);
value === null || value === undefined ? null : Number(value);
form.reset({
value: normalizedValue,
});
@@ -136,7 +132,7 @@ export const StopGracePeriodForm = ({ id, type }: StopGracePeriodFormProps) => {
}
onChange={(e) =>
field.onChange(
e.target.value ? BigInt(e.target.value) : null,
e.target.value ? Number(e.target.value) : null,
)
}
/>
@@ -37,13 +37,13 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
const AddRedirectchema = z.object({
const AddRedirectSchema = z.object({
regex: z.string().min(1, "Regex required"),
permanent: z.boolean().default(false),
replacement: z.string().min(1, "Replacement required"),
});
type AddRedirect = z.infer<typeof AddRedirectchema>;
type AddRedirect = z.infer<typeof AddRedirectSchema>;
// Default presets
const redirectPresets = [
@@ -110,7 +110,7 @@ export const HandleRedirect = ({
regex: "",
replacement: "",
},
resolver: zodResolver(AddRedirectchema),
resolver: zodResolver(AddRedirectSchema),
});
useEffect(() => {
@@ -149,7 +149,7 @@ export const HandleRedirect = ({
const onDialogToggle = (open: boolean) => {
setIsOpen(open);
// commented for the moment because not reseting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// commented for the moment because not resetting the form if accidentally closed the dialog can be considered as a feature instead of a bug
// setPresetSelected("");
// form.reset();
};
@@ -224,7 +224,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -263,7 +263,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>Memory Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -303,7 +303,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Limit</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -343,7 +343,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel>CPU Reservation</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
@@ -379,7 +379,7 @@ export const ShowResources = ({ id, type }: Props) => {
<FormLabel className="text-base">Ulimits</FormLabel>
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger>
<TooltipTrigger type="button">
<InfoIcon className="h-4 w-4 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent className="max-w-xs">
@@ -1,6 +1,7 @@
import copy from "copy-to-clipboard";
import { Check, Copy, Loader2 } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AnalyzeLogs } from "@/components/dashboard/docker/logs/analyze-logs";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -165,6 +166,7 @@ export const ShowDeployment = ({
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<AnalyzeLogs logs={filteredLogs} context="build" />
{serverId && (
<div className="flex items-center space-x-2">
@@ -1,3 +1,4 @@
import copy from "copy-to-clipboard";
import {
ChevronDown,
ChevronUp,
@@ -11,7 +12,6 @@ import {
} from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import copy from "copy-to-clipboard";
import { AlertBlock } from "@/components/shared/alert-block";
import { DateTooltip } from "@/components/shared/date-tooltip";
import { DialogAction } from "@/components/shared/dialog-action";
@@ -0,0 +1,303 @@
import type { ColumnDef } from "@tanstack/react-table";
import {
ArrowUpDown,
CheckCircle2,
ExternalLink,
Loader2,
PenBoxIcon,
RefreshCw,
Server,
Trash2,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { RouterOutputs } from "@/utils/api";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import type { ValidationStates } from "./show-domains";
export type Domain =
| RouterOutputs["domain"]["byApplicationId"][0]
| RouterOutputs["domain"]["byComposeId"][0];
interface ColumnsProps {
id: string;
type: "application" | "compose";
validationStates: ValidationStates;
handleValidateDomain: (host: string) => Promise<void>;
handleDeleteDomain: (domainId: string) => Promise<void>;
isDeleting: boolean;
serverIp?: string;
canCreateDomain: boolean;
canDeleteDomain: boolean;
}
export const createColumns = ({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting,
serverIp,
canCreateDomain,
canDeleteDomain,
}: ColumnsProps): ColumnDef<Domain>[] => [
...(type === "compose"
? [
{
accessorKey: "serviceName",
header: "Service",
cell: ({ row }: { row: { getValue: (key: string) => unknown } }) => {
const serviceName = row.getValue("serviceName") as string | null;
if (!serviceName) return null;
return (
<Badge variant="outline">
<Server className="size-3 mr-1" />
{serviceName}
</Badge>
);
},
} satisfies ColumnDef<Domain>,
]
: []),
{
accessorKey: "host",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Host
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const domain = row.original;
return (
<Link
className="flex items-center gap-2 font-medium hover:underline"
target="_blank"
href={`${domain.https ? "https" : "http"}://${domain.host}${domain.path}`}
>
{domain.host}
<ExternalLink className="size-3" />
</Link>
);
},
},
{
accessorKey: "path",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Path
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const path = row.getValue("path") as string;
return <div className="font-mono text-sm">{path || "/"}</div>;
},
},
{
accessorKey: "port",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Port
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const port = row.getValue("port") as number;
return <Badge variant="secondary">{port}</Badge>;
},
},
{
accessorKey: "customEntrypoint",
header: "Entrypoint",
cell: ({ row }) => {
const entrypoint = row.getValue("customEntrypoint") as string | null;
if (!entrypoint) return <span className="text-muted-foreground">-</span>;
return <div className="font-mono text-sm">{entrypoint}</div>;
},
},
{
accessorKey: "https",
header: "Protocol",
cell: ({ row }) => {
const https = row.getValue("https") as boolean;
return (
<Badge variant={https ? "outline" : "secondary"}>
{https ? "HTTPS" : "HTTP"}
</Badge>
);
},
},
{
id: "certificate",
header: "Certificate",
cell: ({ row }) => {
const domain = row.original;
const validationState = validationStates[domain.host];
return (
<div className="flex items-center gap-2">
{domain.certificateType && (
<Badge variant="outline" className="capitalize">
{domain.certificateType}
</Badge>
)}
{!domain.host.includes("sslip.io") && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className={
validationState?.isValid
? "bg-green-500/10 text-green-500 cursor-pointer"
: validationState?.error
? "bg-red-500/10 text-red-500 cursor-pointer"
: "bg-yellow-500/10 text-yellow-500 cursor-pointer"
}
onClick={() => handleValidateDomain(domain.host)}
>
{validationState?.isLoading ? (
<>
<Loader2 className="size-3 mr-1 animate-spin" />
Checking...
</>
) : validationState?.isValid ? (
<>
<CheckCircle2 className="size-3 mr-1" />
{validationState.message && validationState.cdnProvider
? `${validationState.cdnProvider}`
: "Valid"}
</>
) : validationState?.error ? (
<>
<XCircle className="size-3 mr-1" />
Invalid
</>
) : (
<>
<RefreshCw className="size-3 mr-1" />
Validate
</>
)}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
{validationState?.error ? (
<div className="flex flex-col gap-1">
<p className="font-medium text-red-500">Error:</p>
<p>{validationState.error}</p>
</div>
) : (
"Click to validate DNS configuration"
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
Created
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const createdAt = row.getValue("createdAt") as string;
return (
<div className="text-sm text-muted-foreground">
{new Date(createdAt).toLocaleDateString()}
</div>
);
},
},
{
id: "actions",
header: "Actions",
enableHiding: false,
cell: ({ row }) => {
const domain = row.original;
return (
<div className="flex items-center gap-2">
{!domain.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: domain.host,
https: domain.https,
path: domain.path || undefined,
}}
serverIp={serverIp}
/>
)}
{canCreateDomain && (
<AddDomain id={id} type={type} domainId={domain.domainId}>
<Button
variant="ghost"
size="icon"
className="group hover:bg-blue-500/10 h-8 w-8"
>
<PenBoxIcon className="size-3.5 text-primary group-hover:text-blue-500" />
</Button>
</AddDomain>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
description="Are you sure you want to delete this domain?"
type="destructive"
onClick={async () => {
await handleDeleteDomain(domain.domainId);
}}
>
<Button
variant="ghost"
size="icon"
className="group hover:bg-red-500/10 h-8 w-8"
isLoading={isDeleting}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</DialogAction>
)}
</div>
);
},
},
];
@@ -1,11 +1,12 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { DatabaseZap, Dices, RefreshCw } from "lucide-react";
import { DatabaseZap, Dices, RefreshCw, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import z from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -61,11 +62,14 @@ export const domain = z
.min(1, { message: "Port must be at least 1" })
.max(65535, { message: "Port must be 65535 or below" })
.optional(),
useCustomEntrypoint: z.boolean(),
customEntrypoint: z.string().optional(),
https: z.boolean().optional(),
certificateType: z.enum(["letsencrypt", "none", "custom"]).optional(),
customCertResolver: z.string().optional(),
serviceName: z.string().optional(),
domainType: z.enum(["application", "compose", "preview"]).optional(),
middlewares: z.array(z.string()).optional(),
})
.superRefine((input, ctx) => {
if (input.https && !input.certificateType) {
@@ -114,6 +118,14 @@ export const domain = z
message: "Internal path must start with '/'",
});
}
if (input.useCustomEntrypoint && !input.customEntrypoint) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["customEntrypoint"],
message: "Custom entry point must be specified",
});
}
});
type Domain = z.infer<typeof domain>;
@@ -196,20 +208,24 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
serviceName: undefined,
domainType: type,
middlewares: [],
},
mode: "onChange",
});
const certificateType = form.watch("certificateType");
const useCustomEntrypoint = form.watch("useCustomEntrypoint");
const https = form.watch("https");
const domainType = form.watch("domainType");
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
const isTraefikMeDomain = host?.includes("sslip.io") || false;
useEffect(() => {
if (data) {
@@ -220,10 +236,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: data?.internalPath || undefined,
stripPath: data?.stripPath || false,
port: data?.port || undefined,
useCustomEntrypoint: !!data.customEntrypoint,
customEntrypoint: data.customEntrypoint || undefined,
certificateType: data?.certificateType || undefined,
customCertResolver: data?.customCertResolver || undefined,
serviceName: data?.serviceName || undefined,
domainType: data?.domainType || type,
middlewares: data?.middlewares || [],
});
}
@@ -234,10 +253,13 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
internalPath: undefined,
stripPath: false,
port: undefined,
useCustomEntrypoint: false,
customEntrypoint: undefined,
https: false,
certificateType: undefined,
customCertResolver: undefined,
domainType: type,
middlewares: [],
});
}
}, [form, data, isPending, domainId]);
@@ -268,6 +290,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
composeId: id,
}),
...data,
customEntrypoint: data.useCustomEntrypoint ? data.customEntrypoint : null,
})
.then(async () => {
toast.success(dictionary.success);
@@ -490,7 +513,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
render={({ field }) => (
<FormItem>
{!canGenerateTraefikMeDomains &&
field.value.includes("traefik.me") && (
field.value.includes("sslip.io") && (
<AlertBlock type="warning">
You need to set an IP address in your{" "}
<Link
@@ -501,12 +524,12 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
? "Remote Servers -> Server -> Edit Server -> Update IP Address"
: "Web Server -> Server -> Update Server IP"}
</Link>{" "}
to make your traefik.me domain work.
to make your sslip.io domain work.
</AlertBlock>
)}
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
<strong>Note:</strong> sslip.io is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -544,7 +567,7 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
<p>Generate sslip.io domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -635,6 +658,55 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
}}
/>
<FormField
control={form.control}
name="useCustomEntrypoint"
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between p-3 mt-4 border rounded-lg shadow-sm">
<div className="space-y-0.5">
<FormLabel>Custom Entrypoint</FormLabel>
<FormDescription>
Use custom entrypoint for domain
<br />
"web" and/or "websecure" is used by default.
</FormDescription>
<FormMessage />
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => {
field.onChange(checked);
if (!checked) {
form.setValue("customEntrypoint", undefined);
}
}}
/>
</FormControl>
</FormItem>
)}
/>
{useCustomEntrypoint && (
<FormField
control={form.control}
name="customEntrypoint"
render={({ field }) => (
<FormItem className="w-full">
<FormLabel>Entrypoint Name</FormLabel>
<FormControl>
<Input
placeholder="Enter entrypoint name manually"
{...field}
className="w-full"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="https"
@@ -725,6 +797,88 @@ export const AddDomain = ({ id, type, domainId = "", children }: Props) => {
)}
</>
)}
<FormField
control={form.control}
name="middlewares"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel>Middlewares</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
</TooltipTrigger>
<TooltipContent className="max-w-[300px]">
<p>
Add Traefik middleware references. Middlewares
must be defined in your Traefik configuration.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex flex-wrap gap-2 mb-2">
{field.value?.map((name, index) => (
<Badge key={index} variant="secondary">
{name}
<X
className="ml-1 size-3 cursor-pointer"
onClick={() => {
const newMiddlewares = [...(field.value || [])];
newMiddlewares.splice(index, 1);
form.setValue("middlewares", newMiddlewares);
}}
/>
</Badge>
))}
</div>
<FormControl>
<div className="flex gap-2">
<Input
placeholder="e.g., rate-limit@file, auth@file"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const input = e.currentTarget;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}
}}
/>
<Button
type="button"
variant="secondary"
onClick={() => {
const input = document.querySelector(
'input[placeholder="e.g., rate-limit@file, auth@file"]',
) as HTMLInputElement;
const value = input.value.trim();
if (value && !field.value?.includes(value)) {
form.setValue("middlewares", [
...(field.value || []),
value,
]);
input.value = "";
}
}}
>
Add
</Button>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
</form>
@@ -0,0 +1,147 @@
import { ShieldCheck } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
interface Props {
domainId: string;
applicationId: string;
}
export const HandleForwardAuth = ({ domainId, applicationId }: Props) => {
const [isOpen, setIsOpen] = useState(false);
const { data: haveValidLicense } =
api.licenseKey.haveValidLicenseKey.useQuery();
const utils = api.useUtils();
const { data: status } = api.forwardAuth.status.useQuery(
{ domainId },
{ enabled: isOpen },
);
const { mutateAsync: enable, isPending: isEnabling } =
api.forwardAuth.enable.useMutation();
const { mutateAsync: disable, isPending: isDisabling } =
api.forwardAuth.disable.useMutation();
if (!haveValidLicense) {
return null;
}
const isEnabled = !!status?.enabled;
const isPending = isEnabling || isDisabling;
const refresh = async () => {
await utils.forwardAuth.status.invalidate({ domainId });
await utils.domain.byApplicationId.invalidate({ applicationId });
await utils.application.readTraefikConfig.invalidate({ applicationId });
};
const handleToggle = async (next: boolean) => {
try {
if (next) {
await enable({ domainId });
toast.success("SSO authentication enabled for this domain");
} else {
await disable({ domainId });
toast.success("SSO authentication disabled for this domain");
}
await refresh();
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Error updating SSO authentication",
);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="group hover:bg-emerald-500/10"
title="SSO authentication"
>
<ShieldCheck
className={`size-4 ${
isEnabled
? "text-emerald-500"
: "text-primary group-hover:text-emerald-500"
}`}
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>SSO Authentication</DialogTitle>
<DialogDescription>
Require visitors to authenticate against your identity provider
before reaching this application.
</DialogDescription>
</DialogHeader>
<AlertBlock type="warning">
<div className="flex flex-col gap-1">
<span className="font-medium">Requirements</span>
<ol className="list-decimal pl-4 text-sm">
<li>
The authentication proxy container must be deployed and running
on this app's server. Configure it under{" "}
<span className="font-medium">
Settings SSO Application Authentication
</span>
.
</li>
<li>
This domain must share the same base domain as the
authentication domain (e.g. <code>app.acme.com</code> and{" "}
<code>auth.acme.com</code>).
</li>
</ol>
</div>
</AlertBlock>
<div className="flex items-center justify-between rounded-lg border p-4 mt-2">
<div className="flex flex-col">
<span className="text-sm font-medium">
Protect this domain with SSO
</span>
<span className="text-xs text-muted-foreground">
{isEnabled
? "Visitors must log in via your identity provider."
: "The domain is publicly accessible."}
</span>
</div>
<Switch
checked={isEnabled}
disabled={isPending}
onCheckedChange={handleToggle}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsOpen(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -1,8 +1,22 @@
import {
type ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
type SortingState,
useReactTable,
type VisibilityState,
} from "@tanstack/react-table";
import {
CheckCircle2,
ChevronDown,
ExternalLink,
GlobeIcon,
InfoIcon,
LayoutGrid,
LayoutList,
Loader2,
PenBoxIcon,
RefreshCw,
@@ -23,6 +37,21 @@ import {
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
@@ -30,8 +59,10 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { api } from "@/utils/api";
import { createColumns } from "./columns";
import { DnsHelperModal } from "./dns-helper-modal";
import { AddDomain } from "./handle-domain";
import { HandleForwardAuth } from "./handle-forward-auth";
export type ValidationState = {
isLoading: boolean;
@@ -74,6 +105,19 @@ export const ShowDomains = ({ id, type }: Props) => {
const [validationStates, setValidationStates] = useState<ValidationStates>(
{},
);
const [viewMode, setViewMode] = useState<"grid" | "table">(() => {
if (typeof window !== "undefined") {
return (
(localStorage.getItem("domains-view-mode") as "grid" | "table") ??
"grid"
);
}
return "grid";
});
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [rowSelection, setRowSelection] = useState({});
const { data: ip } = api.settings.getIp.useQuery();
const {
@@ -103,6 +147,16 @@ export const ShowDomains = ({ id, type }: Props) => {
const { mutateAsync: deleteDomain, isPending: isRemoving } =
api.domain.delete.useMutation();
const handleDeleteDomain = async (domainId: string) => {
try {
await deleteDomain({ domainId });
refetch();
toast.success("Domain deleted successfully");
} catch {
toast.error("Error deleting domain");
}
};
const handleValidateDomain = async (host: string) => {
setValidationStates((prev) => ({
...prev,
@@ -140,6 +194,37 @@ export const ShowDomains = ({ id, type }: Props) => {
}
};
const columns = createColumns({
id,
type,
validationStates,
handleValidateDomain,
handleDeleteDomain,
isDeleting: isRemoving,
serverIp: application?.server?.ipAddress?.toString() || ip?.toString(),
canCreateDomain,
canDeleteDomain,
});
const table = useReactTable({
data: data ?? [],
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection,
},
});
return (
<div className="flex w-full flex-col gap-5 ">
<Card className="bg-background">
@@ -151,13 +236,32 @@ export const ShowDomains = ({ id, type }: Props) => {
</CardDescription>
</div>
<div className="flex flex-row gap-4 flex-wrap">
{canCreateDomain && data && data?.length > 0 && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
<div className="flex flex-row gap-2 flex-wrap">
{data && data?.length > 0 && (
<>
<Button
variant="outline"
size="icon"
onClick={() => {
const next = viewMode === "grid" ? "table" : "grid";
localStorage.setItem("domains-view-mode", next);
setViewMode(next);
}}
>
{viewMode === "grid" ? (
<LayoutList className="size-4" />
) : (
<LayoutGrid className="size-4" />
)}
</Button>
</AddDomain>
{canCreateDomain && (
<AddDomain id={id} type={type}>
<Button>
<GlobeIcon className="size-4" /> Add Domain
</Button>
</AddDomain>
)}
</>
)}
</div>
</CardHeader>
@@ -186,6 +290,122 @@ export const ShowDomains = ({ id, type }: Props) => {
</div>
)}
</div>
) : viewMode === "table" ? (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-2 max-sm:flex-wrap">
<Input
placeholder="Filter by host..."
value={
(table.getColumn("host")?.getFilterValue() as string) ?? ""
}
onChange={(event) =>
table.getColumn("host")?.setFilterValue(event.target.value)
}
className="md:max-w-sm"
/>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="sm:ml-auto max-sm:w-full"
>
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table?.getRowModel()?.rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{data && data?.length > 0 && (
<div className="flex items-center justify-end space-x-2 py-4">
<div className="space-x-2 flex flex-wrap">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
)}
</div>
) : (
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2 w-full min-h-[40vh] ">
{data?.map((item) => {
@@ -206,7 +426,7 @@ export const ShowDomains = ({ id, type }: Props) => {
</Badge>
)}
<div className="flex gap-2 flex-wrap">
{!item.host.includes("traefik.me") && (
{!item.host.includes("sslip.io") && (
<DnsHelperModal
domain={{
host: item.host,
@@ -234,6 +454,12 @@ export const ShowDomains = ({ id, type }: Props) => {
</Button>
</AddDomain>
)}
{canCreateDomain && type === "application" && (
<HandleForwardAuth
domainId={item.domainId}
applicationId={id}
/>
)}
{canDeleteDomain && (
<DialogAction
title="Delete Domain"
@@ -341,6 +567,22 @@ export const ShowDomains = ({ id, type }: Props) => {
</TooltipProvider>
)}
{item.middlewares?.map((middleware, index) => (
<TooltipProvider key={`${middleware}-${index}`}>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="secondary">
<InfoIcon className="size-3 mr-1" />
Middleware: {middleware}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Traefik middleware reference</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -56,17 +56,17 @@ export const ShowEnvironment = ({ id, type }: Props) => {
const [isEnvVisible, setIsEnvVisible] = useState(true);
const mutationMap = {
compose: () => api.compose.update.useMutation(),
libsql: () => api.libsql.update.useMutation(),
mariadb: () => api.mariadb.update.useMutation(),
mongo: () => api.mongo.update.useMutation(),
mysql: () => api.mysql.update.useMutation(),
postgres: () => api.postgres.update.useMutation(),
redis: () => api.redis.update.useMutation(),
compose: () => api.compose.saveEnvironment.useMutation(),
libsql: () => api.libsql.saveEnvironment.useMutation(),
mariadb: () => api.mariadb.saveEnvironment.useMutation(),
mongo: () => api.mongo.saveEnvironment.useMutation(),
mysql: () => api.mysql.saveEnvironment.useMutation(),
postgres: () => api.postgres.saveEnvironment.useMutation(),
redis: () => api.redis.saveEnvironment.useMutation(),
};
const { mutateAsync, isPending } = mutationMap[type]
? mutationMap[type]()
: api.mongo.update.useMutation();
: api.mongo.saveEnvironment.useMutation();
const form = useForm<EnvironmentSchema>({
defaultValues: {
@@ -116,7 +116,7 @@ export const ShowEnvironment = ({ id, type }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -106,7 +106,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().optional(),
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -55,7 +59,7 @@ interface Props {
export const SaveGitProvider = ({ applicationId }: Props) => {
const { data, refetch } = api.application.one.useQuery({ applicationId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const router = useRouter();
const { mutateAsync, isPending } =
@@ -107,110 +111,103 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
<div className="grid md:grid-cols-2 gap-4">
<div className="flex items-end col-span-2 gap-4">
<div className="grow">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem>
<div className="flex items-center justify-between">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="sshKey"
render={({ field }) => (
<FormItem className="basis-40">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 items-start">
<FormField
control={form.control}
name="repositoryURL"
render={({ field }) => (
<FormItem className="col-span-2 lg:col-span-3">
<div className="flex items-center justify-between h-5">
<FormLabel>Repository URL</FormLabel>
{field.value?.startsWith("https://") && (
<Link
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-primary"
>
<GitIcon className="h-4 w-4" />
<span>View Repository</span>
</Link>
)}
</div>
<FormControl>
<Input placeholder="Repository URL" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
</div>
<div className="space-y-4">
/>
{sshKeys && sshKeys.length > 0 ? (
<FormField
control={form.control}
name="branch"
name="sshKey"
render={({ field }) => (
<FormItem>
<FormLabel>Branch</FormLabel>
<FormItem className="col-span-2 lg:col-span-1">
<FormLabel className="w-full inline-flex justify-between">
SSH Key
<LockIcon className="size-4 text-muted-foreground" />
</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
<Select
key={field.value}
onValueChange={field.onChange}
defaultValue={field.value}
value={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Select a key" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{sshKeys?.map((sshKey) => (
<SelectItem
key={sshKey.sshKeyId}
value={sshKey.sshKeyId}
>
{sshKey.name}
</SelectItem>
))}
<SelectItem value="none">None</SelectItem>
<SelectLabel>Keys ({sshKeys?.length})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : (
<Button
variant="secondary"
onClick={() => router.push("/dashboard/settings/ssh-keys")}
type="button"
className="col-span-2 lg:col-span-1 lg:mt-7"
>
<KeyRoundIcon className="size-4" /> Add SSH Key
</Button>
)}
<FormField
control={form.control}
name="branch"
render={({ field }) => (
<FormItem className="col-span-2">
<FormLabel>Branch</FormLabel>
<FormControl>
<Input placeholder="Branch" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="buildPath"
render={({ field }) => (
<FormItem>
<FormItem className="col-span-2">
<FormLabel>Build Path</FormLabel>
<FormControl>
<Input placeholder="/" {...field} />
@@ -223,7 +220,7 @@ export const SaveGitProvider = ({ applicationId }: Props) => {
control={form.control}
name="watchPaths"
render={({ field }) => (
<FormItem className="md:col-span-2">
<FormItem className="col-span-2 lg:col-span-4">
<div className="flex items-center gap-2">
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -72,7 +73,10 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).default([]),
enableSubmodules: z.boolean().optional(),
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
id: z.number().nullable(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -58,7 +58,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<CardHeader>
<CardTitle className="text-xl">Deploy Settings</CardTitle>
</CardHeader>
<CardContent className="flex flex-row gap-4 flex-wrap">
<CardContent className="grid grid-cols-2 lg:flex lg:flex-row lg:flex-wrap gap-4">
<TooltipProvider delayDuration={0} disableHoverableContent={false}>
{canDeploy && (
<DialogAction
@@ -274,14 +274,14 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
>
<Button
variant="outline"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2"
className="flex items-center gap-1.5 focus-visible:ring-2 focus-visible:ring-offset-2 col-span-2"
>
<Terminal className="size-4 mr-1" />
Open Terminal
</Button>
</DockerTerminalModal>
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<span className="text-sm font-medium">Autodeploy</span>
<Switch
aria-label="Toggle autodeploy"
@@ -305,7 +305,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
)}
{canUpdateService && (
<div className="flex flex-row items-center gap-2 rounded-md px-4 py-2 border">
<div className="flex flex-row items-center gap-2 justify-between rounded-md px-4 py-2 border col-span-2 md:col-span-1">
<span className="text-sm font-medium">Clean Cache</span>
<Switch
aria-label="Toggle clean cache"
@@ -0,0 +1,277 @@
import DOMPurify from "dompurify";
import { GlobeIcon, Pencil, Search, X } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Dropzone } from "@/components/ui/dropzone";
import { Input } from "@/components/ui/input";
import { type BundledIcon, bundledIcons } from "@/lib/bundled-icons";
import { api } from "@/utils/api";
interface ShowIconSettingsProps {
applicationId: string;
icon?: string | null;
}
const svgToDataUrl = (icon: BundledIcon): string => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#${icon.hex}"><path d="${icon.path}"/></svg>`;
return `data:image/svg+xml;base64,${btoa(svg)}`;
};
export const ShowIconSettings = ({
applicationId,
icon,
}: ShowIconSettingsProps) => {
const [open, setOpen] = useState(false);
const [iconSearchQuery, setIconSearchQuery] = useState("");
const [iconsToShow, setIconsToShow] = useState(24);
const filteredIcons = useMemo(() => {
if (!iconSearchQuery) return bundledIcons;
const q = iconSearchQuery.toLowerCase();
return bundledIcons.filter(
(i) =>
i.title.toLowerCase().includes(q) || i.slug.toLowerCase().includes(q),
);
}, [iconSearchQuery]);
const displayedIcons = filteredIcons.slice(0, iconsToShow);
const hasMoreIcons = filteredIcons.length > iconsToShow;
const utils = api.useUtils();
const { mutateAsync: updateApplication } =
api.application.update.useMutation();
useEffect(() => {
if (open) {
setIconSearchQuery("");
setIconsToShow(24);
}
}, [open]);
const handleIconSelect = async (selectedIcon: BundledIcon) => {
try {
const dataUrl = svgToDataUrl(selectedIcon);
await updateApplication({
applicationId,
icon: dataUrl,
});
toast.success("Icon saved successfully");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
const handleRemoveIcon = async () => {
try {
await updateApplication({
applicationId,
icon: null,
});
toast.success("Icon removed");
await utils.application.one.invalidate({ applicationId });
} catch (_error) {
toast.error("Error removing icon");
}
};
const sanitizeSvg = (svgContent: string): string | null => {
const clean = DOMPurify.sanitize(svgContent, {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["use"],
});
if (!clean) return null;
return `data:image/svg+xml;base64,${btoa(clean)}`;
};
const handleFileUpload = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!file) return;
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/svg+xml",
];
const fileExtension = file.name.split(".").pop()?.toLowerCase();
const allowedExtensions = ["jpg", "jpeg", "png", "svg"];
if (
!allowedTypes.includes(file.type) &&
!allowedExtensions.includes(fileExtension || "")
) {
toast.error("Only JPG, JPEG, PNG, and SVG files are allowed");
return;
}
if (file.size > 2 * 1024 * 1024) {
toast.error("Image size must be less than 2MB");
return;
}
const isSvg = file.type === "image/svg+xml" || fileExtension === "svg";
if (isSvg) {
const text = await file.text();
const sanitizedDataUrl = sanitizeSvg(text);
if (!sanitizedDataUrl) {
toast.error("Invalid SVG file");
return;
}
try {
await updateApplication({
applicationId,
icon: sanitizedDataUrl,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
return;
}
const reader = new FileReader();
reader.onload = async (event) => {
const result = event.target?.result as string;
try {
await updateApplication({
applicationId,
icon: result,
});
toast.success("Icon saved!");
await utils.application.one.invalidate({ applicationId });
setOpen(false);
} catch (_error) {
toast.error("Error saving icon");
}
};
reader.readAsDataURL(file);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<button
type="button"
className="relative group flex items-center justify-center"
>
{icon ? (
// biome-ignore lint/performance/noImgElement: icon is data URL or base64
<img
src={icon}
alt="Application icon"
className="h-8 w-8 object-contain"
/>
) : (
<GlobeIcon className="h-6 w-6 text-muted-foreground" />
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded opacity-0 group-hover:opacity-100 transition-opacity">
<Pencil className="h-3 w-3 text-white" />
</div>
</button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
Change Icon
{icon && (
<Button
variant="ghost"
size="sm"
onClick={handleRemoveIcon}
className="text-muted-foreground"
>
<X className="size-4 mr-1" />
Remove icon
</Button>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
placeholder="Search icons (e.g. react, vue, docker)..."
value={iconSearchQuery}
onChange={(e) => setIconSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
<div className="max-h-[300px] overflow-y-auto border rounded-lg p-4">
{displayedIcons.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground">
No icons found
</div>
) : (
<>
<div className="grid grid-cols-4 sm:grid-cols-6 md:grid-cols-8 gap-2">
{displayedIcons.map((i) => (
<button
type="button"
key={i.slug}
onClick={() => handleIconSelect(i)}
className="flex flex-col items-center gap-1.5 p-2 rounded-lg border hover:border-primary hover:bg-muted transition-colors group"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
className="size-7 group-hover:scale-110 transition-transform"
fill={`#${i.hex}`}
>
<path d={i.path} />
</svg>
<span className="text-[10px] text-muted-foreground capitalize truncate w-full text-center">
{i.title}
</span>
</button>
))}
</div>
{hasMoreIcons && (
<div className="flex justify-center mt-3">
<Button
variant="outline"
size="sm"
onClick={() => setIconsToShow((prev) => prev + 24)}
>
Load More ({filteredIcons.length - iconsToShow} remaining)
</Button>
</div>
)}
</>
)}
</div>
<div className="relative pt-3 border-t">
<p className="text-sm text-muted-foreground text-center mb-3">
or upload a custom icon
</p>
<Dropzone
dropMessage="Drag & drop an icon or click to upload"
accept=".jpg,.jpeg,.png,.svg,image/jpeg,image/png,image/svg+xml"
onChange={handleFileUpload}
classNameWrapper="border-2 border-dashed border-border hover:border-primary bg-muted/30 hover:bg-muted/50 transition-all rounded-lg"
/>
<div className="mt-2 text-center text-xs text-muted-foreground">
Supported formats: JPG, JPEG, PNG, SVG (max 2MB)
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
};
@@ -91,7 +91,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
const containersLength =
option === "native" ? containers?.length : services?.length;
return (
@@ -167,7 +167,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
<SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -1,2 +1,2 @@
export * from "./show-patches";
export * from "./patch-editor";
export * from "./show-patches";
@@ -87,7 +87,7 @@ export const AddPreviewDomain = ({
});
const host = form.watch("host");
const isTraefikMeDomain = host?.includes("traefik.me") || false;
const isTraefikMeDomain = host?.includes("sslip.io") || false;
useEffect(() => {
if (data) {
@@ -162,7 +162,7 @@ export const AddPreviewDomain = ({
<FormItem>
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP
<strong>Note:</strong> sslip.io is a public HTTP
service and does not support SSL/HTTPS. HTTPS and
certificate options will not have any effect.
</AlertBlock>
@@ -202,7 +202,7 @@ export const AddPreviewDomain = ({
sideOffset={5}
className="max-w-[10rem]"
>
<p>Generate traefik.me domain</p>
<p>Generate sslip.io domain</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -88,7 +88,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const form = useForm<Schema>({
defaultValues: {
env: "",
wildcardDomain: "*.traefik.me",
wildcardDomain: "*.sslip.io",
port: 3000,
previewLimit: 3,
previewLabels: [],
@@ -102,7 +102,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
const previewHttps = form.watch("previewHttps");
const wildcardDomain = form.watch("wildcardDomain");
const isTraefikMeDomain = wildcardDomain?.includes("traefik.me") || false;
const isTraefikMeDomain = wildcardDomain?.includes("sslip.io") || false;
useEffect(() => {
setIsEnabled(data?.isPreviewDeploymentsActive || false);
@@ -114,7 +114,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
env: data.previewEnv || "",
buildArgs: data.previewBuildArgs || "",
buildSecrets: data.previewBuildSecrets || "",
wildcardDomain: data.previewWildcard || "*.traefik.me",
wildcardDomain: data.previewWildcard || "*.sslip.io",
port: data.previewPort || 3000,
previewLabels: data.previewLabels || [],
previewLimit: data.previewLimit || 3,
@@ -173,7 +173,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<div className="grid gap-4">
{isTraefikMeDomain && (
<AlertBlock type="info">
<strong>Note:</strong> traefik.me is a public HTTP service and
<strong>Note:</strong> sslip.io is a public HTTP service and
does not support SSL/HTTPS. HTTPS and certificate options will
not have any effect.
</AlertBlock>
@@ -192,7 +192,7 @@ export const ShowPreviewSettings = ({ applicationId }: Props) => {
<FormItem>
<FormLabel>Wildcard Domain</FormLabel>
<FormControl>
<Input placeholder="*.traefik.me" {...field} />
<Input placeholder="*.sslip.io" {...field} />
</FormControl>
<FormMessage />
</FormItem>
@@ -80,6 +80,7 @@ export const commonCronExpressions = [
const formSchema = z
.object({
name: z.string().min(1, "Name is required"),
description: z.string().optional(),
cronExpression: z.string().min(1, "Cron expression is required"),
shellType: z.enum(["bash", "sh"]).default("bash"),
command: z.string(),
@@ -224,6 +225,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
resolver: standardSchemaResolver(formSchema),
defaultValues: {
name: "",
description: "",
cronExpression: "",
shellType: "bash",
command: "",
@@ -263,6 +265,7 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
if (scheduleId && schedule) {
form.reset({
name: schedule.name,
description: schedule.description || "",
cronExpression: schedule.cronExpression,
shellType: schedule.shellType,
command: schedule.command,
@@ -479,6 +482,26 @@ export const HandleSchedules = ({ id, scheduleId, scheduleType }: Props) => {
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input
placeholder="Backs up the database every day at midnight"
{...field}
/>
</FormControl>
<FormDescription>
Optional description of what this schedule does
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<ScheduleFormField
name="cronExpression"
formControl={form.control}
@@ -125,6 +125,11 @@ export const ShowSchedules = ({ id, scheduleType = "application" }: Props) => {
{schedule.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{schedule.description && (
<p className="text-xs text-muted-foreground/70 [overflow-wrap:anywhere] line-clamp-2">
{schedule.description}
</p>
)}
<div className="flex items-center gap-2 text-sm text-muted-foreground flex-wrap">
<Badge
variant="outline"
@@ -483,7 +483,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the
Choose the volume to backup. If you do not see the
volume here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -518,7 +518,7 @@ export const HandleVolumeBackups = ({
</SelectContent>
</Select>
<FormDescription>
Choose the volume to backup, if you dont see the volume
Choose the volume to backup. If you do not see the volume
here, you can type the volume name manually
</FormDescription>
<FormMessage />
@@ -0,0 +1,290 @@
import { Loader2, MoreHorizontal, RefreshCw } from "lucide-react";
import dynamic from "next/dynamic";
import { useState } from "react";
import { toast } from "sonner";
import { ShowContainerConfig } from "@/components/dashboard/docker/config/show-container-config";
import { ShowContainerMounts } from "@/components/dashboard/docker/mounts/show-container-mounts";
import { ShowContainerNetworks } from "@/components/dashboard/docker/networks/show-container-networks";
import { DockerTerminalModal } from "@/components/dashboard/docker/terminal/docker-terminal-modal";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
const DockerLogsId = dynamic(
() =>
import("@/components/dashboard/docker/logs/docker-logs-id").then(
(e) => e.DockerLogsId,
),
{
ssr: false,
},
);
interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
{
appName,
appType,
serverId,
},
{
enabled: !!appName,
},
);
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-xl">Containers</CardTitle>
<CardDescription>
Inspect each container in this compose and run basic lifecycle
actions.
</CardDescription>
</div>
<Button
variant="outline"
size="icon"
onClick={() => refetch()}
disabled={isPending}
>
<RefreshCw className={`h-4 w-4 ${isPending ? "animate-spin" : ""}`} />
</Button>
</CardHeader>
<CardContent>
{isPending ? (
<div className="flex items-center justify-center h-[20vh]">
<Loader2 className="animate-spin h-6 w-6 text-muted-foreground" />
</div>
) : !data || data.length === 0 ? (
<div className="flex items-center justify-center h-[20vh]">
<span className="text-muted-foreground">
No containers found. Deploy the compose to see containers here.
</span>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>State</TableHead>
<TableHead>Status</TableHead>
<TableHead>Container ID</TableHead>
<TableHead className="text-right" />
</TableRow>
</TableHeader>
<TableBody>
{data.map((container) => (
<ContainerRow
key={container.containerId}
container={container}
serverId={serverId}
onActionComplete={() => refetch()}
/>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
};
interface ContainerRowProps {
container: {
containerId: string;
name: string;
state: string;
status: string;
};
serverId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const restartMutation = api.docker.restartContainer.useMutation();
const startMutation = api.docker.startContainer.useMutation();
const stopMutation = api.docker.stopContainer.useMutation();
const killMutation = api.docker.killContainer.useMutation();
const handleAction = async (
action: string,
mutationFn: typeof restartMutation,
) => {
setActionLoading(action);
try {
await mutationFn.mutateAsync({
containerId: container.containerId,
serverId,
});
toast.success(`Container ${action} successfully`);
onActionComplete();
} catch (error) {
toast.error(
`Failed to ${action} container: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setActionLoading(null);
}
};
return (
<TableRow>
<TableCell className="font-medium">{container.name}</TableCell>
<TableCell>
<Badge
variant={
container.state === "running"
? "default"
: container.state === "exited"
? "secondary"
: "destructive"
}
>
{container.state}
</Badge>
</TableCell>
<TableCell>{container.status}</TableCell>
<TableCell className="font-mono text-sm text-muted-foreground">
{container.containerId}
</TableCell>
<TableCell className="text-right">
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
{actionLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DialogTrigger asChild>
<DropdownMenuItem
className="cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Logs
</DropdownMenuItem>
</DialogTrigger>
<ShowContainerConfig
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
>
Terminal
</DockerTerminalModal>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("restart", restartMutation)}
>
Restart
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("start", startMutation)}
>
Start
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer"
disabled={actionLoading !== null}
onClick={() => handleAction("stop", stopMutation)}
>
Stop
</DropdownMenuItem>
<DropdownMenuItem
className="cursor-pointer text-red-500 focus:text-red-600"
disabled={actionLoading !== null}
onClick={() => handleAction("kill", killMutation)}
>
Kill
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<DialogContent className="sm:max-w-7xl">
<DialogHeader>
<DialogTitle>View Logs</DialogTitle>
<DialogDescription>Logs for {container.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-4 pt-2.5">
<DockerLogsId
containerId={container.containerId}
serverId={serverId}
runType="native"
/>
</div>
</DialogContent>
</Dialog>
</TableCell>
</TableRow>
);
};
@@ -49,12 +49,12 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
const composeFile = form.watch("composeFile");
useEffect(() => {
if (data && !composeFile) {
if (data) {
form.reset({
composeFile: data.composeFile || "",
});
}
}, [form, form.reset, data]);
}, [form, data]);
useEffect(() => {
if (data?.composeFile !== undefined) {
@@ -95,7 +95,7 @@ export const ComposeFileEditor = ({ composeId }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending) {
if ((e.ctrlKey || e.metaKey) && e.code === "KeyS" && !isPending) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -57,7 +58,10 @@ const BitbucketProviderSchema = z.object({
slug: z.string().optional(),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
bitbucketId: z.string().min(1, "Bitbucket Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -418,7 +422,7 @@ export const SaveBitbucketProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { HelpCircle, KeyRoundIcon, LockIcon, X } from "lucide-react";
import Link from "next/link";
@@ -41,7 +42,10 @@ const GitProviderSchema = z.object({
repositoryURL: z.string().min(1, {
message: "Repository URL is required",
}),
branch: z.string().min(1, "Branch required"),
branch: z
.string()
.min(1, "Branch required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
sshKey: z.string().optional(),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -55,7 +59,7 @@ interface Props {
export const SaveGitProviderCompose = ({ composeId }: Props) => {
const { data, refetch } = api.compose.one.useQuery({ composeId });
const { data: sshKeys } = api.sshKey.all.useQuery();
const { data: sshKeys } = api.sshKey.allForApps.useQuery();
const router = useRouter();
const { mutateAsync, isPending } = api.compose.update.useMutation();
@@ -1,5 +1,6 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, Plus, X } from "lucide-react";
import { CheckIcon, ChevronsUpDown, HelpCircle, Plus, X } from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
@@ -57,7 +58,10 @@ const GiteaProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
giteaId: z.string().min(1, "Gitea Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -409,10 +413,8 @@ export const SaveGiteaProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
<TooltipTrigger asChild>
<HelpCircle className="size-4 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" />
</TooltipTrigger>
<TooltipContent>
<p>
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, HelpCircle, X } from "lucide-react";
import Link from "next/link";
@@ -55,7 +56,10 @@ const GithubProviderSchema = z.object({
owner: z.string().min(1, "Owner is required"),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
githubId: z.string().min(1, "Github Provider is required"),
watchPaths: z.array(z.string()).optional(),
triggerType: z.enum(["push", "tag"]).default("push"),
@@ -445,7 +449,7 @@ export const SaveGithubProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -1,3 +1,4 @@
import { VALID_BRANCH_REGEX } from "@dokploy/server/utils/git-branch-validation";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { CheckIcon, ChevronsUpDown, X } from "lucide-react";
import Link from "next/link";
@@ -58,7 +59,10 @@ const GitlabProviderSchema = z.object({
gitlabPathNamespace: z.string().min(1),
})
.required(),
branch: z.string().min(1, "Branch is required"),
branch: z
.string()
.min(1, "Branch is required")
.regex(VALID_BRANCH_REGEX, "Invalid branch name"),
gitlabId: z.string().min(1, "Gitlab Provider is required"),
watchPaths: z.array(z.string()).optional(),
enableSubmodules: z.boolean().default(false),
@@ -436,7 +440,7 @@ export const SaveGitlabProviderCompose = ({ composeId }: Props) => {
<FormLabel>Watch Paths</FormLabel>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<TooltipTrigger type="button">
<div className="size-4 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
?
</div>
@@ -77,7 +77,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
}, [option, services, containers]);
const isLoading = option === "native" ? containersLoading : servicesLoading;
const containersLenght =
const containersLength =
option === "native" ? containers?.length : services?.length;
return (
@@ -152,7 +152,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
</>
)}
<SelectLabel>Containers ({containersLenght})</SelectLabel>
<SelectLabel>Containers ({containersLength})</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
@@ -225,7 +225,7 @@ export const RestoreBackup = ({
resolver: zodResolver(RestoreBackupSchema),
});
const destionationId = form.watch("destinationId");
const destinationId = form.watch("destinationId");
const currentDatabaseType = form.watch("databaseType");
const metadata = form.watch("metadata");
@@ -240,12 +240,12 @@ export const RestoreBackup = ({
const { data: files = [], isPending } = api.backup.listBackupFiles.useQuery(
{
destinationId: destionationId,
destinationId: destinationId,
search: debouncedSearchTerm,
serverId: serverId ?? "",
},
{
enabled: isOpen && !!destionationId,
enabled: isOpen && !!destinationId,
},
);
@@ -288,7 +288,6 @@ export const RestoreBackup = ({
toast.error("Please select a database type");
return;
}
console.log({ data });
setIsDeploying(true);
};
@@ -1,8 +1,8 @@
"use client";
import type { inferRouterOutputs } from "@trpc/server";
import Link from "next/link";
import { ArrowRight, ListTodo, Loader2, XCircle } from "lucide-react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -0,0 +1,220 @@
"use client";
import copy from "copy-to-clipboard";
import {
Bot,
Check,
Copy,
Loader2,
RotateCcw,
Settings,
X,
} from "lucide-react";
import Link from "next/link";
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { api } from "@/utils/api";
import type { LogLine } from "./utils";
interface Props {
logs: LogLine[];
context: "build" | "runtime";
}
const MAX_LOG_LINES = 200;
export function AnalyzeLogs({ logs, context }: Props) {
const [open, setOpen] = useState(false);
const [aiId, setAiId] = useState<string>("");
const [copied, setCopied] = useState(false);
const { data: providers } = api.ai.getEnabledProviders.useQuery(undefined, {
enabled: open,
});
const { mutate, isPending, data, reset } = api.ai.analyzeLogs.useMutation({
onError: (error) => {
toast.error("Analysis failed", {
description: error.message,
});
},
});
const handleAnalyze = () => {
if (!aiId || logs.length === 0) return;
const logsText = logs
.slice(-MAX_LOG_LINES)
.map((l) => l.message)
.join("\n");
mutate({ aiId, logs: logsText, context });
};
const handleCopy = () => {
if (!data?.analysis) return;
const success = copy(data.analysis);
if (success) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
return (
<Popover
open={open}
onOpenChange={(isOpen) => {
setOpen(isOpen);
if (!isOpen) {
reset();
setAiId("");
}
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9"
disabled={logs.length === 0}
title="Analyze logs with AI"
>
<Bot className="mr-2 size-4" />
AI
</Button>
</PopoverTrigger>
<PopoverContent className="w-[550px] p-0" align="end">
<div className="flex items-center justify-between border-b px-4 py-3">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4" />
<span className="text-sm font-medium">Log Analysis</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => setOpen(false)}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
<div className="p-4 space-y-3">
{!data?.analysis ? (
providers && providers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-2 text-center">
<p className="text-sm text-muted-foreground">
No AI providers configured. Set up a provider to start
analyzing logs.
</p>
<Button size="sm" variant="outline" asChild>
<Link href="/dashboard/settings/ai">
<Settings className="mr-2 h-3.5 w-3.5" />
Configure AI Provider
</Link>
</Button>
</div>
) : (
<>
<Select value={aiId} onValueChange={setAiId}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select AI provider..." />
</SelectTrigger>
<SelectContent>
{providers?.map((p) => (
<SelectItem key={p.aiId} value={p.aiId}>
{p.name} ({p.model})
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
className="w-full"
disabled={!aiId || isPending || logs.length === 0}
onClick={handleAnalyze}
>
{isPending ? (
<>
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
Analyzing...
</>
) : (
<>
<Bot className="mr-2 h-3.5 w-3.5" />
Analyze{" "}
{logs.length > MAX_LOG_LINES
? `last ${MAX_LOG_LINES}`
: logs.length}{" "}
lines
</>
)}
</Button>
</>
)
) : (
<>
<div className="max-h-[400px] overflow-y-auto">
<div className="prose prose-sm dark:prose-invert max-w-none text-sm break-words">
<ReactMarkdown>{data.analysis}</ReactMarkdown>
</div>
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
className="flex-1"
onClick={() => {
reset();
handleAnalyze();
}}
disabled={isPending}
>
{isPending ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcw className="mr-2 h-3.5 w-3.5" />
)}
Re-analyze
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCopy}
title="Copy analysis to clipboard"
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
reset();
setAiId("");
}}
title="Change provider"
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
</>
)}
</div>
</PopoverContent>
</Popover>
);
}
@@ -12,6 +12,7 @@ import { AlertBlock } from "@/components/shared/alert-block";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import { AnalyzeLogs } from "./analyze-logs";
import { LineCountFilter } from "./line-count-filter";
import { SinceLogsFilter, type TimeFilter } from "./since-logs-filter";
import { StatusLogsFilter } from "./status-logs-filter";
@@ -346,11 +347,13 @@ export const DockerLogsId: React.FC<Props> = ({
title={isPaused ? "Resume logs" : "Pause logs"}
>
{isPaused ? (
<Play className="mr-2 h-4 w-4" />
<Play className="size-4" />
) : (
<Pause className="mr-2 h-4 w-4" />
<Pause className="size-4" />
)}
{isPaused ? "Resume" : "Pause"}
<span className="hidden lg:ml-2 lg:inline">
{isPaused ? "Resume" : "Pause"}
</span>
</Button>
<Button
variant="outline"
@@ -361,11 +364,13 @@ export const DockerLogsId: React.FC<Props> = ({
title="Copy logs to clipboard"
>
{copied ? (
<Check className="mr-2 h-4 w-4" />
<Check className="size-4" />
) : (
<Copy className="mr-2 h-4 w-4" />
<Copy className="size-4" />
)}
Copy
<span className="hidden lg:ml-2 lg:inline">
{copied ? "Copied" : "Copy"}
</span>
</Button>
<Button
variant="outline"
@@ -373,16 +378,18 @@ export const DockerLogsId: React.FC<Props> = ({
className="h-9 sm:w-auto w-full"
onClick={handleDownload}
disabled={filteredLogs.length === 0 || !data?.Name}
title="Download logs as text file"
>
<DownloadIcon className="mr-2 h-4 w-4" />
Download logs
<DownloadIcon className="size-4" />
<span className="hidden lg:ml-2 lg:inline">Download logs</span>
</Button>
<AnalyzeLogs logs={filteredLogs} context="runtime" />
</div>
</div>
{isPaused && (
<AlertBlock type="warning">
<AlertBlock type="warning" className="items-center">
<div className="flex items-center gap-2">
<Pause className="h-4 w-4" />
<Pause className="size-4" />
<span>
Logs paused
{messageBuffer.length > 0 && (
@@ -103,7 +103,7 @@ export function TerminalLine({ log, noTimestamp, searchTerm }: LogLineProps) {
>
{" "}
<div className="flex items-start gap-x-2">
{/* Icon to expand the log item maybe implement a colapsible later */}
{/* Icon to expand the log item maybe implement a collapsible later */}
{/* <Square className="size-4 text-muted-foreground opacity-0 group-hover/logitem:opacity-100 transition-opacity" /> */}
{tooltip(color, rawTimestamp)}
{!noTimestamp && (
@@ -74,6 +74,18 @@ export function parseLogs(logString: string): LogLine[] {
// Detect log type based on message content
export const getLogType = (message: string): LogStyle => {
// Detect HTTP statusCode
const statusMatch = message.match(/"statusCode"\s*:\s*"?(\d{3})"?/);
if (statusMatch) {
const statusCode = Number(statusMatch[1]);
if (statusCode >= 500) return LOG_STYLES.error;
if (statusCode >= 400) return LOG_STYLES.warning;
if (statusCode >= 200 && statusCode < 300) return LOG_STYLES.success;
return LOG_STYLES.info;
}
const lowerMessage = message.toLowerCase();
if (
@@ -0,0 +1,112 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Mount {
Type: string;
Source: string;
Destination: string;
Mode: string;
RW: boolean;
Propagation: string;
Name?: string;
Driver?: string;
}
export const ShowContainerMounts = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const mounts: Mount[] = data?.Mounts ?? [];
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Mounts
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Mounts</DialogTitle>
<DialogDescription>
Volume and bind mounts for this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{mounts.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No mounts found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Source</TableHead>
<TableHead>Destination</TableHead>
<TableHead>Mode</TableHead>
<TableHead>Read/Write</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{mounts.map((mount, index) => (
<TableRow key={index}>
<TableCell>
<Badge variant="outline">{mount.Type}</Badge>
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Name || mount.Source}
</TableCell>
<TableCell className="font-mono text-xs max-w-[250px] truncate">
{mount.Destination}
</TableCell>
<TableCell className="text-xs">
{mount.Mode || "-"}
</TableCell>
<TableCell>
<Badge variant={mount.RW ? "default" : "secondary"}>
{mount.RW ? "RW" : "RO"}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,119 @@
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
interface Network {
IPAMConfig: unknown;
Links: unknown;
Aliases: string[] | null;
MacAddress: string;
NetworkID: string;
EndpointID: string;
Gateway: string;
IPAddress: string;
IPPrefixLen: number;
IPv6Gateway: string;
GlobalIPv6Address: string;
GlobalIPv6PrefixLen: number;
DriverOpts: unknown;
}
export const ShowContainerNetworks = ({ containerId, serverId }: Props) => {
const { data } = api.docker.getConfig.useQuery(
{
containerId,
serverId,
},
{
enabled: !!containerId,
},
);
const networks: Record<string, Network> =
data?.NetworkSettings?.Networks ?? {};
const entries = Object.entries(networks);
return (
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer"
onSelect={(e) => e.preventDefault()}
>
View Networks
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Container Networks</DialogTitle>
<DialogDescription>
Networks attached to this container
</DialogDescription>
</DialogHeader>
<div className="overflow-auto max-h-[70vh]">
{entries.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
No networks found for this container.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Network</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Gateway</TableHead>
<TableHead>MAC Address</TableHead>
<TableHead>Aliases</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{entries.map(([name, network]) => (
<TableRow key={name}>
<TableCell>
<Badge variant="outline">{name}</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{network.IPAddress
? `${network.IPAddress}/${network.IPPrefixLen}`
: "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.Gateway || "-"}
</TableCell>
<TableCell className="font-mono text-xs">
{network.MacAddress || "-"}
</TableCell>
<TableCell className="text-xs">
{network.Aliases?.join(", ") || "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,66 @@
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { api } from "@/utils/api";
interface Props {
containerId: string;
serverId?: string;
}
export const RemoveContainerDialog = ({ containerId, serverId }: Props) => {
const utils = api.useUtils();
const { mutateAsync, isPending } = api.docker.removeContainer.useMutation();
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer text-red-500 hover:!text-red-600"
onSelect={(e) => e.preventDefault()}
>
Remove Container
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the container{" "}
<span className="font-semibold">{containerId}</span>. If the
container is running, it will be forcefully stopped and removed.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isPending}
onClick={async () => {
await mutateAsync({ containerId, serverId })
.then(async () => {
toast.success("Container removed successfully");
await utils.docker.getContainers.invalidate();
})
.catch((err) => {
toast.error(err.message);
});
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
@@ -10,7 +10,11 @@ import {
} from "@/components/ui/dropdown-menu";
import { ShowContainerConfig } from "../config/show-container-config";
import { ShowDockerModalLogs } from "../logs/show-docker-modal-logs";
import { ShowContainerMounts } from "../mounts/show-container-mounts";
import { ShowContainerNetworks } from "../networks/show-container-networks";
import { RemoveContainerDialog } from "../remove/remove-container";
import { DockerTerminalModal } from "../terminal/docker-terminal-modal";
import { UploadFileModal } from "../upload/upload-file-modal";
import type { Container } from "./show-containers";
export const columns: ColumnDef<Container>[] = [
@@ -121,12 +125,30 @@ export const columns: ColumnDef<Container>[] = [
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerMounts
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<ShowContainerNetworks
containerId={container.containerId}
serverId={container.serverId || ""}
/>
<DockerTerminalModal
containerId={container.containerId}
serverId={container.serverId || ""}
>
Terminal
</DockerTerminalModal>
<UploadFileModal
containerId={container.containerId}
serverId={container.serverId || undefined}
>
Upload File
</UploadFileModal>
<RemoveContainerDialog
containerId={container.containerId}
serverId={container.serverId ?? undefined}
/>
</DropdownMenuContent>
</DropdownMenu>
);
@@ -35,7 +35,7 @@ import {
TableRow,
} from "@/components/ui/table";
import { api, type RouterOutputs } from "@/utils/api";
import { columns } from "./colums";
import { columns } from "./columns";
export type Container = NonNullable<
RouterOutputs["docker"]["getContainers"]
>[0];
@@ -0,0 +1,187 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Upload } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Dropzone } from "@/components/ui/dropzone";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
import {
type UploadFileToContainer,
uploadFileToContainerSchema,
} from "@/utils/schema";
interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
}
export const UploadFileModal = ({ children, containerId, serverId }: Props) => {
const [open, setOpen] = useState(false);
const { mutateAsync: uploadFile, isPending: isLoading } =
api.docker.uploadFileToContainer.useMutation({
onSuccess: () => {
toast.success("File uploaded successfully");
setOpen(false);
form.reset();
},
onError: (error) => {
toast.error(error.message || "Failed to upload file to container");
},
});
const form = useForm({
resolver: zodResolver(uploadFileToContainerSchema),
defaultValues: {
containerId,
destinationPath: "/",
serverId: serverId || undefined,
},
});
const file = form.watch("file");
const onSubmit = async (values: UploadFileToContainer) => {
if (!values.file) {
toast.error("Please select a file to upload");
return;
}
const formData = new FormData();
formData.append("containerId", values.containerId);
formData.append("file", values.file);
formData.append("destinationPath", values.destinationPath);
if (values.serverId) {
formData.append("serverId", values.serverId);
}
await uploadFile(formData);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
{children}
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Upload File to Container
</DialogTitle>
<DialogDescription>
Upload a file directly into the container's filesystem
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="destinationPath"
render={({ field }) => (
<FormItem>
<FormLabel>Destination Path</FormLabel>
<FormControl>
<Input
{...field}
placeholder="/path/to/file"
className="font-mono"
/>
</FormControl>
<FormMessage />
<p className="text-xs text-muted-foreground">
Enter the full path where the file should be uploaded in the
container (e.g., /app/config.json)
</p>
</FormItem>
)}
/>
<FormField
control={form.control}
name="file"
render={({ field }) => (
<FormItem>
<FormLabel>File</FormLabel>
<FormControl>
<Dropzone
{...field}
dropMessage="Drop file here or click to browse"
onChange={(files) => {
if (files && files.length > 0) {
field.onChange(files[0]);
} else {
field.onChange(null);
}
}}
/>
</FormControl>
<FormMessage />
{file instanceof File && (
<div className="flex items-center gap-2 p-2 bg-muted rounded-md">
<span className="text-sm text-muted-foreground flex-1">
{file.name} ({(file.size / 1024).toFixed(2)} KB)
</span>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => field.onChange(null)}
>
Remove
</Button>
</div>
)}
</FormItem>
)}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
isLoading={isLoading}
disabled={!file || isLoading}
>
Upload File
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -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 { AlertBlock } from "@/components/shared/alert-block";
import {
@@ -68,12 +75,22 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
</div>
)}
{directories?.length === 0 && (
<div className="w-full flex-col gap-2 flex items-center justify-center h-[55vh]">
<span className="text-muted-foreground text-lg font-medium">
No directories or files detected in{" "}
{"'/etc/dokploy/traefik'"}
</span>
<Folder className="size-8 text-muted-foreground" />
<div className="w-full flex-col gap-4 flex items-center justify-center h-[55vh] border border-dashed rounded-lg">
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
<FolderOpen className="size-7 text-muted-foreground" />
</div>
<div className="flex flex-col items-center gap-1 text-center px-4">
<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>
)}
{directories && directories?.length > 0 && (
@@ -89,11 +106,19 @@ export const ShowTraefikSystem = ({ serverId }: Props) => {
{file ? (
<ShowTraefikFile path={file} serverId={serverId} />
) : (
<div className="h-full w-full flex-col gap-2 flex items-center justify-center">
<span className="text-muted-foreground text-lg font-medium">
No file selected
</span>
<FileIcon className="size-8 text-muted-foreground" />
<div className="h-full min-h-[300px] w-full flex-col gap-4 flex items-center justify-center border border-dashed rounded-lg">
<div className="flex items-center justify-center size-14 rounded-full bg-muted">
<MousePointerClick className="size-7 text-muted-foreground" />
</div>
<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>
@@ -0,0 +1,291 @@
import { formatDistanceToNow } from "date-fns";
import { ArrowRight, Rocket, Server } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { api } from "@/utils/api";
type DeploymentStatus = "idle" | "running" | "done" | "error";
const statusDotClass: Record<string, string> = {
done: "bg-emerald-500",
running: "bg-amber-500",
error: "bg-red-500",
idle: "bg-muted-foreground/40",
};
function getServiceInfo(d: any) {
const app = d.application;
const comp = d.compose;
const serverName: string =
d.server?.name ?? app?.server?.name ?? comp?.server?.name ?? "Dokploy";
if (app?.environment?.project && app.environment) {
return {
name: app.name as string,
environment: app.environment.name as string,
projectName: app.environment.project.name as string,
serverName,
href: `/dashboard/project/${app.environment.project.projectId}/environment/${app.environment.environmentId}/services/application/${app.applicationId}`,
};
}
if (comp?.environment?.project && comp.environment) {
return {
name: comp.name as string,
environment: comp.environment.name as string,
projectName: comp.environment.project.name as string,
serverName,
href: `/dashboard/project/${comp.environment.project.projectId}/environment/${comp.environment.environmentId}/services/compose/${comp.composeId}`,
};
}
return null;
}
function StatCard({
label,
value,
delta,
}: {
label: string;
value: string;
delta?: string;
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col justify-between">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<div className="flex flex-col gap-1">
<span className="text-3xl font-semibold tracking-tight">{value}</span>
{delta && (
<span className="text-xs text-muted-foreground">{delta}</span>
)}
</div>
</div>
);
}
function StatusListCard({
label,
items,
}: {
label: string;
items: { dotClass: string; label: string; count: number }[];
}) {
return (
<div className="rounded-xl border bg-background p-5 min-h-[140px] flex flex-col gap-3">
<span className="text-xs uppercase tracking-wider text-muted-foreground">
{label}
</span>
<ul className="flex flex-col gap-1.5">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2.5 text-sm">
<span
className={`size-2 rounded-full shrink-0 ${item.dotClass}`}
aria-hidden
/>
<span className="font-semibold tabular-nums w-8">{item.count}</span>
<span className="text-muted-foreground">{item.label}</span>
</li>
))}
</ul>
</div>
);
}
export const ShowHome = () => {
const { data: auth } = api.user.get.useQuery();
const { data: homeStats } = api.project.homeStats.useQuery();
const { data: permissions } = api.user.getPermissions.useQuery();
const canReadDeployments = !!permissions?.deployment.read;
const { data: deployments } = api.deployment.allCentralized.useQuery(
undefined,
{
enabled: canReadDeployments,
refetchInterval: 10000,
},
);
const firstName = auth?.user?.firstName?.trim();
const totals = homeStats ?? {
projects: 0,
environments: 0,
applications: 0,
compose: 0,
databases: 0,
services: 0,
};
const statusBreakdown = homeStats?.status ?? {
running: 0,
error: 0,
idle: 0,
};
const recentDeployments = useMemo(() => {
if (!deployments) return [];
return [...deployments]
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
)
.slice(0, 10);
}, [deployments]);
const deployStats = useMemo(() => {
const now = Date.now();
const weekMs = 7 * 24 * 60 * 60 * 1000;
const lastStart = now - weekMs;
const prevStart = now - 2 * weekMs;
const last: NonNullable<typeof deployments> = [];
const prev: NonNullable<typeof deployments> = [];
for (const d of deployments ?? []) {
const t = new Date(d.createdAt).getTime();
if (t >= lastStart) last.push(d);
else if (t >= prevStart) prev.push(d);
}
const lastCount = last.length;
const prevCount = prev.length;
let delta: string | undefined;
if (prevCount > 0) {
const pct = Math.round(((lastCount - prevCount) / prevCount) * 100);
delta = `${pct >= 0 ? "+" : ""}${pct}% vs prev 7d`;
} else if (lastCount > 0) {
delta = "no prior data";
} else {
delta = "no activity yet";
}
return { value: String(lastCount), delta };
}, [deployments]);
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl min-h-[85vh]">
<div className="rounded-xl bg-background shadow-md p-6 flex flex-col gap-6 h-full">
<div className="flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
<h1 className="text-3xl font-semibold tracking-tight">
{firstName ? `Welcome back, ${firstName}` : "Welcome back"}
</h1>
<Button asChild variant="secondary" className="w-fit">
<Link href="/dashboard/projects">
Go to projects
<ArrowRight className="size-4" />
</Link>
</Button>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="Projects"
value={String(totals.projects)}
delta={`${totals.environments} ${totals.environments === 1 ? "environment" : "environments"}`}
/>
<StatCard
label="Services"
value={String(totals.services)}
delta={`${totals.applications} apps · ${totals.compose} compose · ${totals.databases} db`}
/>
<StatCard
label="Deploys / 7d"
value={deployStats.value}
delta={deployStats.delta}
/>
<StatusListCard
label="Status"
items={[
{
dotClass: "bg-emerald-500",
label: "running",
count: statusBreakdown.running,
},
{
dotClass: "bg-red-500",
label: "errored",
count: statusBreakdown.error,
},
{
dotClass: "bg-muted-foreground/40",
label: "idle",
count: statusBreakdown.idle,
},
]}
/>
</div>
<div className="rounded-xl border bg-background">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex items-center gap-2">
<Rocket className="size-4 text-muted-foreground" />
<h2 className="text-sm font-semibold">Recent deployments</h2>
</div>
{canReadDeployments && (
<Link
href="/dashboard/deployments"
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
view all
</Link>
)}
</div>
{!canReadDeployments ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>You do not have permission to view deployments.</span>
</div>
) : recentDeployments.length === 0 ? (
<div className="min-h-[400px] flex flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground p-10">
<Rocket className="size-8 opacity-40" />
<span>No deployments yet.</span>
</div>
) : (
<ul className="divide-y">
{recentDeployments.map((d) => {
const info = getServiceInfo(d);
if (!info) return null;
const status = (d.status ?? "idle") as DeploymentStatus;
return (
<li key={d.deploymentId}>
<Link
href={info.href}
className="flex items-center gap-4 px-5 py-4 hover:bg-muted/40 transition-colors"
>
<span
className={`size-2 rounded-full shrink-0 ${statusDotClass[status] ?? statusDotClass.idle}`}
aria-hidden
/>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm truncate">{info.name}</span>
<span className="text-xs text-muted-foreground truncate">
{info.projectName} · {info.environment}
</span>
</div>
<span className="text-xs text-muted-foreground w-36 hidden lg:flex items-center justify-end gap-1.5 truncate">
<Server className="size-3 shrink-0" />
<span className="truncate">{info.serverName}</span>
</span>
<span className="text-xs text-muted-foreground w-20 text-right hidden sm:inline">
{status}
</span>
<span className="text-xs text-muted-foreground w-24 text-right hidden md:inline">
{formatDistanceToNow(new Date(d.createdAt), {
addSuffix: true,
})}
</span>
<span className="text-xs text-muted-foreground hover:text-foreground transition-colors">
logs
</span>
</Link>
</li>
);
})}
</ul>
)}
</div>
</div>
</Card>
</div>
);
};
@@ -1,4 +1,6 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -9,6 +11,9 @@ interface Props {
}
export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
const { data } = api.mariadb.one.useQuery({ mariadbId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mariadb.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -28,20 +33,43 @@ export const ShowInternalMariadbCredentials = ({ mariadbId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
/>
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mariadbId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mariadb.one.invalidate({ mariadbId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
@@ -82,7 +82,8 @@ export const ShowExternalMongoCredentials = ({ mongoId }: Props) => {
const buildConnectionUrl = () => {
const port = form.watch("externalPort") || data?.externalPort;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}`;
const params = `authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`;
return `mongodb://${data?.databaseUser}:${data?.databasePassword}@${getIp}:${port}/?${params}`;
};
setConnectionUrl(buildConnectionUrl());
@@ -1,4 +1,6 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -9,6 +11,9 @@ interface Props {
}
export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
const { data } = api.mongo.one.useQuery({ mongoId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mongo.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -25,11 +30,21 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mongoId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.mongo.one.invalidate({ mongoId });
}}
/>
</div>
</div>
@@ -47,7 +62,7 @@ export const ShowInternalMongoCredentials = ({ mongoId }: Props) => {
<Label>Internal Connection URL </Label>
<ToggleVisibilityInput
disabled
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017`}
value={`mongodb://${data?.databaseUser}:${data?.databasePassword}@${data?.appName}:27017/?authSource=admin${data?.replicaSets ? "" : "&directConnection=true"}`}
/>
</div>
</div>
@@ -1,103 +1,103 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["block"];
accumulativeData: DockerStatsJSON["block"];
}
export const DockerBlockChart = ({ acummulativeData }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
};
});
const chartConfig = {
readMb: {
label: "Read (MB)",
color: "hsl(var(--chart-1))",
},
writeMb: {
label: "Write (MB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerBlockChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
readMb: item.value.readMb,
writeMb: item.value.writeMb,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="#8884d8" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorWrite" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#82ca9d" stopOpacity={0.8} />
<stop offset="95%" stopColor="#82ca9d" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="readMb"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
name="Read Mb"
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorWrite)"
name="Write Mb"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillBlockRead" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-readMb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-readMb)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillBlockWrite" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-writeMb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-writeMb)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value, name) => {
const label = name === "readMb" ? "Read" : "Write";
return [`${value} MB`, label];
}}
/>
}
/>
<Area
type="monotone"
dataKey="readMb"
stroke="var(--color-readMb)"
fill="url(#fillBlockRead)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="writeMb"
stroke="var(--color-writeMb)"
fill="url(#fillBlockWrite)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
readMb: number;
writeMb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Read ${payload[0].payload.readMb} `}</p>
<p>{`Write: ${payload[0].payload.writeMb} `}</p>
</div>
);
}
return null;
};
@@ -1,87 +1,81 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["cpu"];
accumulativeData: DockerStatsJSON["cpu"];
}
export const DockerCpuChart = ({ acummulativeData }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
};
});
const chartConfig = {
usage: {
label: "CPU Usage",
color: "hsl(var(--chart-1))",
},
} satisfies ChartConfig;
export const DockerCpuChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
name: `Point ${index + 1}`,
time: item.time,
usage: item.value.toString().split("%")[0],
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, 100]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usage"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillCpu" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usage)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usage)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
tickFormatter={(value) => `${value}%`}
domain={[0, 100]}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value}%`, "CPU Usage"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillCpu)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`CPU Usage: ${payload[0].payload.usage}%`}</p>
</div>
);
}
return null;
};
@@ -1,105 +1,82 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["disk"];
accumulativeData: DockerStatsJSON["disk"];
diskTotal: number;
}
export const DockerDiskChart = ({ acummulativeData, diskTotal }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
freeGb: item.value.diskFree,
};
});
const chartConfig = {
usedGb: {
label: "Used (GB)",
color: "hsl(var(--chart-3))",
},
} satisfies ChartConfig;
export const DockerDiskChart = ({ accumulativeData, diskTotal }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
usedGb: +item.value.diskUsage,
totalGb: +item.value.diskTotal,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUsed" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.8} />
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorFree" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#6C28D9" stopOpacity={0.2} />
<stop offset="95%" stopColor="#6C28D9" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, diskTotal]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usedGb"
stroke="#6C28D9"
fillOpacity={1}
fill="url(#colorUsed)"
name="Used GB"
/>
<Area
type="monotone"
dataKey="freeGb"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorFree)"
name="Free GB"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillDiskUsed" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usedGb)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usedGb)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
domain={[0, diskTotal]}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value} GB`}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => {
return [`${value} GB`, "Used"];
}}
/>
}
/>
<Area
type="monotone"
dataKey="usedGb"
stroke="var(--color-usedGb)"
fill="url(#fillDiskUsed)"
strokeWidth={2}
/>
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usedGb: number;
freeGb: number;
totalGb: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
<p>{`Disk usage: ${payload[0].payload.usedGb} GB`}</p>
<p>{`Disk free: ${payload[0].payload.freeGb} GB`}</p>
<p>{`Total disk: ${payload[0].payload.totalGb} GB`}</p>
</div>
);
}
return null;
};
@@ -0,0 +1,182 @@
import { Loader2, RefreshCw } from "lucide-react";
import { useMemo } from "react";
import { Cell, Label, Pie, PieChart } from "recharts";
import { Button } from "@/components/ui/button";
import {
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import { api } from "@/utils/api";
const TYPE_TO_KEY: Record<string, string> = {
Images: "images",
Containers: "containers",
"Local Volumes": "volumes",
"Build Cache": "buildCache",
};
const chartConfig = {
value: {
label: "Size",
},
images: {
label: "Images",
color: "hsl(var(--chart-1))",
},
containers: {
label: "Containers",
color: "hsl(var(--chart-2))",
},
volumes: {
label: "Volumes",
color: "hsl(var(--chart-3))",
},
buildCache: {
label: "Build Cache",
color: "hsl(var(--chart-4))",
},
} satisfies ChartConfig;
const formatSize = (bytes: number): string => {
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
};
export const DockerDiskUsageChart = () => {
const { data, isLoading, refetch, isRefetching } =
api.settings.getDockerDiskUsage.useQuery(undefined, {
refetchOnWindowFocus: false,
});
const { chartData, totalBytes } = useMemo(() => {
const items =
data
?.filter((item) => item.sizeBytes > 0)
.map((item) => {
const key = TYPE_TO_KEY[item.type] ?? item.type;
return {
name: key,
value: item.sizeBytes,
size: item.size,
active: item.active,
totalCount: item.totalCount,
reclaimable: item.reclaimable,
fill: `var(--color-${key})`,
};
}) ?? [];
return {
chartData: items,
totalBytes: items.reduce((sum, item) => sum + item.value, 0),
};
}, [data]);
if (isLoading) {
return (
<div className="flex items-center justify-center h-[16rem]">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
);
}
if (chartData.length === 0) {
return (
<p className="text-xs text-muted-foreground mt-4">
No Docker disk usage data available.
</p>
);
}
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Total: {formatSize(totalBytes)}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetch()}
disabled={isRefetching}
>
<RefreshCw
className={`size-3.5 ${isRefetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
<ChartContainer
config={chartConfig}
className="mx-auto w-full max-h-[250px] [&_.recharts-pie-label-text]:fill-foreground"
>
<PieChart>
<ChartTooltip
content={
<ChartTooltipContent
nameKey="name"
formatter={(value, name) => {
const item = chartData.find((d) => d.name === name);
if (!item) return [formatSize(value as number), name];
return [
`${item.size}${item.active} active / ${item.totalCount} total — Reclaimable: ${item.reclaimable}`,
chartConfig[name as keyof typeof chartConfig]?.label ??
name,
];
}}
/>
}
/>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
innerRadius={60}
outerRadius={85}
strokeWidth={3}
stroke="hsl(var(--background))"
minAngle={15}
>
{chartData.map((entry) => (
<Cell key={entry.name} fill={entry.fill} />
))}
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 8}
className="fill-foreground text-2xl font-bold"
>
{formatSize(totalBytes)}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 14}
className="fill-muted-foreground text-xs"
>
Docker Usage
</tspan>
</text>
);
}
}}
/>
</Pie>
<ChartLegend content={<ChartLegendContent nameKey="name" />} />
</PieChart>
</ChartContainer>
</div>
);
};
@@ -1,93 +1,87 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
import { convertMemoryToBytes } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["memory"];
accumulativeData: DockerStatsJSON["memory"];
memoryLimitGB: number;
}
const chartConfig = {
usage: {
label: "Memory (GB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerMemoryChart = ({
acummulativeData,
accumulativeData,
memoryLimitGB,
}: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
};
});
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
// @ts-ignore
usage: (convertMemoryToBytes(item.value.used) / 1024 ** 3).toFixed(2),
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" domain={[0, +memoryLimitGB.toFixed(2)]} />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="usage"
stroke="#27272A"
fillOpacity={1}
fill="url(#colorUv)"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillMemory" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-usage)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-usage)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis
tickFormatter={(value) => `${value} GB`}
domain={[0, +memoryLimitGB.toFixed(2)]}
tickLine={false}
axisLine={false}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value) => [`${value} GB`, "Memory"]}
/>
}
/>
<Area
type="monotone"
dataKey="usage"
stroke="var(--color-usage)"
fill="url(#fillMemory)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
usage: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0] && payload[0].payload) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`Memory usage: ${payload[0].payload.usage} GB`}</p>
</div>
);
}
return null;
};
@@ -1,99 +1,99 @@
import { format } from "date-fns";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts";
import {
Area,
AreaChart,
CartesianGrid,
Legend,
ResponsiveContainer,
Tooltip,
YAxis,
} from "recharts";
type ChartConfig,
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import type { DockerStatsJSON } from "./show-free-container-monitoring";
interface Props {
acummulativeData: DockerStatsJSON["network"];
accumulativeData: DockerStatsJSON["network"];
}
export const DockerNetworkChart = ({ acummulativeData }: Props) => {
const transformedData = acummulativeData.map((item, index) => {
return {
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
};
});
const chartConfig = {
inMB: {
label: "In (MB)",
color: "hsl(var(--chart-1))",
},
outMB: {
label: "Out (MB)",
color: "hsl(var(--chart-2))",
},
} satisfies ChartConfig;
export const DockerNetworkChart = ({ accumulativeData }: Props) => {
const transformedData = accumulativeData.map((item, index) => ({
time: item.time,
name: `Point ${index + 1}`,
inMB: item.value.inputMb,
outMB: item.value.outputMb,
}));
return (
<div className="mt-6 w-full h-[10rem]">
<ResponsiveContainer>
<AreaChart
data={transformedData}
margin={{
top: 10,
right: 30,
left: 0,
bottom: 0,
}}
>
<defs>
<linearGradient id="colorUv" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#27272A" stopOpacity={0.8} />
<stop offset="95%" stopColor="white" stopOpacity={0} />
</linearGradient>
</defs>
<YAxis stroke="#A1A1AA" />
<CartesianGrid strokeDasharray="3 3" stroke="#27272A" />
{/* @ts-ignore */}
<Tooltip content={<CustomTooltip />} />
<Legend />
<Area
type="monotone"
dataKey="inMB"
stroke="#8884d8"
fillOpacity={1}
fill="url(#colorUv)"
name="In MB"
/>
<Area
type="monotone"
dataKey="outMB"
stroke="#82ca9d"
fillOpacity={1}
fill="url(#colorUv)"
name="Out MB"
/>
</AreaChart>
</ResponsiveContainer>
</div>
<ChartContainer config={chartConfig} className="mt-4 h-[10rem] w-full">
<AreaChart
data={transformedData}
margin={{ top: 10, right: 10, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillNetIn" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-inMB)" stopOpacity={0.8} />
<stop
offset="95%"
stopColor="var(--color-inMB)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient id="fillNetOut" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--color-outMB)"
stopOpacity={0.8}
/>
<stop
offset="95%"
stopColor="var(--color-outMB)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid vertical={false} />
<YAxis tickLine={false} axisLine={false} />
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const time = payload?.[0]?.payload?.time;
return time ? format(new Date(time), "PPpp") : "";
}}
formatter={(value, name) => {
const label = name === "inMB" ? "In" : "Out";
return [`${value} MB`, label];
}}
/>
}
/>
<Area
type="monotone"
dataKey="inMB"
stroke="var(--color-inMB)"
fill="url(#fillNetIn)"
strokeWidth={2}
/>
<Area
type="monotone"
dataKey="outMB"
stroke="var(--color-outMB)"
fill="url(#fillNetOut)"
strokeWidth={2}
/>
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
);
};
interface CustomTooltipProps {
active: boolean;
payload?: {
color?: string;
dataKey?: string;
value?: number;
payload: {
time: string;
inMB: number;
outMB: number;
};
}[];
}
const CustomTooltip = ({ active, payload }: CustomTooltipProps) => {
if (active && payload && payload.length && payload[0]) {
return (
<div className="custom-tooltip bg-background p-2 shadow-lg rounded-md text-primary border">
{payload[0].payload.time && (
<p>{`Date: ${format(new Date(payload[0].payload.time), "PPpp")}`}</p>
)}
<p>{`In Usage: ${payload[0].payload.inMB} `}</p>
<p>{`Out Usage: ${payload[0].payload.outMB} `}</p>
</div>
);
}
return null;
};
@@ -5,6 +5,7 @@ import { api } from "@/utils/api";
import { DockerBlockChart } from "./docker-block-chart";
import { DockerCpuChart } from "./docker-cpu-chart";
import { DockerDiskChart } from "./docker-disk-chart";
import { DockerDiskUsageChart } from "./docker-disk-usage-chart";
import { DockerMemoryChart } from "./docker-memory-chart";
import { DockerNetworkChart } from "./docker-network-chart";
@@ -124,7 +125,7 @@ export const ContainerFreeMonitoring = ({
refetchOnWindowFocus: false,
},
);
const [acummulativeData, setAcummulativeData] = useState<DockerStatsJSON>({
const [accumulativeData, setAccumulativeData] = useState<DockerStatsJSON>({
cpu: [],
memory: [],
block: [],
@@ -136,7 +137,7 @@ export const ContainerFreeMonitoring = ({
useEffect(() => {
setCurrentData(defaultData);
setAcummulativeData({
setAccumulativeData({
cpu: [],
memory: [],
block: [],
@@ -155,7 +156,7 @@ export const ContainerFreeMonitoring = ({
network: data.network[data.network.length - 1] ?? currentData.network,
disk: data.disk[data.disk.length - 1] ?? currentData.disk,
});
setAcummulativeData({
setAccumulativeData({
block: data?.block || [],
cpu: data?.cpu || [],
disk: data?.disk || [],
@@ -184,7 +185,7 @@ export const ContainerFreeMonitoring = ({
setCurrentData(data);
const MAX_DATA_POINTS = 300;
setAcummulativeData((prevData) => ({
setAccumulativeData((prevData) => ({
cpu: [...prevData.cpu, data.cpu].slice(-MAX_DATA_POINTS),
memory: [...prevData.memory, data.memory].slice(-MAX_DATA_POINTS),
block: [...prevData.block, data.block].slice(-MAX_DATA_POINTS),
@@ -219,16 +220,16 @@ export const ContainerFreeMonitoring = ({
<CardContent>
<div className="flex flex-col gap-2 w-full">
<span className="text-sm text-muted-foreground">
Used: {currentData.cpu.value}
Used: {String(currentData.cpu.value ?? "0%")}
</span>
<Progress
value={Number.parseInt(
currentData.cpu.value.replace("%", ""),
String(currentData.cpu.value ?? "0%").replace("%", ""),
10,
)}
className="w-[100%]"
/>
<DockerCpuChart acummulativeData={acummulativeData.cpu} />
<DockerCpuChart accumulativeData={accumulativeData.cpu} />
</div>
</CardContent>
</Card>
@@ -252,7 +253,7 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]"
/>
<DockerMemoryChart
acummulativeData={acummulativeData.memory}
accumulativeData={accumulativeData.memory}
memoryLimitGB={
// @ts-ignore
convertMemoryToBytes(currentData.memory.value.total) /
@@ -277,13 +278,25 @@ export const ContainerFreeMonitoring = ({
className="w-[100%]"
/>
<DockerDiskChart
acummulativeData={acummulativeData.disk}
accumulativeData={accumulativeData.disk}
diskTotal={currentData.disk.value.diskTotal}
/>
</div>
</CardContent>
</Card>
)}
{appName === "dokploy" && (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
Docker Disk Usage
</CardTitle>
</CardHeader>
<CardContent>
<DockerDiskUsageChart />
</CardContent>
</Card>
)}
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
@@ -294,7 +307,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground">
{`Read: ${currentData.block.value.readMb} / Write: ${currentData.block.value.writeMb} `}
</span>
<DockerBlockChart acummulativeData={acummulativeData.block} />
<DockerBlockChart accumulativeData={accumulativeData.block} />
</div>
</CardContent>
</Card>
@@ -307,7 +320,7 @@ export const ContainerFreeMonitoring = ({
<span className="text-sm text-muted-foreground">
{`In MB: ${currentData.network.value.inputMb} / Out MB: ${currentData.network.value.outputMb} `}
</span>
<DockerNetworkChart acummulativeData={acummulativeData.network} />
<DockerNetworkChart accumulativeData={accumulativeData.network} />
</div>
</CardContent>
</Card>
@@ -1,4 +1,6 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -9,6 +11,9 @@ interface Props {
}
export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
const { data } = api.mysql.one.useQuery({ mysqlId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.mysql.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -28,20 +33,43 @@ export const ShowInternalMysqlCredentials = ({ mysqlId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databasePassword}
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "user",
});
toast.success("Password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<Label>Root Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
disabled
value={data?.databaseRootPassword}
/>
<UpdateDatabasePassword
label="Root Password"
onUpdatePassword={async (newPassword) => {
await changePassword({
mysqlId,
password: newPassword,
type: "root",
});
toast.success("Root password updated successfully");
utils.mysql.one.invalidate({ mysqlId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
@@ -1,4 +1,6 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -9,6 +11,9 @@ interface Props {
}
export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
const { data } = api.postgres.one.useQuery({ postgresId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.postgres.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -28,11 +33,21 @@ export const ShowInternalPostgresCredentials = ({ postgresId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
value={data?.databasePassword}
disabled
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
postgresId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.postgres.one.invalidate({ postgresId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">
@@ -43,6 +43,7 @@ import {
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddTemplateSchema = z.object({
name: z.string().min(1, {
@@ -53,9 +54,8 @@ const AddTemplateSchema = z.object({
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
description: z.string().optional(),
serverId: z.string().optional(),
@@ -71,6 +71,9 @@ interface Props {
export const AddApplication = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: servers } = api.server.withSSHKey.useQuery();
@@ -171,7 +174,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -191,17 +195,19 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value || (showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -225,7 +231,8 @@ export const AddApplication = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -43,6 +43,7 @@ import {
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddComposeSchema = z.object({
composeType: z.enum(["docker-compose", "stack"]).optional(),
@@ -54,9 +55,8 @@ const AddComposeSchema = z.object({
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
description: z.string().optional(),
serverId: z.string().optional(),
@@ -74,13 +74,13 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const { data: servers } = api.server.withSSHKey.useQuery();
const { mutateAsync, isPending, error, isError } =
api.compose.create.useMutation();
// Get environment data to extract projectId
// const { data: environment } = api.environment.one.useQuery({ environmentId });
const hasServers = servers && servers.length > 0;
// Show dropdown logic based on cloud environment
// Cloud: show only if there are remote servers (no Dokploy option)
@@ -185,7 +185,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
Select a Server{" "}
{showLocalOption ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
@@ -205,17 +206,19 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value || (showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={!isCloud ? "Dokploy" : "Select a Server"}
placeholder={
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -239,7 +242,8 @@ export const AddCompose = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -52,12 +52,13 @@ import {
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
type DbType = z.infer<typeof mySchema>["type"];
const dockerImageDefaultPlaceholder: Record<DbType, string> = {
mongo: "mongo:8",
libsql: "ghcr.io/tursodatabase/libsql-server:v0.24.32",
mongo: "mongo:7",
mariadb: "mariadb:11",
mysql: "mysql:8",
postgres: "postgres:18",
@@ -82,9 +83,8 @@ const baseDatabaseSchema = z.object({
.min(1, {
message: "App name is required",
})
.regex(/^[a-z](?!.*--)([a-z0-9-]*[a-z])?$/, {
message:
"App name supports lowercase letters, numbers, '-' and can only start and end letters, and does not support continuous '-'",
.regex(APP_NAME_REGEX, {
message: APP_NAME_MESSAGE,
}),
databasePassword: z
.string()
@@ -219,6 +219,9 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
const [visible, setVisible] = useState(false);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: webServerSettings } =
api.settings.getWebServerSettings.useQuery();
const showLocalOption = !isCloud && !webServerSettings?.remoteServersOnly;
const { data: servers } = api.server.withSSHKey.useQuery();
const libsqlMutation = api.libsql.create.useMutation();
const mariadbMutation = api.mariadb.create.useMutation();
@@ -470,19 +473,20 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
field.value ||
(showLocalOption ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
showLocalOption ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
{showLocalOption && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
@@ -501,7 +505,8 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
</SelectItem>
))}
<SelectLabel>
Servers ({servers?.length + (!isCloud ? 1 : 0)})
Servers (
{servers?.length + (showLocalOption ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
@@ -632,7 +637,6 @@ export const AddDatabase = ({ environmentId, projectName }: Props) => {
control={form.control}
name="enableNamespaces"
render={({ field }) => {
console.log(field.value);
return (
<FormItem>
<FormLabel>Enable Namespaces</FormLabel>
@@ -0,0 +1,494 @@
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Code2, FileInput, Globe2, HardDrive, HelpCircle } from "lucide-react";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Textarea } from "@/components/ui/textarea";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { slugify } from "@/lib/slug";
import { api } from "@/utils/api";
import { APP_NAME_MESSAGE, APP_NAME_REGEX } from "@/utils/schema";
const AddImportSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
appName: z
.string()
.min(1, { message: "App name is required" })
.regex(APP_NAME_REGEX, { message: APP_NAME_MESSAGE }),
base64: z.string().min(1, { message: "Base64 content is required" }),
serverId: z.string().optional(),
});
type AddImport = z.infer<typeof AddImportSchema>;
type TemplateInfo = {
compose: string;
template: {
domains: Array<{
serviceName: string;
port: number;
path?: string;
host?: string;
}>;
envs: string[];
mounts: Array<{ filePath: string; content: string }>;
};
};
interface Props {
environmentId: string;
projectName?: string;
}
export const AddImport = ({ environmentId, projectName }: Props) => {
const utils = api.useUtils();
const [visible, setVisible] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [mountOpen, setMountOpen] = useState(false);
const [selectedMount, setSelectedMount] = useState<{
filePath: string;
content: string;
} | null>(null);
const [templateInfo, setTemplateInfo] = useState<TemplateInfo | null>(null);
const slug = slugify(projectName);
const { data: isCloud } = api.settings.isCloud.useQuery();
const { data: servers } = api.server.withSSHKey.useQuery();
const shouldShowServerDropdown = !!(servers && servers.length > 0);
const { mutateAsync: previewTemplate, isPending: isProcessing } =
api.compose.previewTemplate.useMutation();
const { mutateAsync: createCompose, isPending: isCreating } =
api.compose.create.useMutation();
const { mutateAsync: importCompose, isPending: isImporting } =
api.compose.import.useMutation();
const form = useForm<AddImport>({
defaultValues: { name: "", appName: `${slug}-`, base64: "" },
resolver: zodResolver(AddImportSchema),
});
const resetAll = () => {
form.reset({ name: "", appName: `${slug}-`, base64: "" });
setTemplateInfo(null);
setPreviewOpen(false);
setMountOpen(false);
setSelectedMount(null);
};
const handleOpenChange = (open: boolean) => {
if (!open) resetAll();
setVisible(open);
};
const handleLoad = async (data: AddImport) => {
try {
const result = await previewTemplate({
appName: data.appName,
base64: data.base64.trim(),
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
setTemplateInfo(result);
setPreviewOpen(true);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error processing template",
);
}
};
const handleImport = async () => {
const data = form.getValues();
try {
const compose = await createCompose({
name: data.name,
appName: data.appName,
environmentId,
composeType: "docker-compose",
serverId: data.serverId === "dokploy" ? undefined : data.serverId,
});
await importCompose({
composeId: compose.composeId,
base64: data.base64.trim(),
});
toast.success("Compose imported successfully");
await utils.environment.one.invalidate({ environmentId });
resetAll();
setVisible(false);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Error importing compose",
);
}
};
const handleCancelPreview = () => {
setPreviewOpen(false);
setTemplateInfo(null);
};
return (
<>
<Dialog open={visible} onOpenChange={handleOpenChange}>
<DialogTrigger className="w-full">
<DropdownMenuItem
className="w-full cursor-pointer space-x-3"
onSelect={(e) => e.preventDefault()}
>
<FileInput className="size-4 text-muted-foreground" />
<span>Import</span>
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Import Compose</DialogTitle>
<DialogDescription>
Paste a base64-encoded compose export to preview and import it
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
id="hook-form-import"
onSubmit={form.handleSubmit(handleLoad)}
className="grid w-full gap-4"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="My App"
{...field}
onChange={(e) => {
const val = e.target.value || "";
form.setValue(
"appName",
`${slug}-${slugify(val.trim())}`,
);
field.onChange(val);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{shouldShowServerDropdown && (
<FormField
control={form.control}
name="serverId"
render={({ field }) => (
<FormItem>
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<FormLabel className="break-all w-fit flex flex-row gap-1 items-center">
Select a Server {!isCloud ? "(Optional)" : ""}
<HelpCircle className="size-4 text-muted-foreground" />
</FormLabel>
</TooltipTrigger>
<TooltipContent
className="z-[999] w-[300px]"
align="start"
side="top"
>
<span>
If no server is selected, the compose will be
deployed on the server where the user is logged
in.
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Select
onValueChange={field.onChange}
defaultValue={
field.value || (!isCloud ? "dokploy" : undefined)
}
>
<SelectTrigger>
<SelectValue
placeholder={
!isCloud ? "Dokploy" : "Select a Server"
}
/>
</SelectTrigger>
<SelectContent>
<SelectGroup>
{!isCloud && (
<SelectItem value="dokploy">
<span className="flex items-center gap-2 justify-between w-full">
<span>Dokploy</span>
<span className="text-muted-foreground text-xs self-center">
Default
</span>
</span>
</SelectItem>
)}
{servers?.map((server) => (
<SelectItem
key={server.serverId}
value={server.serverId}
>
<span className="flex items-center gap-2 justify-between w-full">
<span>{server.name}</span>
<span className="text-muted-foreground text-xs self-center">
{server.ipAddress}
</span>
</span>
</SelectItem>
))}
<SelectLabel>
Servers (
{(servers?.length ?? 0) + (!isCloud ? 1 : 0)})
</SelectLabel>
</SelectGroup>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="appName"
render={({ field }) => (
<FormItem>
<FormLabel>App Name</FormLabel>
<FormControl>
<Input placeholder="my-app" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="base64"
render={({ field }) => (
<FormItem>
<FormLabel>Configuration (Base64)</FormLabel>
<FormControl>
<Textarea
placeholder="Paste your base64-encoded compose export here..."
className="font-mono resize-none h-32"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
isLoading={isCreating || isProcessing}
>
Load
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Preview modal */}
<Dialog
open={previewOpen}
onOpenChange={(open) => !open && handleCancelPreview()}
>
<DialogContent className="max-w-[60vw]">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
</DialogTitle>
<DialogDescription className="space-y-2">
<p>Review the template information before importing</p>
<AlertBlock type="warning">
Warning: This will remove all existing environment variables,
mounts, and domains from this service.
</AlertBlock>
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Docker Compose</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
{templateInfo?.template.domains &&
templateInfo.template.domains.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Globe2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Domains</h3>
</div>
<div className="grid grid-cols-1 gap-3">
{templateInfo.template.domains.map((domain, index) => (
<div
key={index}
className="rounded-lg border bg-card p-3 text-card-foreground shadow-sm"
>
<div className="font-medium">
{domain.serviceName}
</div>
<div className="text-sm text-muted-foreground space-y-1">
<div>Port: {domain.port}</div>
{domain.host && <div>Host: {domain.host}</div>}
{domain.path && <div>Path: {domain.path}</div>}
</div>
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.envs &&
templateInfo.template.envs.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">
Environment Variables
</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.envs.map((env, index) => (
<div
key={index}
className="rounded-lg truncate border bg-card p-2 font-mono text-sm"
>
{env}
</div>
))}
</div>
</div>
</>
)}
{templateInfo?.template.mounts &&
templateInfo.template.mounts.length > 0 && (
<>
<Separator />
<div className="space-y-4">
<div className="flex items-center gap-2">
<HardDrive className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Mounts</h3>
</div>
<div className="grid grid-cols-1 gap-2">
{templateInfo.template.mounts.map((mount, index) => (
<div
key={index}
className="rounded-lg border bg-card p-2 font-mono text-sm hover:bg-accent cursor-pointer transition-colors"
onClick={() => {
setSelectedMount(mount);
setMountOpen(true);
}}
>
{mount.filePath}
</div>
))}
</div>
</div>
</>
)}
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={handleCancelPreview}>
Cancel
</Button>
<Button isLoading={isImporting} onClick={handleImport}>
Import
</Button>
</div>
</DialogContent>
</Dialog>
{/* Mount content modal */}
<Dialog open={mountOpen} onOpenChange={setMountOpen}>
<DialogContent className="max-w-[50vw]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{selectedMount?.filePath}
</DialogTitle>
<DialogDescription>Mount File Content</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[45vh] pr-4">
<CodeEditor
language="yaml"
value={selectedMount?.content || ""}
className="font-mono"
readOnly
/>
</ScrollArea>
<div className="flex justify-end gap-2 pt-4">
<Button onClick={() => setMountOpen(false)}>Close</Button>
</div>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,4 +1,5 @@
import {
Bookmark,
BookText,
CheckIcon,
ChevronsUpDown,
@@ -82,6 +83,7 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
const [open, setOpen] = useState(false);
const [viewMode, setViewMode] = useState<"detailed" | "icon">("detailed");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [showBookmarksOnly, setShowBookmarksOnly] = useState(false);
const [customBaseUrl, setCustomBaseUrl] = useState<string | undefined>(() => {
// Try to get from props first, then localStorage
if (baseUrl) return baseUrl;
@@ -122,8 +124,45 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
enabled: open,
},
);
const { data: bookmarkIds = [], isLoading: isLoadingBookmarks } =
api.user.getBookmarkedTemplates.useQuery(undefined, {
enabled: open,
});
const utils = api.useUtils();
const { mutateAsync: toggleBookmark } =
api.user.toggleTemplateBookmark.useMutation({
onMutate: async ({ templateId }) => {
await utils.user.getBookmarkedTemplates.cancel();
const previousBookmarks = utils.user.getBookmarkedTemplates.getData();
utils.user.getBookmarkedTemplates.setData(undefined, (old = []) => {
if (old.includes(templateId)) {
return old.filter((id) => id !== templateId);
}
return [...old, templateId];
});
return { previousBookmarks };
},
onError: (err, variables, context) => {
if (context?.previousBookmarks) {
utils.user.getBookmarkedTemplates.setData(
undefined,
context.previousBookmarks,
);
}
toast.error("Failed to update bookmark");
},
onSuccess: (data) => {
toast.success(
data.isBookmarked ? "Added to bookmarks" : "Removed from bookmarks",
);
},
});
const [serverId, setServerId] = useState<string | undefined>(undefined);
const { mutateAsync, isPending, error, isError } =
api.compose.deployTemplate.useMutation();
@@ -137,7 +176,9 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
query === "" ||
template.name.toLowerCase().includes(query.toLowerCase()) ||
template.description.toLowerCase().includes(query.toLowerCase());
return matchesTags && matchesQuery;
const matchesBookmarks =
!showBookmarksOnly || bookmarkIds.includes(template.id);
return matchesTags && matchesQuery && matchesBookmarks;
}) || [];
const hasServers = servers && servers.length > 0;
@@ -146,6 +187,14 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
// Self-hosted: show only if there are remote servers (Dokploy is default, hide if no remote servers)
const shouldShowServerDropdown = hasServers;
const handleToggleBookmark = async (
e: React.MouseEvent,
templateId: string,
) => {
e.stopPropagation();
await toggleBookmark({ templateId });
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger className="w-full">
@@ -243,6 +292,20 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</Command>
</PopoverContent>
</Popover>
<Button
variant={showBookmarksOnly ? "default" : "outline"}
size="icon"
onClick={() => setShowBookmarksOnly(!showBookmarksOnly)}
className="h-9 w-9 flex-shrink-0"
disabled={isLoadingBookmarks}
>
<Bookmark
className={cn(
"size-4",
showBookmarksOnly && "fill-current",
)}
/>
</Button>
<Button
size="icon"
onClick={() =>
@@ -299,11 +362,19 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
</div>
</div>
) : templates.length === 0 ? (
<div className="flex justify-center items-center w-full gap-2 min-h-[50vh]">
<div className="flex flex-col justify-center items-center w-full gap-2 min-h-[50vh]">
<SearchIcon className="text-muted-foreground size-6" />
<div className="text-xl font-medium text-muted-foreground">
No templates found
{showBookmarksOnly
? "No bookmarked templates found"
: "No templates found"}
</div>
{showBookmarksOnly && (
<p className="text-sm text-muted-foreground">
Click the bookmark icon on templates to add them to
bookmarks
</p>
)}
</div>
) : (
<div
@@ -323,9 +394,25 @@ export const AddTemplate = ({ environmentId, baseUrl }: Props) => {
viewMode === "detailed" && "h-[400px]",
)}
>
<Badge className="absolute top-2 right-2" variant="blue">
{template?.version}
</Badge>
<div className="absolute top-2 left-2 z-10">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 bg-background/80 backdrop-blur-sm hover:bg-background"
onClick={(e) => handleToggleBookmark(e, template.id)}
>
<Bookmark
className={cn(
"size-4",
bookmarkIds.includes(template.id) &&
"fill-yellow-400 text-yellow-400",
)}
/>
</Button>
</div>
<div className="absolute top-2 right-2">
<Badge variant="blue">{template?.version}</Badge>
</div>
<div
className={cn(
"flex-none p-6 pb-3 flex flex-col items-center gap-4 bg-muted/30",
@@ -298,7 +298,19 @@ export const TemplateGenerator = ({ environmentId }: Props) => {
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 w-full justify-end">
<Button
onClick={stepper.prev}
onClick={() => {
if (
stepper.current.id === "variant" &&
templateInfo.details
) {
setTemplateInfo((prev) => ({
...prev,
details: null,
}));
return;
}
stepper.prev();
}}
disabled={stepper.isFirst}
variant="secondary"
>
@@ -88,7 +88,12 @@ export const EnvironmentVariables = ({ environmentId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -87,7 +87,12 @@ export const ProjectEnvironment = ({ projectId, children }: Props) => {
// Add keyboard shortcut for Ctrl+S/Cmd+S
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s" && !isPending && isOpen) {
if (
(e.ctrlKey || e.metaKey) &&
e.code === "KeyS" &&
!isPending &&
isOpen
) {
e.preventDefault();
form.handleSubmit(onSubmit)();
}
@@ -166,6 +166,7 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -178,6 +179,7 @@ export const ShowProjects = () => {
return (
total +
(env.applications?.length || 0) +
(env.libsql?.length || 0) +
(env.mariadb?.length || 0) +
(env.mongo?.length || 0) +
(env.mysql?.length || 0) +
@@ -342,7 +344,7 @@ export const ShowProjects = () => {
}
}}
>
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border">
<Card className="group relative w-full h-full bg-transparent transition-colors hover:bg-border flex flex-col">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 overflow-clip">
<span className="flex flex-col gap-1.5 ">
@@ -489,7 +491,7 @@ export const ShowProjects = () => {
</div>
</CardTitle>
</CardHeader>
<CardFooter className="pt-4">
<CardFooter className="pt-4 mt-auto">
<div className="space-y-1 text-xs flex flex-row justify-between max-sm:flex-wrap w-full gap-2 sm:gap-4">
<DateTooltip date={project.createdAt}>
Created
@@ -1,4 +1,6 @@
import { toast } from "sonner";
import { ToggleVisibilityInput } from "@/components/shared/toggle-visibility-input";
import { UpdateDatabasePassword } from "@/components/shared/update-database-password";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -9,6 +11,9 @@ interface Props {
}
export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
const { data } = api.redis.one.useQuery({ redisId });
const utils = api.useUtils();
const { mutateAsync: changePassword } =
api.redis.changePassword.useMutation();
return (
<>
<div className="flex w-full flex-col gap-5 ">
@@ -24,11 +29,21 @@ export const ShowInternalRedisCredentials = ({ redisId }: Props) => {
</div>
<div className="flex flex-col gap-2">
<Label>Password</Label>
<div className="flex flex-row gap-4">
<div className="flex flex-row gap-2 items-center">
<ToggleVisibilityInput
value={data?.databasePassword}
disabled
/>
<UpdateDatabasePassword
onUpdatePassword={async (newPassword) => {
await changePassword({
redisId,
password: newPassword,
});
toast.success("Password updated successfully");
utils.redis.one.invalidate({ redisId });
}}
/>
</div>
</div>
<div className="flex flex-col gap-2">

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