Compare commits

...

74 Commits

Author SHA1 Message Date
Mauricio Siu d768adb6ad feat(api): enhance OpenAPI schema for application endpoints and network validation
- Added new endpoints for application creation, retrieval, and reloading with detailed request and response schemas.
- Updated existing network validation schemas to enforce minimum length requirements for network IDs.
- Improved OpenAPI documentation to ensure better clarity and validation for application-related operations.
2026-07-27 00:59:56 -06:00
Mauricio Siu 24d0cff060 Merge pull request #3774 from Dokploy/feat/add-network-management
Feat/add network management
2026-07-27 00:43:28 -06:00
Mauricio Siu 29fcc74ca5 feat(audit): integrate auditing for network operations
- Added auditing functionality for network creation, recreation, and deletion actions in the network API.
- Updated the audit log schema to include "network" as a resource type.
- Enhanced the network router to log actions with relevant metadata, improving traceability and accountability for network management operations.
2026-07-27 00:42:05 -06:00
Mauricio Siu ec74035c70 feat: add new test 2026-07-25 03:20:00 -06:00
autofix-ci[bot] aee15efac7 [autofix.ci] apply automated fixes 2026-07-25 09:10:21 +00:00
Mauricio Siu 97885ee44a feat(networks): enhance network management and UI components
- Added functionality for assigning Docker networks to services, allowing users to manage network associations directly from the UI.
- Introduced new components for assigning networks to both individual services and compose files, improving user experience and flexibility.
- Updated existing components to integrate network assignment features, ensuring seamless interaction within the dashboard.
- Enhanced the database schema to support new network-related fields, including `networkIds` and `detachDokployNetwork`, for better service configuration.
- Improved the layout and responsiveness of various UI elements to accommodate new features and enhance usability.
2026-07-25 03:09:54 -06:00
Mauricio Siu 73e4fdd757 Merge pull request #4626 from veksen/veksen/textarea-resets-while-editing
fix: prevent environment form from resetting while editing
2026-07-22 14:33:42 -06:00
Mauricio Siu 0fc75840d2 Merge pull request #4890 from SteadEXE/canary
fix(ui): add cursor pointer style for buttons
2026-07-22 00:12:53 -06:00
autofix-ci[bot] 79c127b03b [autofix.ci] apply automated fixes 2026-07-22 06:03:44 +00:00
Jordan B f2148040a3 Add cursor pointer style for buttons
Added cursor pointer style for buttons and roles.
2026-07-22 08:01:02 +02:00
Mauricio Siu 8a37626636 feat(networks): implement network recreation functionality and enhance synchronization
- Added a new API endpoint for recreating Docker networks that have been removed, allowing users to restore networks directly from the Dokploy interface.
- Updated the ShowNetworks and SyncNetworks components to include options for recreating networks, improving user management capabilities.
- Enhanced the network synchronization process to better handle stale records, providing users with clear actions for network maintenance.
- Refactored network creation logic to streamline the process and ensure consistency across network management operations.
2026-07-21 23:10:33 -06:00
Mauricio Siu adcbd6fd8e feat(networks): enhance network management with server filtering and synchronization
- Added server filtering capability to the dashboard, allowing users to manage networks specific to selected servers.
- Introduced SyncNetworks component for synchronizing Docker networks with Dokploy, enabling import of new networks and cleanup of stale records.
- Updated HandleNetwork and ShowNetworks components to support server-specific operations, improving user experience and functionality.
- Enhanced API to support network synchronization and importing, ensuring accurate data handling for network management.
2026-07-21 22:55:10 -06:00
Mauricio Siu c578c18500 feat(networks): add network configuration viewer and enhance network actions
- Introduced a new ShowNetworkConfig component for displaying detailed Docker network configuration.
- Integrated ShowNetworkConfig into the ShowNetworks component, allowing users to view network details directly.
- Updated the layout of network actions for improved user experience and accessibility.
- Enhanced the API to support network inspection functionality, providing necessary data for the new component.
2026-07-21 22:28:00 -06:00
Mauricio Siu 94ddbf8ba5 refactor(networks): streamline network management and enhance validation
- Simplified the network form schema by removing unnecessary fields and enforcing validation rules for IP settings.
- Updated the HandleNetwork component to eliminate the edit functionality, focusing on network creation only.
- Improved the ShowNetworks component by adding a delete action for network management, enhancing user experience.
- Introduced a new SQL migration for the network schema, establishing necessary constraints and types.
- Cleaned up the API by removing the update network functionality, reflecting the immutable nature of Docker networks.
2026-07-21 22:19:22 -06:00
autofix-ci[bot] 61c2b73f08 [autofix.ci] apply automated fixes 2026-07-22 03:47:06 +00:00
Mauricio Siu 736a77e112 refactor(networks): update network form schema and enhance handling logic
- Replaced zodResolver with standardSchemaResolver for improved schema validation.
- Added new toggle options for network settings, enhancing user interface clarity.
- Refactored network creation and update logic to streamline payload handling.
- Updated network form schema to remove default values, ensuring explicit user input.
- Introduced SERVER_LOCAL sentinel for local Dokploy server identification.
2026-07-21 21:46:40 -06:00
Mauricio Siu f4bcc2e8a8 refactor(networks): enhance network display and management interface
- Updated the ShowNetworks component to improve the layout and user experience.
- Introduced a new Pencil icon for editing networks and added a Badge component for displaying network drivers.
- Refactored loading and empty state handling for better visual feedback.
- Modified the API query to include server details for each network, enhancing the data returned for better context.
2026-07-21 19:10:51 -06:00
Mauricio Siu 99e483c1a1 feat(network): add network management schema and update related tables
- Introduced a new SQL file defining the "network" type and its associated table structure.
- Added "networkIds" column to multiple application tables for network association.
- Established foreign key constraints for the "network" table linking to "organization" and "server" tables.
- Updated migration journal and added a new snapshot for versioning.
2026-07-21 19:02:51 -06:00
Mauricio Siu 22c8a1658a Merge branch 'canary' into feat/add-network-management 2026-07-21 19:01:28 -06:00
Mauricio Siu f0f0db4c7f refactor: remove deprecated network management files and update .gitignore
- Deleted the SQL file and snapshot related to the "network" management schema, cleaning up the database structure.
- Updated the migration journal to reflect these deletions.
- Added .playwright-* to .gitignore to exclude Playwright test files from version control.
2026-07-21 19:01:11 -06:00
Mauricio Siu 8b868c66d6 Merge pull request #4882 from Dokploy/feat/custom-ai-providers
feat(ai): allow organizations to define custom AI provider presets
2026-07-21 03:23:32 -06:00
Mauricio Siu 366e44b75a fix(ai): only show Custom Presets button to owner/admin roles 2026-07-21 03:22:48 -06:00
Mauricio Siu cb2db0d30a Bump version from v0.29.12 to v0.29.13 2026-07-21 03:22:19 -06:00
Mauricio Siu 7ba3853bab feat(ai): allow organizations to define custom AI provider presets
Organization admins can define their own AI providers (name + API URL)
from the AI settings section. When at least one custom provider is
defined, it replaces the built-in provider list in the Add AI form,
the API URL is auto-filled and locked, and the backend rejects any
configuration whose URL is not in the allowed list.
2026-07-21 03:19:06 -06:00
Mauricio Siu 8def9e933e Merge pull request #4880 from Dokploy/fix/sso-initial-credentials-linking
fix(auth): enable email verification for SSO and user creation
2026-07-21 02:50:51 -06:00
Mauricio Siu e9b51667e2 fix(auth): enable email verification for SSO and user creation
Updated the SSO configuration to trust verified emails and modified user creation to set emailVerified to true by default. This enhances security and ensures that user email verification is properly handled.
2026-07-21 02:50:22 -06:00
Mauricio Siu 25370cac30 Merge pull request #4847 from ANSUJKMEHER/fix-4666-action-terminology
fix: rename compose "Reload" action to "Rebuild"
2026-07-21 00:52:29 -06:00
Mauricio Siu 52c7db1f66 Merge pull request #4877 from Dokploy/fix/2fa-invalid-code-error-message
fix(2fa): show correct error message for invalid TOTP code
2026-07-21 00:52:04 -06:00
Mauricio Siu 6d65a36aac fix(2fa): show correct error for invalid TOTP code
The verify-totp handler checked for the error code
INVALID_TWO_FACTOR_AUTHENTICATION, which no longer exists in
better-auth 1.6.23 (the two-factor plugin now returns INVALID_CODE).
As a result, entering a wrong TOTP code fell through to the generic
catch and showed "Error verifying 2FA code / Unknown error" instead of
a clear "Invalid verification code" message.

Match the current better-auth error code so the specific, actionable
message is shown.
2026-07-20 18:20:56 -06:00
Mauricio Siu cbec72ed80 Merge pull request #4876 from Dokploy/fix/collapsed-sidebar-org-menu-width
fix(ui): organization menu clipped when sidebar is collapsed
2026-07-20 18:04:24 -06:00
Mauricio Siu 3b102fac56 fix(ui): organization menu clipped when sidebar is collapsed
The organization switcher's DropdownMenuContent inherited its width from
the collapsed trigger (~40px) via the base primitive's
w-(--radix-dropdown-menu-trigger-width), clamping the menu to the
min-w-32 (128px) floor. This cut off the org name and action buttons in
icon mode.

Set an explicit w-64 so the menu fits its content regardless of the
trigger width, matching the pattern already used by the notification
dropdown in the same file.

Fixes #4840
2026-07-20 18:03:28 -06:00
Mauricio Siu b2ade17487 Merge pull request #4874 from Dokploy/fix/idor-server-remove
fix(security): cross-org authorization bypass in server.remove
2026-07-20 17:40:18 -06:00
Mauricio Siu ffe62bca0e Merge pull request #4875 from Dokploy/fix/cmdi-registry-test-login
fix(security): command injection in registry.testRegistry / testRegistryById
2026-07-20 17:40:00 -06:00
Mauricio Siu d3f522b7a6 fix(security): command injection in registry.testRegistry/testRegistryById remote path
The remote (execAsyncRemote) path built `echo ${password} | docker ${args.join(" ")}`
with the password, registryUrl and username interpolated unescaped, so a password
like `pw; whoami` ran arbitrary commands as root on the target server. Reuse
safeDockerLoginCommand (already used by create/update), which shell-escapes each
field and feeds the password via --password-stdin. The local argv+stdin path was
already safe.
2026-07-20 17:17:45 -06:00
Mauricio Siu 4aee66b2d1 fix(security): enforce organization ownership on server.remove
server.remove deleted a server (and its deployment rows) by caller-supplied
serverId without checking it belongs to the active organization, unlike server.one
and server.update. An owner/admin of org A could delete org B's server registration.
Resolve and compare the server's organizationId before the active-services guard,
so cross-org callers are rejected without leaking existence.
2026-07-20 17:14:35 -06:00
Mauricio Siu d02f34f9d4 Merge pull request #4873 from Dokploy/fix/cmdi-quote-sweep
fix(security): escape user-controlled values across command-injection sinks (quote sweep)
2026-07-20 17:05:16 -06:00
Mauricio Siu 92310ddb14 fix(security): base64-encode remote traefik YAML and escape config paths
writeTraefikConfigRemote piped the stringified YAML through echo '...' where a
single quote in any label/host/serviceName broke out (GHSA-478p). Encode it as
base64 like the other writers, and quote() every configPath used in remote
rm/cat/redirect commands (including the input.path-derived ones).
2026-07-20 16:49:48 -06:00
Mauricio Siu 16b5b7293f fix(security): escape file paths and remote schedule command in shell invocations
quote() the user-derived paths that reach the shell in file mounts
(mount.ts, docker getCreateFileCommand), patch repo read (repoPath/filePath),
certificate create/remove (certificatePath), and the remote scheduled command
(containerId/shellType/command/logPath), so $(), backticks and traversal in
these fields can no longer inject commands.
2026-07-20 16:48:43 -06:00
Mauricio Siu d629faebc6 fix(security): validate volumeName and escape volume-backup file names
Add VOLUME_NAME_REGEX (Docker volume-name format) and enforce it on volumeName in
create/update/runManually — a legit volume name never contains shell metacharacters,
so this blocks injection across every docker run/rm/rclone sink at once. Escape the
user-supplied backupFileName and the mount volumeName in database rebuild with quote().
2026-07-20 16:45:55 -06:00
Mauricio Siu eeb6e7b8ea fix(security): escape S3/rclone args and restore paths to prevent command injection
Wrap S3 credential flags (getS3Credentials + destination.testConnection), the
listBackupFiles search path, and every restore backupPath/backupFile with
shell-quote's quote() so $(), backticks, quotes and spaces in destination
fields or backupFile can no longer break out of the rclone shell commands.
2026-07-20 16:11:16 -06:00
Mauricio Siu 9b078e0b4c Merge pull request #4871 from Dokploy/refactor/inline-quote-git-providers
refactor(providers): inline quote() in git clone commands, drop shellWord helper
2026-07-20 16:00:15 -06:00
Mauricio Siu ce4be79b3d refactor(providers): inline quote() in git clone commands, drop shellWord helper
Use shell-quote's quote([...]) directly at each git-provider clone call site
instead of the one-line shellWord wrapper, and remove the now-unused helper.
Behavior is identical (quote([String(v ?? '')])).
2026-07-20 15:58:16 -06:00
Mauricio Siu 8c2e91a5e6 Merge pull request #4870 from Dokploy/fix/github-setup-callback-authz
fix(security): missing authorization on GitHub App setup callback (unauth cross-org write)
2026-07-20 15:40:46 -06:00
Mauricio Siu 0514363062 chore: drop explanatory comments in github setup handler 2026-07-20 15:37:17 -06:00
Mauricio Siu ffb8d4396b Merge pull request #4869 from Dokploy/fix/schedule-host-authz-bypass
fix(security): host-schedule owner/admin bypass via applicationId (member → root)
2026-07-20 15:34:19 -06:00
Mauricio Siu 5ae344db58 fix(security): authenticate and authorize the GitHub App setup callback
The /api/providers/github/setup callback performed privileged writes (persisting
App client_secret/webhook_secret/private_key, re-pointing installations) with no
session check, deriving the write target (organizationId/userId on gh_init,
githubId on gh_setup) from the attacker-controlled state parameter. An
unauthenticated request, or a member with no gitProviders permission, could plant
or re-point a provider in any organization.

Require a valid session, gate on the gitProviders permission (same guard the tRPC
github router uses), derive the create target from the session, and verify the
gh_setup provider belongs to the caller's organization.
2026-07-20 15:32:06 -06:00
autofix-ci[bot] f339805ddf [autofix.ci] apply automated fixes 2026-07-20 21:30:52 +00:00
Mauricio Siu 8fe3294a08 fix(schedule): give scheduleType a clean enum type for the host-access gate
createInsertSchema inferred scheduleType as a broadened union (drizzle-zod 0.5.1),
so passing input.scheduleType into assertHostScheduleAccess failed typecheck on a
cold build. Refine the insert schema's scheduleType to a plain z.enum and type the
helper param as the schedule enum.
2026-07-20 15:30:13 -06:00
Mauricio Siu 1e3f10bd22 fix(security): enforce owner/admin gate on host schedules regardless of service link
Host-level schedules (server / dokploy-server) run their script as root on the
host. The owner/admin gate only ran in the no-service branch, so a member could
attach an accessible applicationId to a dokploy-server schedule and skip it,
gaining root via schedule.runManually.

Extract assertHostScheduleAccess into the schedule service and call it before
the service-access branch in create/update/delete/runManually so the host-level
authorization always applies.
2026-07-20 15:22:06 -06:00
Mauricio Siu 393fb92344 Merge pull request #4865 from Dokploy/fix/authz-websocket-handlers
fix(security): missing authorization on docker/terminal WebSocket handlers (member -> root)
2026-07-20 15:00:02 -06:00
Mauricio Siu 79c9cf99e0 fix(security): pass serviceId from the remaining terminal entry points
- application general 'Open Terminal' now passes serviceId (applicationId).
- The docker/terminal DockerTerminalModal now forwards serviceId to its Terminal.
- compose container rows thread serviceId to their terminal + logs modals.

This lets a member with service access (but without the docker permission) open
the terminal / read logs of their own service's containers from every service
view, matching the backend service-first authorization.
2026-07-20 01:20:16 -06:00
autofix-ci[bot] 6cbc7a0031 [autofix.ci] apply automated fixes 2026-07-20 07:15:39 +00:00
Mauricio Siu eb1f11b908 fix(security): service access is authoritative for service-scoped wss ops
When a container is opened from a service page (serviceId present), gate on
service access alone — matching application.readLogs — instead of requiring the
docker permission first. This unblocks a member who has the service but not the
canAccessToDocker permission (or the server it runs on) from reading its logs /
opening its terminal. Generic Docker-overview ops (no serviceId) still require
docker permission + server access.
2026-07-20 01:15:18 -06:00
autofix-ci[bot] 1a3c76d1f2 [autofix.ci] apply automated fixes 2026-07-20 06:48:40 +00:00
Mauricio Siu 1e354a3cf2 fix(security): check service access before server in wss authz, and pass serviceId from log views
- Reorder canAccessDockerOverWss: docker permission -> service access (if
  serviceId) -> server access, so the service grant is the primary signal.
- Pass serviceId from the service log views (application + postgres/mysql/mariadb/
  mongo/redis/libsql + compose) so container logs opened from a service page are
  gated by checkServiceAccess, matching the terminal path.
2026-07-20 00:48:20 -06:00
Mauricio Siu 1bc76e9e5b Reapply "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 56169f3278.
2026-07-20 00:35:40 -06:00
Mauricio Siu 56169f3278 Revert "feat(security): enforce service-level access on docker WebSocket handlers"
This reverts commit 479d851829.
2026-07-20 00:29:19 -06:00
Mauricio Siu 479d851829 feat(security): enforce service-level access on docker WebSocket handlers
Completes GHSA-qf9j service-level dimension: when a container terminal/logs/stats
is opened from a service page, the frontend now passes serviceId, and the wss
authorizer calls checkServiceAccess so a member restricted to specific services
cannot reach another service's containers. Generic Docker-overview opens have no
serviceId and keep the docker-permission + server-access gate.

Verified against the local WebSocket: a member (docker:read=false) is rejected
with close code 4003; owner is allowed.

Closes GHSA-qf9j-c9p4-r4xp
2026-07-20 00:14:39 -06:00
Mauricio Siu 68f5afae42 fix(security): authorize docker/terminal WebSocket handlers
The /terminal, /docker-container-terminal, /docker-container-logs and
/docker-stats WebSocket handlers only checked session + organization, so any
authenticated member could open a root shell / read logs of any container, and
the local (serverId=local) branch reached a root SSH shell on the control-plane
host.

Adds an authorization layer (server/wss/authorize.ts):
- container ops (terminal/logs/stats): require the docker permission (owner/admin
  or a member granted canAccessToDocker); for a remote server, require the server
  be accessible to the caller.
- host/server terminal: the local host root terminal is restricted to owner/admin;
  a remote server terminal is gated on server access.

Closes GHSA-c68r-7wg9-p7v2, GHSA-899j-cjwp-v4gw, GHSA-7r6p-v9gw-pwc8, GHSA-qf9j-c9p4-r4xp
2026-07-19 23:49:02 -06:00
Mauricio Siu 637715ac66 Merge pull request #4864 from Dokploy/fix/cmdi-registry-swarm-node
fix(security): OS command injection via swarm nodeId and registry tag
2026-07-19 23:36:26 -06:00
Mauricio Siu df2779eaeb fix(security): escape swarm nodeId and registry tag in cluster commands
- cluster.removeWorker: input.nodeId (z.string(), no regex) was interpolated raw
  into 'docker node update/rm ${nodeId}' — now shell-quoted.
- swarm image upload (getRegistryCommands): registryTag / imageName were
  interpolated raw into 'docker tag'/'docker push' and an echo. registryTag is
  built from username and imagePrefix, which have no schema regex, so it was
  injectable — now shell-quoted. (The docker login already used
  safeDockerLoginCommand, so credentials were already safe.)
- gpu-setup: nodeId (derived from 'docker info', not user input) escaped as
  defense-in-depth.

Closes GHSA-4mfc-grxw-6858, GHSA-hfwh-69ch-gv47, GHSA-prwq-2mcm-mvhr
2026-07-19 23:34:18 -06:00
Mauricio Siu 95a3556baa Merge pull request #4863 from Dokploy/fix/cmdi-compose-path-command
fix(security): OS command injection via compose path and custom command
2026-07-19 23:11:33 -06:00
Ansuj Kumar Meher 15fe3b21c9 fix: rename compose "Reload" action to "Rebuild"
Renames the compose "Reload" action to "Rebuild" to accurately reflect its actual behavior. Clicking "Reload" triggers rebuildCompose() which executes a full Docker build (docker compose up -d --build). Also updates dialog text, toast notifications, and tooltips accordingly.

Fixes #4666
2026-07-18 21:04:59 +05:30
Jean-Philippe Sirois 86ea311714 fix: prevent environment form from resetting while editing
The application environment editor shares the application.one query with
its parent page, which polls every 5s. Each refetch pushed fresh data
into a useEffect that called form.reset(), wiping any in-progress edits
(e.g. while scrolling down to the Save button). Gate the reset on the
form not being dirty so background refetches no longer clobber unsaved
edits, and reset the dirty baseline after a successful save so the form
re-syncs with the server normally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 20:28:02 -04:00
Mauricio Siu 5f508163e5 feat: add network management schema and update related tables
- Introduced a new ENUM type "networkDriver" and created the "network" table with various attributes for network management.
- Added "networkIds" column to multiple existing tables (application, compose, mariadb, mongo, mysql, postgres, redis) to support network associations.
- Established foreign key constraints for "organizationId" and "serverId" in the "network" table to ensure referential integrity.
- Updated migration journal and added a new snapshot for version 0166 to reflect these changes.
2026-04-13 16:12:57 -06:00
Mauricio Siu fc75b847b5 Merge branch 'canary' into feat/add-network-management 2026-04-13 15:59:23 -06:00
Mauricio Siu b1abcb9e06 refactor: remove deprecated network schema and related snapshots
- Deleted the SQL file for the "network" table and its associated constraints, as well as the snapshot for version 0146, to clean up the database schema.
- Updated the migration journal to reflect these deletions, ensuring consistency in the database structure.
2026-04-13 15:58:21 -06:00
Mauricio Siu d542972522 fix: enhance server validation in createNetwork function
- Updated the createNetwork function to ensure serverId is required when in cloud mode, improving error handling for network creation.
- Added braces for clarity in the conditional statement, enhancing code readability.
2026-02-22 01:58:28 -06:00
Mauricio Siu e7c38d4c54 feat: create network table and update related schemas
- Added a new "network" table with various attributes including networkId, name, driver, and constraints for organizationId and serverId.
- Introduced a custom ENUM type "networkDriver" for network drivers.
- Updated existing tables (application, compose, mariadb, mongo, mysql, postgres, redis) to include a new "networkIds" column of type text[].
- Created a snapshot for version 7 to reflect these schema changes in the database.
2026-02-22 01:57:18 -06:00
autofix-ci[bot] a8f941b5d9 [autofix.ci] apply automated fixes 2026-02-22 07:56:34 +00:00
Mauricio Siu c68fac55fd refactor: remove deprecated network and snapshot SQL files
- Deleted the SQL files for the "network" table and its associated constraints, as well as the snapshots for versions 0146 and 0147, to clean up the database schema and remove unused components.
- Updated the migration journal to reflect these deletions, ensuring the database remains consistent and manageable.
2026-02-22 01:56:07 -06:00
Mauricio Siu efcad7bbf5 feat: add networkIds column to multiple database tables
- Introduced a new column "networkIds" of type text[] with a default value of an empty array to the application, compose, mariadb, mongo, mysql, postgres, and redis tables.
- Updated the application and compose schemas to include the new networkIds field in their respective TypeScript definitions.
- Added corresponding entries in the migration journal and created a snapshot for version 7 to reflect these changes.
2026-02-22 01:53:36 -06:00
Mauricio Siu a0566cdbd7 refactor: remove unused server value and update network handling logic
- Eliminated the unused DOKPLOY_SERVER_VALUE constant from the network handling component.
- Updated the default serverId to undefined in the network form values and adjusted related logic to ensure compatibility with cloud environments.
- Enhanced server validation in the createNetwork function to require a serverId when in cloud mode, improving error handling for network creation.
2026-02-21 14:59:50 -06:00
Mauricio Siu 69598821ed feat: implement Docker network management functionality
- Added components for handling and displaying Docker networks, including creation, editing, and listing of networks.
- Introduced a new API router for network operations, integrating with the database schema for network management.
- Updated the sidebar layout to include a link to the networks dashboard, ensuring user access to network features.
- Created necessary database migrations for the network table and its associated types.
- Enhanced the dashboard layout to support the new network management interface.
2026-02-21 14:53:27 -06:00
142 changed files with 48978 additions and 24953 deletions
+4 -1
View File
@@ -43,4 +43,7 @@ yarn-error.log*
*.pem
.db
.db
.playwright-*
.credentials
@@ -0,0 +1,70 @@
import { execSync } from "node:child_process";
import { existsSync, rmSync } from "node:fs";
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
const runsSafely = (command: string) => {
if (existsSync(MARK)) rmSync(MARK);
try {
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
} catch {}
const fired = existsSync(MARK);
if (existsSync(MARK)) rmSync(MARK);
return !fired;
};
const PAYLOADS = (m: string) => [
`$(touch ${m})`,
"`touch " + m + "`",
`x; touch ${m}`,
`x | touch ${m}`,
];
describe("cluster removeWorker nodeId injection", () => {
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
it("escapes nodeId in drain/remove commands", () => {
for (const nodeId of PAYLOADS(MARK)) {
const drain = `: node update --availability drain ${quote([nodeId])}`;
const remove = `: node rm ${quote([nodeId])} --force`;
expect(runsSafely(drain)).toBe(true);
expect(runsSafely(remove)).toBe(true);
}
});
});
describe("swarm upload registry tag/push injection", () => {
// registryTag is built from registryUrl/username/imagePrefix (username and
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
for (const payload of PAYLOADS(MARK)) {
const registryTag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: payload,
username: "user",
} as any,
"app:latest",
);
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
const pushCmd = `: push ${quote([registryTag])}`;
expect(runsSafely(tagCmd)).toBe(true);
expect(runsSafely(pushCmd)).toBe(true);
}
});
it("keeps a legitimate registry tag intact", () => {
const tag = getRegistryTag(
{
registryUrl: "registry.example.com",
imagePrefix: "team",
username: "user",
} as any,
"myapp:1.2.3",
);
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
expect(parse(quote([tag]))).toEqual([tag]);
});
});
@@ -0,0 +1,199 @@
import type { Compose, ComposeSpecification } from "@dokploy/server";
import {
applyServiceNetworks,
declareUsedNetworksInRoot,
resolveServiceNetworks,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { beforeEach, expect, test, type vi } from "vitest";
import { parse } from "yaml";
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
beforeEach(() => {
findManyMock.mockReset();
findManyMock.mockResolvedValue([]);
});
const baseCompose = {
serverId: null,
isolatedDeployment: false,
} as unknown as Compose;
const withServiceNetworks = (
serviceNetworks: Compose["serviceNetworks"],
): Compose => ({ ...baseCompose, serviceNetworks });
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
expect(findManyMock).not.toHaveBeenCalled();
});
test("applyServiceNetworks: injects assigned network by networkId", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.has("shared-net")).toBe(true);
expect(result.services?.web?.networks).toContain("shared-net");
});
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
const result = parse(`
services:
db:
image: postgres
networks:
- dokploy-network
- default
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
]),
);
expect(injected.size).toBe(0);
expect(result.services?.db?.networks).not.toContain("dokploy-network");
expect(result.services?.db?.networks).not.toContain("default");
});
test("applyServiceNetworks: unknown networkId is skipped", async () => {
findManyMock.mockResolvedValue([]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "web",
networkIds: ["missing"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
});
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
const result = parse(`
services:
web:
image: nginx
`) as ComposeSpecification;
const injected = await applyServiceNetworks(
result,
withServiceNetworks([
{
serviceName: "ghost",
networkIds: ["net-1"],
detachDokployNetwork: false,
},
]),
);
expect(injected.size).toBe(0);
expect(result.services?.web?.networks).toBeUndefined();
});
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
const used = parse(`
services:
web:
image: nginx
networks:
- dokploy-network
`) as ComposeSpecification;
declareUsedNetworksInRoot(used, new Set());
expect(used.networks).toHaveProperty("dokploy-network");
const unused = parse(`
services:
web:
image: nginx
networks:
- default
`) as ComposeSpecification;
declareUsedNetworksInRoot(unused, new Set());
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
});
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
const result = parse(`
services:
web:
image: nginx
networks:
- shared-net
`) as ComposeSpecification;
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
expect(result.networks).toHaveProperty("shared-net");
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
});
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
const resolved = await resolveServiceNetworks({});
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
expect(findManyMock).not.toHaveBeenCalled();
});
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
expect(resolved).toEqual([]);
});
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
expect(resolved).toEqual([
{ Target: "dokploy-network" },
{ Target: "overlay-a" },
]);
});
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
const override = [{ Target: "custom-net" }];
const resolved = await resolveServiceNetworks({ networkSwarm: override });
expect(resolved).toBe(override);
expect(findManyMock).not.toHaveBeenCalled();
});
+2
View File
@@ -32,6 +32,8 @@ const baseApp: ApplicationNested = {
railpackVersion: "0.15.4",
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
@@ -1,8 +1,10 @@
import { cloneGitRepository } from "@dokploy/server/utils/providers/git";
import { shellWord } from "@dokploy/server/utils/providers/utils";
import { parse } from "shell-quote";
import { parse, quote } from "shell-quote";
import { describe, expect, it } from "vitest";
// How git-provider commands escape a single user value before it reaches the shell.
const shellArg = (value: string) => quote([String(value ?? "")]);
// Payloads that, if reached a shell unescaped, would execute commands.
const INJECTION_PAYLOADS = [
"$(touch /tmp/pwned)",
@@ -24,10 +26,10 @@ const LEGIT_VALUES = [
"https://gitlab.example.com/group/sub/project.git",
];
describe("shellWord (git provider shell escaping)", () => {
describe("git provider shell escaping (quote)", () => {
it("collapses every injection payload into a single literal token (no shell operators)", () => {
for (const payload of INJECTION_PAYLOADS) {
const parsed = parse(shellWord(payload));
const parsed = parse(shellArg(payload));
// A safely escaped value parses back to exactly the original string,
// as ONE token. If escaping failed, parse() would emit operator
// objects such as { op: ";" } or { op: "$(" } instead.
@@ -37,7 +39,7 @@ describe("shellWord (git provider shell escaping)", () => {
it("leaves legitimate URLs and branch names intact", () => {
for (const value of LEGIT_VALUES) {
expect(parse(shellWord(value))).toEqual([value]);
expect(parse(shellArg(value))).toEqual([value]);
}
});
});
@@ -7,6 +7,8 @@ const baseApp: ApplicationNested = {
rollbackActive: false,
applicationId: "",
previewLabels: [],
networkIds: [],
detachDokployNetwork: false,
createEnvFile: true,
bitbucketRepositorySlug: "",
herokuVersion: "",
+104
View File
@@ -0,0 +1,104 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
// Mock the permission + server helpers the wss authorizer composes.
const mockHasPermission = vi.hoisted(() => vi.fn());
const mockFindMember = vi.hoisted(() => vi.fn());
const mockCheckServiceAccess = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server/services/permission", () => ({
hasPermission: mockHasPermission,
findMemberByUserId: mockFindMember,
checkServiceAccess: mockCheckServiceAccess,
}));
const mockGetAccessibleServerIds = vi.hoisted(() => vi.fn());
vi.mock("@dokploy/server", () => ({
getAccessibleServerIds: mockGetAccessibleServerIds,
}));
import {
canAccessDockerOverWss,
canAccessTerminalOverWss,
} from "@/server/wss/authorize";
const USER = { id: "user-1" };
const SESSION = { activeOrganizationId: "org-1" };
beforeEach(() => {
vi.clearAllMocks();
});
describe("canAccessDockerOverWss", () => {
it("denies when there is no user or session", async () => {
expect(await canAccessDockerOverWss(null, SESSION)).toBe(false);
expect(await canAccessDockerOverWss(USER, null)).toBe(false);
});
it("denies a member without docker permission", async () => {
mockHasPermission.mockResolvedValue(false);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(false);
});
it("allows when the caller has docker permission (no server)", async () => {
mockHasPermission.mockResolvedValue(true);
expect(await canAccessDockerOverWss(USER, SESSION)).toBe(true);
});
it("denies a remote server the caller cannot access, even with docker permission", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["other-server"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(false);
});
it("allows a remote server the caller can access", async () => {
mockHasPermission.mockResolvedValue(true);
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessDockerOverWss(USER, SESSION, "srv-1")).toBe(true);
});
it("denies when the container belongs to a service the caller cannot access", async () => {
mockCheckServiceAccess.mockRejectedValue(new Error("no access"));
expect(await canAccessDockerOverWss(USER, SESSION, null, "svc-1")).toBe(
false,
);
});
it("allows service access even without docker permission or server access", async () => {
// A member granted the service but without canAccessToDocker, whose
// service runs on a server they were not individually granted, must still
// read its logs — matches application.readLogs (service access only).
mockHasPermission.mockResolvedValue(false);
mockGetAccessibleServerIds.mockResolvedValue(new Set());
mockCheckServiceAccess.mockResolvedValue(undefined);
expect(
await canAccessDockerOverWss(USER, SESSION, "srv-remote", "svc-1"),
).toBe(true);
// Service path is authoritative — it must not fall through to docker/server.
expect(mockHasPermission).not.toHaveBeenCalled();
expect(mockGetAccessibleServerIds).not.toHaveBeenCalled();
});
});
describe("canAccessTerminalOverWss", () => {
it("denies the local host terminal to a plain member", async () => {
mockFindMember.mockResolvedValue({ role: "member" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(false);
});
it("allows the local host terminal to an owner", async () => {
mockFindMember.mockResolvedValue({ role: "owner" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("allows the local host terminal to an admin", async () => {
mockFindMember.mockResolvedValue({ role: "admin" });
expect(await canAccessTerminalOverWss(USER, SESSION, "local")).toBe(true);
});
it("gates a remote server terminal on server access", async () => {
mockGetAccessibleServerIds.mockResolvedValue(new Set(["srv-1"]));
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-1")).toBe(true);
expect(await canAccessTerminalOverWss(USER, SESSION, "srv-2")).toBe(false);
// role lookup must not be needed for the remote path
expect(mockFindMember).not.toHaveBeenCalled();
});
});
@@ -185,7 +185,7 @@ export const ShowImport = ({ composeId }: Props) => {
</Button>
</div>
<Dialog open={showModal} onOpenChange={setShowModal}>
<DialogContent className="max-w-[50vw]">
<DialogContent className="sm:max-w-3xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle className="text-2xl font-bold">
Template Information
@@ -199,7 +199,7 @@ export const ShowImport = ({ composeId }: Props) => {
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-6 flex-1 min-h-0 overflow-y-auto pr-1">
<div className="space-y-4">
<div className="flex items-center gap-2">
<Code2 className="h-5 w-5 text-primary" />
@@ -207,12 +207,14 @@ export const ShowImport = ({ composeId }: Props) => {
Docker Compose
</h3>
</div>
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
<div className="max-h-[45vh] overflow-auto rounded-md border">
<CodeEditor
language="yaml"
value={templateInfo?.compose || ""}
className="font-mono"
readOnly
/>
</div>
</div>
<Separator />
@@ -60,14 +60,16 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
const currentBuildArgs = form.watch("buildArgs");
const currentBuildSecrets = form.watch("buildSecrets");
const currentCreateEnvFile = form.watch("createEnvFile");
const { isDirty } = form.formState;
const hasChanges =
currentEnv !== (data?.env || "") ||
currentBuildArgs !== (data?.buildArgs || "") ||
currentBuildSecrets !== (data?.buildSecrets || "") ||
currentCreateEnvFile !== (data?.createEnvFile ?? true);
// Skip reset while editing so background refetches don't wipe edits
useEffect(() => {
if (data) {
if (data && !isDirty) {
form.reset({
env: data.env || "",
buildArgs: data.buildArgs || "",
@@ -75,7 +77,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
createEnvFile: data.createEnvFile ?? true,
});
}
}, [data, form]);
}, [data, isDirty, form]);
const onSubmit = async (formData: EnvironmentSchema) => {
mutateAsync({
@@ -87,6 +89,7 @@ export const ShowEnvironment = ({ applicationId }: Props) => {
})
.then(async () => {
toast.success("Environments Added");
form.reset(formData);
await refetch();
})
.catch(() => {
@@ -271,6 +271,7 @@ export const ShowGeneralApplication = ({ applicationId }: Props) => {
<DockerTerminalModal
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={applicationId}
>
<Button
variant="outline"
@@ -50,9 +50,10 @@ export const badgeStateColor = (state: string) => {
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogs = ({ appName, serverId }: Props) => {
export const ShowDockerLogs = ({ appName, serverId, serviceId }: Props) => {
const [containerId, setContainerId] = useState<string | undefined>();
const [option, setOption] = useState<"swarm" | "native">("native");
@@ -182,6 +183,7 @@ export const ShowDockerLogs = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -6,6 +6,7 @@ import { toast } from "sonner";
import { z } from "zod";
import { AlertBlock } from "@/components/shared/alert-block";
import { CodeEditor } from "@/components/shared/code-editor";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
@@ -111,7 +112,10 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
return (
<Card className="bg-background">
<CardHeader>
<CardTitle className="text-xl">Enable Isolated Deployment</CardTitle>
<CardTitle className="text-xl flex items-center gap-2">
Enable Isolated Deployment
<Badge variant="yellow">Deprecated</Badge>
</CardTitle>
<CardDescription>
Configure isolated deployment to the compose file.
<div className="text-sm text-muted-foreground flex flex-col gap-2">
@@ -138,6 +142,11 @@ export const IsolatedDeploymentTab = ({ composeId }: Props) => {
</CardHeader>
<CardContent>
<div className="space-y-4">
<AlertBlock type="warning">
Isolated deployment is deprecated. Use the Networks section above to
attach networks per service and detach them from dokploy-network —
it is declarative and does not break on restarts.
</AlertBlock>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<Form {...form}>
<form
@@ -55,12 +55,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowComposeContainers = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending, refetch } =
api.docker.getContainersByAppNameMatch.useQuery(
@@ -122,6 +124,7 @@ export const ShowComposeContainers = ({
key={container.containerId}
container={container}
serverId={serverId}
serviceId={serviceId}
onActionComplete={() => refetch()}
/>
))}
@@ -142,12 +145,14 @@ interface ContainerRowProps {
status: string;
};
serverId?: string;
serviceId?: string;
onActionComplete: () => void;
}
const ContainerRow = ({
container,
serverId,
serviceId,
onActionComplete,
}: ContainerRowProps) => {
const [logsOpen, setLogsOpen] = useState(false);
@@ -236,6 +241,7 @@ const ContainerRow = ({
<DockerTerminalModal
containerId={container.containerId}
serverId={serverId || ""}
serviceId={serviceId}
>
Terminal
</DockerTerminalModal>
@@ -280,6 +286,7 @@ const ContainerRow = ({
containerId={container.containerId}
serverId={serverId}
runType="native"
serviceId={serviceId}
/>
</div>
</DialogContent>
@@ -84,19 +84,19 @@ export const ComposeActions = ({ composeId }: Props) => {
)}
{canDeploy && (
<DialogAction
title="Reload Compose"
description="Are you sure you want to reload this compose?"
title="Rebuild Compose"
description="Are you sure you want to rebuild this compose?"
type="default"
onClick={async () => {
await redeploy({
composeId: composeId,
})
.then(() => {
toast.success("Compose reloaded successfully");
toast.success("Compose rebuilt successfully");
refetch();
})
.catch(() => {
toast.error("Error reloading compose");
toast.error("Error rebuilding compose");
});
}}
>
@@ -109,12 +109,14 @@ export const ComposeActions = ({ composeId }: Props) => {
<TooltipTrigger asChild>
<div className="flex items-center">
<RefreshCcw className="size-4 mr-1" />
Reload
Rebuild
</div>
</TooltipTrigger>
<TooltipPrimitive.Portal>
<TooltipContent sideOffset={5} className="z-60">
<p>Reload the compose without rebuilding it</p>
<p>
Rebuilds the compose without downloading the source code
</p>
</TooltipContent>
</TooltipPrimitive.Portal>
</Tooltip>
@@ -206,6 +208,7 @@ export const ComposeActions = ({ composeId }: Props) => {
appName={data?.appName || ""}
serverId={data?.serverId || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
>
<Button
variant="outline"
@@ -52,7 +52,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
Preview Compose
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-6xl max-h-200">
<DialogContent className="sm:max-w-6xl max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle>Converted Compose</DialogTitle>
<DialogDescription>
@@ -62,10 +62,6 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</DialogHeader>
{isError && <AlertBlock type="error">{error?.message}</AlertBlock>}
<AlertBlock type="info" className="mb-4">
Preview your docker-compose file with added domains. Note: At least
one domain must be specified for this conversion to take effect.
</AlertBlock>
{isPending ? (
<div className="flex flex-row items-center justify-center min-h-100 border p-4 rounded-md">
<Loader2 className="h-8 w-8 text-muted-foreground mb-2 animate-spin" />
@@ -79,7 +75,7 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</div>
) : (
<>
<div className="flex flex-row gap-2 justify-end my-4">
<div className="flex flex-row gap-2 justify-end">
<Button
variant="secondary"
isLoading={isPending}
@@ -100,14 +96,15 @@ export const ShowConvertedCompose = ({ composeId }: Props) => {
</Button>
</div>
<pre>
<div className="flex-1 min-h-0 overflow-auto rounded-md border">
<CodeEditor
value={compose || ""}
language="yaml"
readOnly
height="50rem"
height="100%"
wrapperClassName="h-full"
/>
</pre>
</div>
</>
)}
</DialogContent>
@@ -35,9 +35,14 @@ export const DockerLogs = dynamic(
interface Props {
appName: string;
serverId?: string;
serviceId?: string;
}
export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
export const ShowDockerLogsStack = ({
appName,
serverId,
serviceId,
}: Props) => {
const [option, setOption] = useState<"swarm" | "native">("native");
const [containerId, setContainerId] = useState<string | undefined>();
@@ -167,6 +172,7 @@ export const ShowDockerLogsStack = ({ appName, serverId }: Props) => {
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType={option}
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -35,12 +35,14 @@ interface Props {
appName: string;
serverId?: string;
appType: "stack" | "docker-compose";
serviceId?: string;
}
export const ShowDockerLogsCompose = ({
appName,
appType,
serverId,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -104,6 +106,7 @@ export const ShowDockerLogsCompose = ({
serverId={serverId || ""}
containerId={containerId || "select-a-container"}
runType="native"
serviceId={serviceId}
/>
</CardContent>
</Card>
@@ -23,6 +23,7 @@ interface Props {
containerId: string;
serverId?: string | null;
runType: "swarm" | "native";
serviceId?: string;
}
export const priorities = [
@@ -52,6 +53,7 @@ export const DockerLogsId: React.FC<Props> = ({
containerId,
serverId,
runType,
serviceId,
}) => {
const { data } = api.docker.getConfig.useQuery(
{
@@ -157,6 +159,10 @@ export const DockerLogsId: React.FC<Props> = ({
params.append("serverId", serverId);
}
if (serviceId) {
params.append("serviceId", serviceId);
}
const wsUrl = `${protocol}//${
window.location.host
}/docker-container-logs?${params.toString()}`;
@@ -222,7 +228,7 @@ export const DockerLogsId: React.FC<Props> = ({
ws.close();
}
};
}, [containerId, serverId, lines, search, since]);
}, [containerId, serverId, serviceId, lines, search, since]);
const handleDownload = () => {
const logContent = filteredLogs
@@ -23,12 +23,14 @@ interface Props {
containerId: string;
serverId?: string;
children?: React.ReactNode;
serviceId?: string;
}
export const DockerTerminalModal = ({
children,
containerId,
serverId,
serviceId,
}: Props) => {
const [mainDialogOpen, setMainDialogOpen] = useState(false);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
@@ -74,6 +76,7 @@ export const DockerTerminalModal = ({
id="terminal"
containerId={containerId}
serverId={serverId || ""}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -10,12 +10,14 @@ interface Props {
id: string;
containerId?: string;
serverId?: string;
serviceId?: string;
}
export const DockerTerminal: React.FC<Props> = ({
id,
containerId,
serverId,
serviceId,
}) => {
const termRef = useRef(null);
const [activeWay, setActiveWay] = React.useState<string | undefined>("bash");
@@ -38,7 +40,7 @@ export const DockerTerminal: React.FC<Props> = ({
const addonFit = new FitAddon();
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}`;
const wsUrl = `${protocol}//${window.location.host}/docker-container-terminal?containerId=${containerId}&activeWay=${activeWay}${serverId ? `&serverId=${serverId}` : ""}${serviceId ? `&serviceId=${serviceId}` : ""}`;
const ws = new WebSocket(wsUrl);
@@ -229,6 +229,7 @@ export const ShowGeneralLibsql = ({ libsqlId }: Props) => {
)}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.libsqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -236,6 +236,7 @@ export const ShowGeneralMariadb = ({ mariadbId }: Props) => {
))}
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mariadbId}
serverId={data?.serverId || ""}
>
<Button
@@ -230,6 +230,7 @@ export const ShowGeneralMongo = ({ mongoId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mongoId}
serverId={data?.serverId || ""}
>
<Button
@@ -228,6 +228,7 @@ export const ShowGeneralMysql = ({ mysqlId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.mysqlId}
serverId={data?.serverId || ""}
>
<Button
@@ -0,0 +1,347 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Check, ChevronsUpDown, Loader2, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, 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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Form, FormControl, FormField, FormItem } from "@/components/ui/form";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
interface Props {
composeId: string;
}
const serviceSchema = z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
});
const formSchema = z.object({
services: z.array(serviceSchema),
});
type FormValues = z.infer<typeof formSchema>;
export const AssignComposeNetworks = ({ composeId }: Props) => {
const [cacheType, setCacheType] = useState<"cache" | "fetch">("cache");
const { data: compose } = api.compose.one.useQuery({ composeId });
const {
data: services,
isLoading: isLoadingServices,
error: servicesError,
refetch: refetchServices,
isRefetching: isRefetchingServices,
} = api.compose.loadServices.useQuery(
{ composeId, type: cacheType },
{ retry: false },
);
const onRetry = () => {
setCacheType("fetch");
setTimeout(() => refetchServices(), 0);
};
const { data: networks } = api.network.all.useQuery(
{ serverId: compose?.serverId ?? undefined },
{ enabled: compose !== undefined },
);
const { mutateAsync, isPending } = api.compose.update.useMutation();
const utils = api.useUtils();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: { services: [] },
});
const { fields } = useFieldArray({
control: form.control,
name: "services",
});
useEffect(() => {
if (!services) return;
const serviceNetworks = compose?.serviceNetworks ?? [];
form.reset({
services: services.map((serviceName) => {
const config = serviceNetworks.find(
(s) => s.serviceName === serviceName,
);
return {
serviceName,
networkIds: config?.networkIds ?? [],
detachDokployNetwork: config?.detachDokployNetwork ?? false,
};
}),
});
}, [services, compose?.serviceNetworks, form]);
const allowsBridge = compose?.composeType === "docker-compose";
const availableNetworks = (networks ?? []).filter(
(n) => allowsBridge || n.driver === "overlay",
);
const onSubmit = async (values: FormValues) => {
try {
const serviceNetworks = values.services.filter(
(s) => s.networkIds.length > 0 || s.detachDokployNetwork,
);
await mutateAsync({
composeId,
serviceNetworks,
});
toast.success("Networks updated. Redeploy the compose to apply them.");
await utils.compose.one.invalidate({ composeId });
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-start justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach Docker networks per service and detach it from
dokploy-network. Takes effect on the next deploy.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Refresh
</Button>
</CardHeader>
<CardContent className="flex flex-col gap-4">
{servicesError ? (
<div className="flex flex-col gap-3">
<AlertBlock type="error">
Could not load the compose services. If this compose was just
created from a template, it hasn't been cloned yet — click Reload
to clone the repository and read its services.
</AlertBlock>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
isLoading={isRefetchingServices}
onClick={onRetry}
>
<RefreshCw className="size-4" />
Reload services
</Button>
</div>
</div>
) : isLoadingServices ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading services...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !services?.length ? (
<span className="py-6 text-center text-sm text-muted-foreground">
No services found in this compose.
</span>
) : (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex flex-col gap-4"
>
{fields.map((fieldItem, index) => (
<ServiceRow
key={fieldItem.id}
control={form.control}
index={index}
service={fieldItem.serviceName}
availableNetworks={availableNetworks}
/>
))}
<div className="flex justify-end">
<Button
type="submit"
disabled={!form.formState.isDirty}
isLoading={isPending}
>
Save
</Button>
</div>
</form>
</Form>
)}
</CardContent>
</Card>
);
};
type NetworkOption = { networkId: string; name: string; driver: string };
const ServiceRow = ({
control,
index,
service,
availableNetworks,
}: {
control: ReturnType<typeof useForm<FormValues>>["control"];
index: number;
service: string;
availableNetworks: NetworkOption[];
}) => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-3 rounded-lg border p-4">
<FormField
control={control}
name={`services.${index}.detachDokployNetwork`}
render={({ field }) => (
<FormItem className="flex flex-row items-center justify-between gap-3 space-y-0">
<span className="text-sm font-medium">{service}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
Detach dokploy-network
</span>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</div>
</FormItem>
)}
/>
<FormField
control={control}
name={`services.${index}.networkIds`}
render={({ field }) => {
const selectedNetworks = availableNetworks.filter((n) =>
field.value.includes(n.networkId),
);
const toggle = (networkId: string) => {
field.onChange(
field.value.includes(networkId)
? field.value.filter((id) => id !== networkId)
: [...field.value, networkId],
);
};
return (
<FormItem className="space-y-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput
placeholder="Search networks..."
className="h-9"
/>
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No networks available on this server.
</div>
) : (
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = field.value.includes(
n.networkId,
);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge key={n.networkId} variant="secondary">
{n.name}
</Badge>
))}
</div>
)}
</FormItem>
);
}}
/>
</div>
);
};
@@ -0,0 +1,355 @@
import { Check, ChevronsUpDown, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils";
import { api } from "@/utils/api";
type ServiceType =
| "application"
| "postgres"
| "mysql"
| "mariadb"
| "mongo"
| "redis"
| "libsql";
interface Props {
id: string;
type: ServiceType;
}
export const AssignNetworks = ({ id, type }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<string[]>([]);
const [detached, setDetached] = useState(false);
const {
service,
serverId,
networkIds,
detachDokployNetwork,
updateAsync,
isUpdating,
refetch,
} = useServiceNetworks(id, type);
const { data: networks } = api.network.all.useQuery(
{ serverId: serverId ?? undefined },
{ enabled: service !== undefined },
);
const { data: applicationDomains } = api.domain.byApplicationId.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const hasDomains = (applicationDomains?.length ?? 0) > 0;
useEffect(() => {
setSelected(networkIds ?? []);
setDetached(detachDokployNetwork ?? false);
}, [networkIds, detachDokployNetwork]);
const availableNetworks = (networks ?? []).filter(
(n) => n.driver === "overlay",
);
const selectedNetworks = availableNetworks.filter((n) =>
selected.includes(n.networkId),
);
const isDirty =
selected.length !== (networkIds?.length ?? 0) ||
selected.some((networkId) => !networkIds?.includes(networkId)) ||
detached !== detachDokployNetwork;
const toggle = (networkId: string) => {
setSelected((prev) =>
prev.includes(networkId)
? prev.filter((id) => id !== networkId)
: [...prev, networkId],
);
};
const onSave = async () => {
try {
await updateAsync({
networkIds: selected,
detachDokployNetwork: detached,
});
toast.success("Networks updated. Redeploy the service to apply them.");
await refetch();
} catch (error) {
toast.error("Error updating networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Card className="bg-background">
<CardHeader className="flex flex-row items-center justify-between flex-wrap gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-xl">Networks</CardTitle>
<CardDescription>
Attach additional Docker networks to this service so it can reach
services on those networks. Takes effect on the next deploy.
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-row items-start justify-between gap-3 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Detach from dokploy-network
</span>
<Badge variant="secondary">dokploy-network</Badge>
</div>
<p className="text-sm text-muted-foreground">
By default the service joins the shared dokploy-network. Detach it
to keep it reachable only through the networks you attach below.
</p>
</div>
<Switch checked={detached} onCheckedChange={setDetached} />
</div>
{detached && hasDomains && (
<AlertBlock type="warning">
Warning: this service has domains. Detaching it from dokploy-network
will break Traefik routing, and its domains will stop working.
</AlertBlock>
)}
{detached && !hasDomains && selected.length === 0 && (
<AlertBlock type="warning">
This service is detached from dokploy-network but has no other
network attached. It would be unreachable, so dokploy-network will
be kept until you attach a network below.
</AlertBlock>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
aria-expanded={open}
className="w-full justify-between"
>
<span className="text-muted-foreground">
{selectedNetworks.length > 0
? `${selectedNetworks.length} network${
selectedNetworks.length > 1 ? "s" : ""
} selected`
: "Select networks..."}
</span>
<ChevronsUpDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search networks..." className="h-9" />
<CommandList className="max-h-60">
{availableNetworks.length === 0 ? (
<div className="py-6 text-center text-sm text-muted-foreground">
No overlay networks on this server.
</div>
) : (
<>
<CommandEmpty className="py-6 text-center text-sm text-muted-foreground">
No networks found.
</CommandEmpty>
<CommandGroup>
{availableNetworks.map((n) => {
const isSelected = selected.includes(n.networkId);
return (
<CommandItem
key={n.networkId}
value={n.name}
onSelect={() => toggle(n.networkId)}
className="cursor-pointer"
>
<Checkbox
checked={isSelected}
className="mr-2"
onCheckedChange={() => toggle(n.networkId)}
/>
<span>{n.name}</span>
<Badge variant="outline" className="ml-2">
{n.driver}
</Badge>
<Check
className={cn(
"ml-auto size-4",
isSelected ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedNetworks.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedNetworks.map((n) => (
<Badge
key={n.networkId}
variant="secondary"
className="flex items-center gap-1 pr-1"
>
{n.name}
<button
type="button"
onClick={() => toggle(n.networkId)}
className="ml-0.5 rounded-full outline-hidden hover:opacity-70"
>
<X className="size-3" />
<span className="sr-only">Remove {n.name}</span>
</button>
</Badge>
))}
</div>
)}
<div className="flex justify-end">
<Button disabled={!isDirty} isLoading={isUpdating} onClick={onSave}>
Save
</Button>
</div>
</CardContent>
</Card>
);
};
// Maps a service type to its one-query and update-mutation, normalizing the
// per-type id field name and the networkIds field.
const useServiceNetworks = (id: string, type: ServiceType) => {
const application = api.application.one.useQuery(
{ applicationId: id },
{ enabled: type === "application" },
);
const postgres = api.postgres.one.useQuery(
{ postgresId: id },
{ enabled: type === "postgres" },
);
const mysql = api.mysql.one.useQuery(
{ mysqlId: id },
{ enabled: type === "mysql" },
);
const mariadb = api.mariadb.one.useQuery(
{ mariadbId: id },
{ enabled: type === "mariadb" },
);
const mongo = api.mongo.one.useQuery(
{ mongoId: id },
{ enabled: type === "mongo" },
);
const redis = api.redis.one.useQuery(
{ redisId: id },
{ enabled: type === "redis" },
);
const libsql = api.libsql.one.useQuery(
{ libsqlId: id },
{ enabled: type === "libsql" },
);
const applicationUpdate = api.application.update.useMutation();
const postgresUpdate = api.postgres.update.useMutation();
const mysqlUpdate = api.mysql.update.useMutation();
const mariadbUpdate = api.mariadb.update.useMutation();
const mongoUpdate = api.mongo.update.useMutation();
const redisUpdate = api.redis.update.useMutation();
const libsqlUpdate = api.libsql.update.useMutation();
const map = {
application: {
query: application,
mutation: applicationUpdate,
save: (payload: SavePayload) =>
applicationUpdate.mutateAsync({ applicationId: id, ...payload }),
},
postgres: {
query: postgres,
mutation: postgresUpdate,
save: (payload: SavePayload) =>
postgresUpdate.mutateAsync({ postgresId: id, ...payload }),
},
mysql: {
query: mysql,
mutation: mysqlUpdate,
save: (payload: SavePayload) =>
mysqlUpdate.mutateAsync({ mysqlId: id, ...payload }),
},
mariadb: {
query: mariadb,
mutation: mariadbUpdate,
save: (payload: SavePayload) =>
mariadbUpdate.mutateAsync({ mariadbId: id, ...payload }),
},
mongo: {
query: mongo,
mutation: mongoUpdate,
save: (payload: SavePayload) =>
mongoUpdate.mutateAsync({ mongoId: id, ...payload }),
},
redis: {
query: redis,
mutation: redisUpdate,
save: (payload: SavePayload) =>
redisUpdate.mutateAsync({ redisId: id, ...payload }),
},
libsql: {
query: libsql,
mutation: libsqlUpdate,
save: (payload: SavePayload) =>
libsqlUpdate.mutateAsync({ libsqlId: id, ...payload }),
},
}[type];
const service = map.query.data;
return {
service,
serverId: service?.serverId ?? null,
networkIds: service?.networkIds ?? [],
detachDokployNetwork: service?.detachDokployNetwork ?? false,
updateAsync: map.save,
isUpdating: map.mutation.isPending,
refetch: map.query.refetch,
};
};
type SavePayload = {
networkIds: string[];
detachDokployNetwork: boolean;
};
@@ -0,0 +1,373 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { Network, Plus, Trash2 } from "lucide-react";
import { useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/utils/api";
// Only bridge and overlay can be created: "host"/"none" are Docker
// singletons and macvlan/ipvlan need driver options we don't expose.
const networkDriverEnum = ["bridge", "overlay"] as const;
const ipamConfigEntrySchema = z.object({
subnet: z.string().optional(),
ipRange: z.string().optional(),
gateway: z.string().optional(),
});
const networkFormSchema = z
.object({
name: z.string().min(1, "Name is required"),
driver: z.enum(networkDriverEnum),
internal: z.boolean(),
attachable: z.boolean(),
enableIPv4: z.boolean(),
enableIPv6: z.boolean(),
ipamDriver: z.string().optional(),
ipamConfig: z.array(ipamConfigEntrySchema),
})
.superRefine((input, ctx) => {
if (!input.enableIPv4 && !input.enableIPv6) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of input.ipamConfig.entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipamConfig", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
});
type NetworkFormValues = z.infer<typeof networkFormSchema>;
const defaultValues: NetworkFormValues = {
name: "",
driver: "bridge",
internal: false,
attachable: false,
enableIPv4: true,
enableIPv6: false,
ipamDriver: "",
ipamConfig: [],
};
const toggleOptions = [
{
name: "internal",
label: "Internal",
description: "Containers on this network cannot reach external networks.",
},
{
name: "attachable",
label: "Attachable",
description:
"Allow standalone containers to attach (overlay networks only).",
},
{
name: "enableIPv4",
label: "Enable IPv4",
description: "Enable IPv4 addressing on the network.",
},
{
name: "enableIPv6",
label: "Enable IPv6",
description: "Enable IPv6 addressing on the network.",
},
] as const;
interface HandleNetworkProps {
/** Target server; undefined creates on the local Dokploy server */
serverId?: string;
children?: React.ReactNode;
}
// Docker networks are immutable, so this dialog only creates them;
// changing a network means deleting and recreating it.
export const HandleNetwork = ({ serverId, children }: HandleNetworkProps) => {
const [isOpen, setIsOpen] = useState(false);
const utils = api.useUtils();
const { mutateAsync, isPending } = api.network.create.useMutation();
const form = useForm<NetworkFormValues>({
resolver: zodResolver(networkFormSchema),
defaultValues,
});
const ipamConfigFieldArray = useFieldArray({
control: form.control,
name: "ipamConfig",
});
const onSubmit = async (data: NetworkFormValues) => {
try {
await mutateAsync({
name: data.name,
driver: data.driver,
serverId,
internal: data.internal,
attachable: data.attachable,
enableIPv4: data.enableIPv4,
enableIPv6: data.enableIPv6,
ipam: {
driver: data.ipamDriver || undefined,
config: data.ipamConfig,
},
});
toast.success("Network created");
await utils.network.all.invalidate();
setIsOpen(false);
form.reset(defaultValues);
} catch (error) {
toast.error("Error creating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const trigger = children ?? (
<Button>
<Plus className=" size-4" />
Add network
</Button>
);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Network className="size-5 text-muted-foreground" />
Add network
</DialogTitle>
<DialogDescription>
Create a new Docker network for your organization. Networks are
immutable: to change one, delete it and create it again.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex w-full flex-col gap-6"
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="my-network" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="driver"
render={({ field }) => (
<FormItem>
<FormLabel>Driver</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select driver" />
</SelectTrigger>
</FormControl>
<SelectContent>
{networkDriverEnum.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription className="text-muted-foreground">
bridge for single-server containers; overlay for Swarm
services.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{toggleOptions.map((option) => (
<FormField
key={option.name}
control={form.control}
name={option.name}
render={({ field }) => (
<FormItem className="flex flex-row items-start justify-between gap-3 space-y-0 rounded-lg border p-4">
<div className="space-y-1 pr-1">
<FormLabel>{option.label}</FormLabel>
<FormDescription className="text-muted-foreground">
{option.description}
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
))}
</div>
<div className="space-y-4 rounded-lg border p-4">
<div className="space-y-1">
<FormLabel>IPAM</FormLabel>
<p className="text-sm text-muted-foreground">
IP address management settings for this network.
</p>
</div>
<FormField
control={form.control}
name="ipamDriver"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Driver (optional)
</FormLabel>
<FormControl>
<Input {...field} placeholder="default" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="space-y-2">
<FormLabel className="text-muted-foreground">
Config (subnet / gateway / IP range)
</FormLabel>
{ipamConfigFieldArray.fields.map((field, index) => (
<div key={field.id} className="flex flex-wrap gap-2">
<FormField
control={form.control}
name={`ipamConfig.${index}.subnet`}
render={({ field: f }) => (
<FormItem className="min-w-[140px] flex-1">
<FormControl>
<Input
{...f}
placeholder="Subnet (e.g. 172.20.0.0/16)"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.ipRange`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="IP range" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`ipamConfig.${index}.gateway`}
render={({ field: f }) => (
<FormItem className="min-w-[120px] flex-1">
<FormControl>
<Input {...f} placeholder="Gateway" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Remove IPAM config"
onClick={() => ipamConfigFieldArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
ipamConfigFieldArray.append({
subnet: "",
ipRange: "",
gateway: "",
})
}
>
<Plus className="size-4" />
Add IPAM config
</Button>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
>
Cancel
</Button>
<Button type="submit" isLoading={isPending}>
Create network
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,69 @@
"use client";
import { Eye, Loader2 } from "lucide-react";
import { useState } from "react";
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 { api } from "@/utils/api";
interface Props {
networkId: string;
networkName: string;
}
export const ShowNetworkConfig = ({ networkId, networkName }: Props) => {
const [open, setOpen] = useState(false);
const { data, isLoading, error } = api.network.inspect.useQuery(
{ networkId },
{ enabled: open },
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label="View network config">
<Eye className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="w-full md:w-[70vw] min-w-[70vw]">
<DialogHeader>
<DialogTitle>Network Config</DialogTitle>
<DialogDescription>
docker network inspect output for "{networkName}"
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="text-wrap rounded-lg border p-4 overflow-y-auto text-sm bg-card max-h-[80vh]">
<code>
<pre className="whitespace-pre-wrap wrap-break-word">
<CodeEditor
language="json"
lineWrapping
lineNumbers={false}
readOnly
value={JSON.stringify(data, null, 2)}
/>
</pre>
</code>
</div>
)}
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,494 @@
"use client";
import {
type ColumnDef,
flexRender,
getCoreRowModel,
getPaginationRowModel,
getSortedRowModel,
type PaginationState,
type SortingState,
useReactTable,
} from "@tanstack/react-table";
import type { inferRouterOutputs } from "@trpc/server";
import {
ArrowUpDown,
Loader2,
Network,
RotateCcw,
ShieldCheck,
Trash2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { HandleNetwork } from "@/components/dashboard/networks/handle-network";
import { ShowNetworkConfig } from "@/components/dashboard/networks/show-network-config";
import { SyncNetworks } from "@/components/dashboard/networks/sync-networks";
import { DialogAction } from "@/components/shared/dialog-action";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { AppRouter } from "@/server/api/root";
import { api } from "@/utils/api";
type NetworkRow = inferRouterOutputs<AppRouter>["network"]["all"][number];
interface Props {
/** Selected server; undefined shows the local Dokploy server */
serverId?: string;
}
const getIpamEntries = (row: NetworkRow) =>
(row.ipam?.config ?? []).filter((c) => c.subnet || c.gateway || c.ipRange);
const SortableHeader = ({
column,
title,
}: {
column: {
getIsSorted: () => false | "asc" | "desc";
toggleSorting: (asc: boolean) => void;
};
title: string;
}) => (
<Button
variant="ghost"
className="-ml-3 h-8"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
>
{title}
<ArrowUpDown className="ml-2 size-4" />
</Button>
);
export const ShowNetworks = ({ serverId }: Props) => {
const utils = api.useUtils();
const [verified, setVerified] = useState(false);
const [sorting, setSorting] = useState<SortingState>([
{ id: "createdAt", desc: true },
]);
const [globalFilter, setGlobalFilter] = useState("");
const [driverFilter, setDriverFilter] = useState<string>("all");
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const { data: networks, isLoading } = api.network.all.useQuery({ serverId });
const { mutateAsync: removeNetwork } = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
// Same query the Sync dialog uses; "missing" tells us which records
// no longer have a real network in Docker
const {
data: syncStatus,
isFetching: isVerifying,
refetch: refetchVerify,
} = api.network.networksToSync.useQuery({ serverId }, { enabled: verified });
const missingIds = useMemo(
() => new Set(syncStatus?.missing.map((m) => m.networkId) ?? []),
[syncStatus],
);
const onVerify = async () => {
setVerified(true);
const { data: result, error } = await refetchVerify();
if (error) {
toast.error("Error verifying networks", {
description: error.message,
});
return;
}
if (!result) return;
if (result.missing.length === 0) {
toast.success("All networks exist in Docker");
} else {
toast.warning(
`${result.missing.length} network(s) no longer exist in Docker`,
);
}
};
const filteredData = useMemo(() => {
let list = networks ?? [];
if (driverFilter !== "all") {
list = list.filter((n) => n.driver === driverFilter);
}
if (globalFilter.trim()) {
const query = globalFilter.toLowerCase();
list = list.filter(
(n) =>
n.name.toLowerCase().includes(query) ||
(n.ipam?.config ?? []).some(
(c) =>
c.subnet?.toLowerCase().includes(query) ||
c.gateway?.toLowerCase().includes(query) ||
c.ipRange?.toLowerCase().includes(query),
),
);
}
return list;
}, [networks, driverFilter, globalFilter]);
const columns = useMemo<ColumnDef<NetworkRow>[]>(
() => [
{
accessorKey: "name",
header: ({ column }) => <SortableHeader column={column} title="Name" />,
cell: ({ row }) => (
<div className="flex items-center gap-2 font-medium">
{row.original.name}
{verified &&
syncStatus &&
(missingIds.has(row.original.networkId) ? (
<>
<Badge variant="red">Missing in Docker</Badge>
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={async () => {
try {
await recreateMutation.mutateAsync({
networkId: row.original.networkId,
});
toast.success(
`Network "${row.original.name}" recreated in Docker`,
);
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error recreating network", {
description:
error instanceof Error
? error.message
: "Unknown error",
});
}
}}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
</>
) : (
<Badge variant="green">In sync</Badge>
))}
</div>
),
},
{
accessorKey: "driver",
header: ({ column }) => (
<SortableHeader column={column} title="Driver" />
),
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Badge variant="outline">{row.original.driver}</Badge>
<span className="text-xs text-muted-foreground">
{row.original.driver === "overlay" ? "swarm" : "local"}
</span>
</div>
),
},
{
id: "subnet",
accessorFn: (row) => getIpamEntries(row)[0]?.subnet ?? "",
header: ({ column }) => (
<SortableHeader column={column} title="Subnet" />
),
cell: ({ row }) => {
const ipamEntries = getIpamEntries(row.original);
if (ipamEntries.length === 0) {
return <span className="text-muted-foreground">Auto</span>;
}
return (
<div className="flex flex-col gap-1">
{ipamEntries.map((c, index) => (
<div
key={`${row.original.networkId}-ipam-${index}`}
className="flex flex-col"
>
<span>{c.subnet ?? "—"}</span>
{(c.gateway || c.ipRange) && (
<span className="text-xs text-muted-foreground">
{[
c.gateway && `gw ${c.gateway}`,
c.ipRange && `range ${c.ipRange}`,
]
.filter(Boolean)
.join(" · ")}
</span>
)}
</div>
))}
</div>
);
},
},
{
accessorKey: "internal",
header: ({ column }) => (
<SortableHeader column={column} title="Internal" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.internal ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "attachable",
header: ({ column }) => (
<SortableHeader column={column} title="Attachable" />
),
cell: ({ row }) => (
<span className="text-muted-foreground">
{row.original.attachable ? "Yes" : "No"}
</span>
),
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<SortableHeader column={column} title="Created" />
),
cell: ({ row }) => (
<span className="text-muted-foreground whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleDateString()}
</span>
),
},
{
id: "actions",
enableSorting: false,
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => (
<div className="flex items-center justify-end gap-3">
<ShowNetworkConfig
networkId={row.original.networkId}
networkName={row.original.name}
/>
<DialogAction
title="Delete network"
description={`The network "${row.original.name}" will be removed from Docker and Dokploy. This action cannot be undone.`}
onClick={async () => {
try {
await removeNetwork({
networkId: row.original.networkId,
});
toast.success("Network deleted");
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
} catch (error) {
toast.error("Error deleting network", {
description:
error instanceof Error ? error.message : "Unknown error",
});
}
}}
>
<Button
variant="ghost"
size="icon-sm"
aria-label="Delete network"
>
<Trash2 className="size-4 text-destructive" />
</Button>
</DialogAction>
</div>
),
},
],
[verified, syncStatus, missingIds, removeNetwork, recreateMutation, utils],
);
const table = useReactTable({
data: filteredData,
columns,
state: {
sorting,
pagination,
},
onSortingChange: setSorting,
onPaginationChange: setPagination,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
return (
<div className="w-full">
<Card className="h-full bg-sidebar p-2.5 rounded-xl">
<div className="rounded-xl bg-background shadow-md ">
<CardHeader className="">
<CardTitle className="text-xl flex flex-row gap-2">
<Network className="size-6 text-muted-foreground self-center" />
Networks
</CardTitle>
<CardDescription>
Manage the Docker networks of the selected server.
</CardDescription>
<CardAction className="self-center">
<div className="flex items-center gap-2">
{networks && networks.length > 0 && (
<Button
variant="outline"
isLoading={isVerifying}
onClick={onVerify}
>
<ShieldCheck className="size-4" />
Verify
</Button>
)}
<SyncNetworks serverId={serverId} />
{networks && networks.length > 0 && (
<HandleNetwork serverId={serverId} />
)}
</div>
</CardAction>
</CardHeader>
<CardContent className="space-y-4 py-8 border-t">
{isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center text-sm text-muted-foreground min-h-[45vh]">
<span>Loading...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : !networks?.length ? (
<div className="flex min-h-[45vh] w-full flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-8">
<div className="rounded-full bg-muted p-4">
<Network className="size-10 text-muted-foreground" />
</div>
<div className="space-y-1 text-center">
<p className="text-sm font-medium">No networks yet</p>
<p className="max-w-sm text-sm text-muted-foreground">
Create Docker networks for your organization and optionally
attach them to a server. Add your first network to get
started.
</p>
</div>
<HandleNetwork serverId={serverId} />
</div>
) : (
<>
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search by name, subnet, gateway..."
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
className="max-w-xs"
/>
<Select value={driverFilter} onValueChange={setDriverFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="Driver" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All drivers</SelectItem>
<SelectItem value="bridge">bridge</SelectItem>
<SelectItem value="overlay">overlay</SelectItem>
</SelectContent>
</Select>
</div>
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<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}>
{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 text-muted-foreground"
>
No networks match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{table.getPageCount() > 1 && (
<div className="flex items-center justify-end gap-4">
<span className="text-sm text-muted-foreground">
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<div className="flex gap-2">
<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>
)}
</>
)}
</CardContent>
</div>
</Card>
</div>
);
};
@@ -0,0 +1,249 @@
"use client";
import { Loader2, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { AlertBlock } from "@/components/shared/alert-block";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { api } from "@/utils/api";
interface Props {
serverId?: string;
}
export const SyncNetworks = ({ serverId }: Props) => {
const [open, setOpen] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const utils = api.useUtils();
const { data, isLoading, error, refetch } =
api.network.networksToSync.useQuery({ serverId }, { enabled: open });
const importMutation = api.network.import.useMutation();
const removeMutation = api.network.remove.useMutation();
const recreateMutation = api.network.recreate.useMutation();
const toggleSelected = (name: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(name)) {
next.delete(name);
} else {
next.add(name);
}
return next;
});
};
const onImport = async () => {
try {
const result = await importMutation.mutateAsync({
serverId,
names: Array.from(selected),
});
if (result.imported.length > 0) {
toast.success(`Imported ${result.imported.length} network(s)`);
}
for (const failure of result.errors) {
toast.error(`Could not import "${failure.name}"`, {
description: failure.error,
});
}
setSelected(new Set());
await utils.network.all.invalidate();
setOpen(false);
await refetch();
} catch (error) {
toast.error("Error importing networks", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRemoveStale = async (networkId: string, name: string) => {
try {
await removeMutation.mutateAsync({ networkId });
toast.success(`Removed stale record "${name}"`);
await utils.network.all.invalidate();
await refetch();
} catch (error) {
toast.error("Error removing record", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
const onRecreate = async (networkId: string, name: string) => {
try {
await recreateMutation.mutateAsync({ networkId });
toast.success(`Network "${name}" recreated in Docker`);
await utils.network.all.invalidate();
await utils.network.networksToSync.invalidate();
await refetch();
} catch (error) {
toast.error("Error recreating network", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
if (!value) setSelected(new Set());
}}
>
<DialogTrigger asChild>
<Button variant="outline">
<RefreshCw className="size-4" />
Sync
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="size-5 text-muted-foreground" />
Sync networks
</DialogTitle>
<DialogDescription>
Import networks that exist in Docker but not in Dokploy, and clean
up records whose network no longer exists.
</DialogDescription>
</DialogHeader>
{error ? (
<AlertBlock type="error">{error.message}</AlertBlock>
) : isLoading ? (
<div className="flex flex-row gap-2 items-center justify-center py-10 text-sm text-muted-foreground">
<span>Scanning Docker networks...</span>
<Loader2 className="animate-spin size-4" />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Found in Docker ({data?.importable.length ?? 0})
</span>
{data?.importable.length ? (
data.importable.map((dockerNetwork) => (
<label
key={dockerNetwork.name}
htmlFor={`import-network-${dockerNetwork.name}`}
className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border p-3"
>
<div className="flex items-center gap-3">
<Checkbox
id={`import-network-${dockerNetwork.name}`}
checked={selected.has(dockerNetwork.name)}
onCheckedChange={() =>
toggleSelected(dockerNetwork.name)
}
/>
<div className="flex flex-col">
<span className="text-sm font-medium">
{dockerNetwork.name}
</span>
{dockerNetwork.subnets.length > 0 && (
<span className="text-xs text-muted-foreground">
{dockerNetwork.subnets.join(" · ")}
</span>
)}
</div>
</div>
<Badge variant="outline">{dockerNetwork.driver}</Badge>
</label>
))
) : (
<span className="text-sm text-muted-foreground">
Nothing to import — everything is in sync.
</span>
)}
</div>
{!!data?.missing.length && (
<>
<Separator />
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">
Missing in Docker ({data.missing.length})
</span>
<span className="text-xs text-muted-foreground">
These records exist in Dokploy but their network is gone
from Docker.
</span>
{data.missing.map((stale) => (
<div
key={stale.networkId}
className="flex items-center justify-between gap-3 rounded-lg border border-dashed p-3"
>
<span className="text-sm">{stale.name}</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
isLoading={recreateMutation.isPending}
onClick={() =>
onRecreate(stale.networkId, stale.name)
}
>
<RotateCcw className="size-3.5" />
Recreate
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove stale record ${stale.name}`}
isLoading={removeMutation.isPending}
onClick={() =>
onRemoveStale(stale.networkId, stale.name)
}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</>
)}
</div>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Close
</Button>
<Button
type="button"
disabled={selected.size === 0}
isLoading={importMutation.isPending}
onClick={onImport}
>
Import selected ({selected.size})
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -234,6 +234,7 @@ export const ShowGeneralPostgres = ({ postgresId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.postgresId}
serverId={data?.serverId || ""}
>
<Button
@@ -229,6 +229,7 @@ export const ShowGeneralRedis = ({ redisId }: Props) => {
</TooltipProvider>
<DockerTerminalModal
appName={data?.appName || ""}
serviceId={data?.redisId}
serverId={data?.serverId || ""}
>
<Button
@@ -328,7 +328,7 @@ export const RequestsTable = ({ dateRange }: RequestsTableProps) => {
open={!!selectedRow}
onOpenChange={(_open) => setSelectedRow(undefined)}
>
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
<SheetHeader>
<SheetTitle>Request log</SheetTitle>
<SheetDescription>
@@ -13,10 +13,14 @@ import {
} from "@/components/ui/card";
import { api } from "@/utils/api";
import { HandleAi } from "./handle-ai";
import { HandleAiProviders } from "./handle-ai-providers";
export const AiForm = () => {
const { data: aiConfigs, refetch, isPending } = api.ai.getAll.useQuery();
const { mutateAsync, isPending: isRemoving } = api.ai.delete.useMutation();
const { data: currentUser } = api.user.get.useQuery();
const isOrgAdmin =
currentUser?.role === "owner" || currentUser?.role === "admin";
return (
<div className="w-full">
@@ -30,7 +34,10 @@ export const AiForm = () => {
</CardTitle>
<CardDescription>Manage your AI configurations</CardDescription>
</div>
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
<div className="flex flex-row gap-2">
{isOrgAdmin && <HandleAiProviders />}
{aiConfigs && aiConfigs?.length > 0 && <HandleAi />}
</div>
</CardHeader>
<CardContent className="space-y-2 py-8 border-t">
{isPending ? (
@@ -0,0 +1,158 @@
"use client";
import { standardSchemaResolver as zodResolver } from "@hookform/resolvers/standard-schema";
import { PlusIcon, ServerIcon, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { api } from "@/utils/api";
const Schema = z.object({
providers: z.array(
z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
}),
),
});
type Schema = z.infer<typeof Schema>;
export const HandleAiProviders = () => {
const utils = api.useUtils();
const [open, setOpen] = useState(false);
const { data: providers } = api.ai.getCustomProviders.useQuery();
const { mutateAsync, isPending } = api.ai.saveCustomProviders.useMutation();
const form = useForm<Schema>({
resolver: zodResolver(Schema),
defaultValues: {
providers: [],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "providers",
});
useEffect(() => {
if (open) {
form.reset({ providers: providers ?? [] });
}
}, [open, providers, form]);
const onSubmit = async (data: Schema) => {
try {
await mutateAsync({ providers: data.providers });
await utils.ai.getCustomProviders.invalidate();
toast.success("Custom providers saved successfully");
setOpen(false);
} catch (error) {
toast.error("Failed to save custom providers", {
description: error instanceof Error ? error.message : "Unknown error",
});
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className="cursor-pointer space-x-3">
<ServerIcon className="h-4 w-4" />
Custom Presets
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Custom AI Providers</DialogTitle>
<DialogDescription>
Define your own AI providers, like an internal LLM platform. When at
least one is defined, only these providers can be used in AI
configurations.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
{fields.length === 0 && (
<p className="text-sm text-muted-foreground">
No custom providers defined. The built-in provider list will be
used.
</p>
)}
{fields.map((fieldItem, index) => (
<div key={fieldItem.id} className="flex gap-2 items-start">
<FormField
control={form.control}
name={`providers.${index}.name`}
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Input placeholder="Internal LLM" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name={`providers.${index}.apiUrl`}
render={({ field }) => (
<FormItem className="flex-[2]">
<FormControl>
<Input
placeholder="https://llm.internal.company/v1"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="group hover:bg-red-500/10"
onClick={() => remove(index)}
>
<Trash2 className="size-4 text-primary group-hover:text-red-500" />
</Button>
</div>
))}
<div className="flex justify-between">
<Button
type="button"
variant="outline"
onClick={() => append({ name: "", apiUrl: "" })}
>
<PlusIcon className="h-4 w-4" />
Add Provider
</Button>
<Button type="submit" isLoading={isPending}>
Save
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
@@ -99,6 +99,11 @@ export const HandleAi = ({ aiId }: Props) => {
enabled: !!aiId,
},
);
const { data: customProviders } = api.ai.getCustomProviders.useQuery();
const hasCustomProviders = (customProviders?.length ?? 0) > 0;
const providerOptions: { name: string; apiUrl: string }[] = hasCustomProviders
? (customProviders ?? [])
: [...AI_PROVIDERS];
const { mutateAsync, isPending } = aiId
? api.ai.update.useMutation()
: api.ai.create.useMutation();
@@ -210,7 +215,9 @@ export const HandleAi = ({ aiId }: Props) => {
<FormLabel>Provider</FormLabel>
<Select
onValueChange={(value) => {
const provider = AI_PROVIDERS.find((p) => p.apiUrl === value);
const provider = providerOptions.find(
(p) => p.apiUrl === value,
);
if (provider) {
form.setValue("name", provider.name);
form.setValue("apiUrl", provider.apiUrl);
@@ -222,15 +229,20 @@ export const HandleAi = ({ aiId }: Props) => {
<SelectValue placeholder="Select a provider preset..." />
</SelectTrigger>
<SelectContent>
{AI_PROVIDERS.map((provider) => (
<SelectItem key={provider.apiUrl} value={provider.apiUrl}>
{providerOptions.map((provider) => (
<SelectItem
key={`${provider.name}-${provider.apiUrl}`}
value={provider.apiUrl}
>
{provider.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[0.8rem] text-muted-foreground">
Quick-fill provider name and URL, or configure manually below
{hasCustomProviders
? "Select one of the providers defined by your organization"
: "Quick-fill provider name and URL, or configure manually below"}
</p>
</div>
@@ -260,6 +272,7 @@ export const HandleAi = ({ aiId }: Props) => {
<FormControl>
<Input
placeholder="https://api.openai.com/v1"
disabled={hasCustomProviders}
{...field}
onChange={(e) => {
field.onChange(e);
@@ -271,7 +284,9 @@ export const HandleAi = ({ aiId }: Props) => {
/>
</FormControl>
<FormDescription>
The base URL for your AI provider's API
{hasCustomProviders
? "The API URL is defined by your organization's providers"
: "The base URL for your AI provider's API"}
</FormDescription>
<FormMessage />
</FormItem>
@@ -100,7 +100,7 @@ export const Enable2FA = () => {
});
if (result.error) {
if (result.error.code === "INVALID_TWO_FACTOR_AUTHENTICATION") {
if (result.error.code === "INVALID_CODE") {
toast.error("Invalid verification code");
return;
}
@@ -40,6 +40,7 @@ interface Props {
children?: React.ReactNode;
serverId?: string;
appType?: "stack" | "docker-compose";
serviceId?: string;
}
export const DockerTerminalModal = ({
@@ -47,6 +48,7 @@ export const DockerTerminalModal = ({
appName,
serverId,
appType,
serviceId,
}: Props) => {
const { data, isPending } = api.docker.getContainersByAppNameMatch.useQuery(
{
@@ -131,6 +133,7 @@ export const DockerTerminalModal = ({
serverId={serverId || ""}
id="terminal"
containerId={containerId || "select-a-container"}
serviceId={serviceId}
/>
<Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}>
<DialogContent onEscapeKeyDown={(event) => event.preventDefault()}>
@@ -1,5 +1,6 @@
import { ShowResources } from "@/components/dashboard/application/advanced/show-resources";
import { ShowVolumes } from "@/components/dashboard/application/advanced/volumes/show-volumes";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { ShowCustomCommand } from "@/components/dashboard/postgres/advanced/show-custom-command";
import { ShowClusterSettings } from "../application/advanced/cluster/show-cluster-settings";
import { RebuildDatabase } from "./rebuild-database";
@@ -21,6 +22,7 @@ export const ShowDatabaseAdvancedSettings = ({ id, type }: Props) => {
<ShowClusterSettings id={id} type={type} />
) : null}
<ShowVolumes id={id} type={type} />
<AssignNetworks id={id} type={type} />
<ShowResources id={id} type={type} />
<RebuildDatabase id={id} type={type} />
</div>
+16 -1
View File
@@ -25,6 +25,7 @@ import {
Loader2,
LogIn,
type LucideIcon,
Network,
Package,
Palette,
PieChart,
@@ -209,6 +210,20 @@ const MENU: Menu = {
// Only enabled for users with access to Docker
isEnabled: ({ permissions }) => !!permissions?.docker.read,
},
{
isSingle: true,
title: "Networks",
url: "/dashboard/networks",
icon: Network,
// Only enabled for admins and users with access to Docker in non-cloud environments
isEnabled: ({ auth, isCloud }) =>
!!(
(auth?.role === "owner" ||
auth?.role === "admin" ||
auth?.canAccessToDocker) &&
!isCloud
),
},
{
isSingle: true,
title: "Requests",
@@ -648,7 +663,7 @@ function SidebarLogo() {
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
className="w-64 rounded-lg max-h-[min(70vh,28rem)] flex flex-col"
align="start"
side={isMobile ? "bottom" : "right"}
sideOffset={4}
@@ -95,6 +95,7 @@ const RESOURCE_LABELS: Record<string, string> = {
notification: "Notification",
settings: "Settings",
session: "Session",
network: "Network",
};
function MetadataCell({ metadata }: { metadata: string | null }) {
@@ -47,7 +47,7 @@ export const DrawerLogs = ({ isOpen, onClose, filteredLogs }: Props) => {
onClose();
}}
>
<SheetContent className="sm:max-w-[740px] flex flex-col">
<SheetContent className="w-full sm:max-w-[740px]! flex flex-col">
<SheetHeader>
<SheetTitle>Deployment Logs</SheetTitle>
<SheetDescription>Details of the request log entry.</SheetDescription>
+1 -1
View File
@@ -6,7 +6,7 @@ import type * as React from "react";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
+1 -1
View File
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-[80px] w-full rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
"flex field-sizing-content min-h-[80px] w-full [overflow-wrap:anywhere] rounded-lg border border-input bg-transparent px-3 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className,
)}
{...props}
@@ -0,0 +1,32 @@
CREATE TYPE "public"."networkDriver" AS ENUM('bridge', 'overlay');--> statement-breakpoint
CREATE TABLE "network" (
"networkId" text PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"driver" "networkDriver" DEFAULT 'bridge' NOT NULL,
"internal" boolean DEFAULT false NOT NULL,
"attachable" boolean DEFAULT false NOT NULL,
"enableIPv4" boolean DEFAULT true NOT NULL,
"enableIPv6" boolean DEFAULT false NOT NULL,
"ipam" jsonb DEFAULT '{}'::jsonb,
"createdAt" text NOT NULL,
"organizationId" text NOT NULL,
"serverId" text
);
--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "application" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "compose" ADD COLUMN "serviceNetworks" jsonb DEFAULT '[]'::jsonb;--> statement-breakpoint
ALTER TABLE "libsql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "libsql" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "mariadb" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "mongo" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "mysql" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "postgres" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "networkIds" text[] DEFAULT '{}';--> statement-breakpoint
ALTER TABLE "redis" ADD COLUMN "detachDokployNetwork" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "network" ADD CONSTRAINT "network_organizationId_organization_id_fk" FOREIGN KEY ("organizationId") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "network" ADD CONSTRAINT "network_serverId_server_serverId_fk" FOREIGN KEY ("serverId") REFERENCES "public"."server"("serverId") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1226,6 +1226,13 @@
"when": 1783674181297,
"tag": "0174_great_naoko",
"breakpoints": true
},
{
"idx": 175,
"version": "7",
"when": 1784965709630,
"tag": "0175_fantastic_peter_quill",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dokploy",
"version": "v0.29.12",
"version": "v0.29.13",
"private": true,
"license": "Apache-2.0",
"type": "module",
@@ -1,5 +1,6 @@
import { createGithub } from "@dokploy/server";
import { createGithub, validateRequest } from "@dokploy/server";
import { db } from "@dokploy/server/db";
import { hasPermission } from "@dokploy/server/services/permission";
import { eq } from "drizzle-orm";
import type { NextApiRequest, NextApiResponse } from "next";
import { Octokit } from "octokit";
@@ -21,18 +22,22 @@ export default async function handler(
if (!code) {
return res.status(400).json({ error: "Missing code parameter" });
}
const [action, ...rest] = state?.split(":");
// For gh_init: rest[0] = organizationId, rest[1] = userId
// For gh_setup: rest[0] = githubProviderId
const { user, session } = await validateRequest(req);
if (!user || !session?.activeOrganizationId) {
return res.status(401).json({ error: "Unauthorized" });
}
const ctx = {
user: { id: user.id },
session: { activeOrganizationId: session.activeOrganizationId },
};
const [action] = state?.split(":") ?? [];
if (!(await hasPermission(ctx, { gitProviders: ["create"] }))) {
return res.status(403).json({ error: "Forbidden" });
}
if (action === "gh_init") {
const organizationId = rest[0];
const userId = rest[1] || (req.query.userId as string);
if (!userId) {
return res.status(400).json({ error: "Missing userId parameter" });
}
const octokit = new Octokit({});
const { data } = await octokit.request(
"POST /app-manifests/{code}/conversions",
@@ -51,16 +56,32 @@ export default async function handler(
githubWebhookSecret: data.webhook_secret,
githubPrivateKey: data.pem,
},
organizationId as string,
userId,
session.activeOrganizationId,
user.id,
);
} else if (action === "gh_setup") {
const githubId = state?.split(":")[1];
if (!githubId) {
return res.status(400).json({ error: "Missing github provider id" });
}
const provider = await db.query.github.findFirst({
where: eq(github.githubId, githubId),
with: { gitProvider: true },
});
if (
!provider ||
provider.gitProvider.organizationId !== session.activeOrganizationId
) {
return res.status(404).json({ error: "Github provider not found" });
}
await db
.update(github)
.set({
githubInstallationId: installation_id,
})
.where(eq(github.githubId, rest[0] as string))
.where(eq(github.githubId, githubId))
.returning();
}
+89
View File
@@ -0,0 +1,89 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import { validateRequest } from "@dokploy/server/lib/auth";
import { createServerSideHelpers } from "@trpc/react-query/server";
import type { GetServerSidePropsContext } from "next";
import type { ReactElement } from "react";
import superjson from "superjson";
import { ShowNetworks } from "@/components/dashboard/networks/show-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { ServerFilter } from "@/components/shared/server-filter";
import { appRouter } from "@/server/api/root";
const Dashboard = () => {
return (
<ServerFilter>
{(serverId) => <ShowNetworks serverId={serverId} />}
</ServerFilter>
);
};
export default Dashboard;
Dashboard.getLayout = (page: ReactElement) => {
return <DashboardLayout>{page}</DashboardLayout>;
};
export async function getServerSideProps(
ctx: GetServerSidePropsContext<{ serviceId: string }>,
) {
if (IS_CLOUD) {
return {
redirect: {
permanent: true,
destination: "/dashboard/projects",
},
};
}
const { user, session } = await validateRequest(ctx.req);
if (!user) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
const { req } = ctx;
const helpers = createServerSideHelpers({
router: appRouter,
ctx: {
req: req as any,
res: ctx.res as any,
db: null as any,
session: session as any,
user: user as any,
},
transformer: superjson,
});
try {
await helpers.project.all.prefetch();
if (user.role === "member") {
const userR = await helpers.user.one.fetch({
userId: user.id,
});
if (!userR?.canAccessToDocker) {
return {
redirect: {
permanent: true,
destination: "/",
},
};
}
}
await helpers.network.all.prefetch({});
return {
props: {
trpcState: helpers.dehydrate(),
},
};
} catch {
return {
props: {},
};
}
}
@@ -35,6 +35,7 @@ import { ShowVolumeBackups } from "@/components/dashboard/application/volume-bac
import { DeleteService } from "@/components/dashboard/compose/delete-service";
import { ContainerFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-container-monitoring";
import { ContainerPaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-container-monitoring";
import { AssignNetworks } from "@/components/dashboard/networks/assign-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -347,6 +348,7 @@ const Service = (
<ShowDockerLogs
appName={data?.appName || ""}
serverId={data?.serverId || ""}
serviceId={data?.applicationId}
/>
</div>
</TabsContent>
@@ -417,6 +419,7 @@ const Service = (
<ShowBuildServer applicationId={applicationId} />
<ShowResources id={applicationId} type="application" />
<ShowVolumes id={applicationId} type="application" />
<AssignNetworks id={applicationId} type="application" />
<ShowRedirects applicationId={applicationId} />
<ShowSecurity applicationId={applicationId} />
<ShowPorts applicationId={applicationId} />
@@ -31,6 +31,7 @@ import { UpdateCompose } from "@/components/dashboard/compose/update-compose";
import { ShowBackups } from "@/components/dashboard/database/backups/show-backups";
import { ComposeFreeMonitoring } from "@/components/dashboard/monitoring/free/container/show-free-compose-monitoring";
import { ComposePaidMonitoring } from "@/components/dashboard/monitoring/paid/container/show-paid-compose-monitoring";
import { AssignComposeNetworks } from "@/components/dashboard/networks/assign-compose-networks";
import { DashboardLayout } from "@/components/layouts/dashboard-layout";
import { AdvanceBreadcrumb } from "@/components/shared/advance-breadcrumb";
import { StatusTooltip } from "@/components/shared/status-tooltip";
@@ -312,6 +313,7 @@ const Service = (
serverId={data?.serverId || undefined}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/>
</div>
</TabsContent>
@@ -380,11 +382,13 @@ const Service = (
serverId={data?.serverId || ""}
appName={data?.appName || ""}
appType={data?.composeType || "docker-compose"}
serviceId={data?.composeId}
/>
) : (
<ShowDockerLogsStack
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.composeId}
/>
)}
</div>
@@ -424,6 +428,7 @@ const Service = (
<AddCommandCompose composeId={composeId} />
<ShowVolumes id={composeId} type="compose" />
<ShowImport composeId={composeId} />
<AssignComposeNetworks composeId={composeId} />
<IsolatedDeploymentTab composeId={composeId} />
</div>
</TabsContent>
@@ -185,7 +185,7 @@ const Libsql = (
router.push(newPath, undefined, { shallow: true });
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -269,6 +269,7 @@ const Libsql = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.libsqlId}
/>
</div>
</TabsContent>
@@ -199,7 +199,7 @@ const Mariadb = (
router.push(newPath, undefined, { shallow: true });
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -299,6 +299,7 @@ const Mariadb = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.mariadbId}
/>
</div>
</TabsContent>
@@ -199,7 +199,7 @@ const Mongo = (
router.push(newPath, undefined, { shallow: true });
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -299,6 +299,7 @@ const Mongo = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.mongoId}
/>
</div>
</TabsContent>
@@ -199,7 +199,7 @@ const MySql = (
router.push(newPath, undefined, { shallow: true });
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start ",
@@ -276,6 +276,7 @@ const MySql = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.mysqlId}
/>
</div>
</TabsContent>
@@ -200,7 +200,7 @@ const Postgresql = (
});
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -284,6 +284,7 @@ const Postgresql = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.postgresId}
/>
</div>
</TabsContent>
@@ -198,7 +198,7 @@ const Redis = (
router.push(newPath, undefined, { shallow: true });
}}
>
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-scroll">
<div className="flex flex-row items-center justify-between w-full gap-4 overflow-x-auto">
<TabsList
className={cn(
"md:grid md:w-fit max-md:overflow-y-scroll justify-start",
@@ -297,6 +297,7 @@ const Redis = (
<ShowDockerLogs
serverId={data?.serverId || ""}
appName={data?.appName || ""}
serviceId={data?.redisId}
/>
</div>
</TabsContent>
+2
View File
@@ -21,6 +21,7 @@ import { mariadbRouter } from "./routers/mariadb";
import { mongoRouter } from "./routers/mongo";
import { mountRouter } from "./routers/mount";
import { mysqlRouter } from "./routers/mysql";
import { networkRouter } from "./routers/network";
import { notificationRouter } from "./routers/notification";
import { organizationRouter } from "./routers/organization";
import { patchRouter } from "./routers/patch";
@@ -60,6 +61,7 @@ export const appRouter = createTRPCRouter({
application: applicationRouter,
backup: backupRouter,
bitbucket: bitbucketRouter,
network: networkRouter,
certificates: certificateRouter,
cluster: clusterRouter,
compose: composeRouter,
+16 -1
View File
@@ -1,6 +1,7 @@
import { IS_CLOUD } from "@dokploy/server/constants";
import {
apiCreateAi,
apiSaveAiCustomProviders,
apiUpdateAi,
deploySuggestionSchema,
} from "@dokploy/server/db/schema/ai";
@@ -13,7 +14,9 @@ import {
deleteAiSettings,
getAiSettingById,
getAiSettingsByOrganizationId,
getCustomAiProviders,
saveAiSettings,
saveCustomAiProviders,
suggestVariants,
} from "@dokploy/server/services/ai";
import { createComposeByTemplate } from "@dokploy/server/services/compose";
@@ -200,6 +203,19 @@ export const aiRouter = createTRPCRouter({
return await deleteAiSettings(input.aiId);
}),
getCustomProviders: protectedProcedure.query(async ({ ctx }) => {
return await getCustomAiProviders(ctx.session.activeOrganizationId);
}),
saveCustomProviders: adminProcedure
.input(apiSaveAiCustomProviders)
.mutation(async ({ ctx, input }) => {
return await saveCustomAiProviders(
ctx.session.activeOrganizationId,
input.providers,
);
}),
getEnabledProviders: protectedProcedure.query(async ({ ctx }) => {
const settings = await getAiSettingsByOrganizationId(
ctx.session.activeOrganizationId,
@@ -346,7 +362,6 @@ ${input.logs}`,
name: input.name,
sourceType: "raw",
appName: `${projectName}-${generatePassword(6)}`,
isolatedDeployment: true,
environmentId: input.environmentId,
});
+2 -1
View File
@@ -49,6 +49,7 @@ import {
restoreWebServerBackup,
} from "@dokploy/server/utils/restore";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { z } from "zod";
import {
createTRPCRouter,
@@ -510,7 +511,7 @@ export const backupRouter = createTRPCRouter({
: input.search;
const searchPath = baseDir ? `${bucketPath}/${baseDir}` : bucketPath;
const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} "${searchPath}" --no-mimetype --no-modtime 2>/dev/null`;
const listCommand = `rclone lsjson ${rcloneFlags.join(" ")} ${quote([searchPath])} --no-mimetype --no-modtime 2>/dev/null`;
let stdout = "";
+3 -2
View File
@@ -6,6 +6,7 @@ import {
getRemoteDocker,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { quote } from "shell-quote";
import { z } from "zod";
import { audit } from "@/server/api/utils/audit";
import { getLocalServerIp } from "@/server/wss/terminal";
@@ -51,8 +52,8 @@ export const clusterRouter = createTRPCRouter({
}
}
try {
const drainCommand = `docker node update --availability drain ${input.nodeId}`;
const removeCommand = `docker node rm ${input.nodeId} --force`;
const drainCommand = `docker node update --availability drain ${quote([input.nodeId])}`;
const removeCommand = `docker node rm ${quote([input.nodeId])} --force`;
if (input.serverId) {
await execAsyncRemote(input.serverId, drainCommand);
@@ -639,7 +639,6 @@ export const composeRouter = createTRPCRouter({
name: input.id,
sourceType: "raw",
appName: appName,
isolatedDeployment: template.config.config?.isolated !== false,
});
await addNewService(ctx, compose.composeId);
@@ -997,7 +996,6 @@ export const composeRouter = createTRPCRouter({
composeFile: templateData.compose,
sourceType: "raw",
env: processedTemplate.envs?.join("\n"),
isolatedDeployment: true,
});
if (processedTemplate.mounts && processedTemplate.mounts.length > 0) {
@@ -10,6 +10,7 @@ import {
import { db } from "@dokploy/server/db";
import { TRPCError } from "@trpc/server";
import { desc, eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { createTRPCRouter, withPermission } from "@/server/api/trpc";
import { audit } from "@/server/api/utils/audit";
import {
@@ -58,10 +59,10 @@ export const destinationRouter = createTRPCRouter({
} = input;
try {
const rcloneFlags = [
`--s3-access-key-id="${accessKey}"`,
`--s3-secret-access-key="${secretAccessKey}"`,
`--s3-region="${region}"`,
`--s3-endpoint="${endpoint}"`,
`--s3-access-key-id=${quote([accessKey])}`,
`--s3-secret-access-key=${quote([secretAccessKey])}`,
`--s3-region=${quote([region])}`,
`--s3-endpoint=${quote([endpoint])}`,
"--s3-no-check-bucket",
"--s3-force-path-style",
"--retries 1",
@@ -70,13 +71,13 @@ export const destinationRouter = createTRPCRouter({
"--contimeout 5s",
];
if (provider) {
rcloneFlags.unshift(`--s3-provider="${provider}"`);
rcloneFlags.unshift(`--s3-provider=${quote([provider])}`);
}
if (additionalFlags?.length) {
rcloneFlags.push(...additionalFlags);
}
const rcloneDestination = `:s3:${bucket}`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} "${rcloneDestination}"`;
const rcloneCommand = `rclone ls ${rcloneFlags.join(" ")} ${quote([rcloneDestination])}`;
if (IS_CLOUD && !input.serverId) {
throw new TRPCError({
@@ -153,6 +153,7 @@ export const environmentRouter = createTRPCRouter({
return environments;
} catch (error) {
console.log(error);
throw new TRPCError({
code: "BAD_REQUEST",
message: `Error fetching environments: ${error instanceof Error ? error.message : error}`,
+151
View File
@@ -0,0 +1,151 @@
import {
createNetwork,
findNetworkById,
findNetworksToSync,
importDockerNetworks,
inspectNetwork,
recreateNetwork,
removeNetwork,
} from "@dokploy/server";
import { TRPCError } from "@trpc/server";
import { and, desc, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
import {
apiCreateNetwork,
apiFindOneNetwork,
apiRemoveNetwork,
network as networkTable,
} from "@/server/db/schema";
import { audit } from "../utils/audit";
export const networkRouter = createTRPCRouter({
all: protectedProcedure
.input(z.object({ serverId: z.string().optional() }))
.query(async ({ ctx, input }) => {
const rows = await db.query.network.findMany({
where: and(
eq(networkTable.organizationId, ctx.session.activeOrganizationId),
input.serverId
? eq(networkTable.serverId, input.serverId)
: isNull(networkTable.serverId),
),
orderBy: desc(networkTable.createdAt),
});
return rows;
}),
one: protectedProcedure
.input(apiFindOneNetwork)
.query(async ({ ctx, input }) => {
const row = await findNetworkById(input.networkId);
if (row.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return row;
}),
create: protectedProcedure
.input(apiCreateNetwork)
.mutation(async ({ ctx, input }) => {
const created = await createNetwork(
input,
ctx.session.activeOrganizationId,
);
await audit(ctx, {
action: "create",
resourceType: "network",
resourceId: created.networkId,
resourceName: created.name,
});
return created;
}),
networksToSync: protectedProcedure
.input(z.object({ serverId: z.string().optional() }))
.query(async ({ ctx, input }) => {
return findNetworksToSync(
ctx.session.activeOrganizationId,
input.serverId ?? null,
);
}),
import: protectedProcedure
.input(
z.object({
serverId: z.string().optional(),
names: z.array(z.string().min(1)).min(1),
}),
)
.mutation(async ({ ctx, input }) => {
const result = await importDockerNetworks(
ctx.session.activeOrganizationId,
input.serverId ?? null,
input.names,
);
if (result.imported.length > 0) {
await audit(ctx, {
action: "create",
resourceType: "network",
resourceName: result.imported.join(", "),
metadata: { imported: result.imported },
});
}
return result;
}),
inspect: protectedProcedure
.input(apiFindOneNetwork)
.query(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
return inspectNetwork(input.networkId);
}),
recreate: protectedProcedure
.input(apiFindOneNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Network not found",
});
}
const recreated = await recreateNetwork(input.networkId);
await audit(ctx, {
action: "reload",
resourceType: "network",
resourceId: recreated.networkId,
resourceName: recreated.name,
});
return recreated;
}),
remove: protectedProcedure
.input(apiRemoveNetwork)
.mutation(async ({ ctx, input }) => {
const network = await findNetworkById(input.networkId);
if (network.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Not authorized to delete this network",
});
}
const removed = await removeNetwork(input.networkId);
await audit(ctx, {
action: "delete",
resourceType: "network",
resourceId: removed.networkId,
resourceName: removed.name,
});
return removed;
}),
});
+11 -2
View File
@@ -5,6 +5,7 @@ import {
findRegistryById,
IS_CLOUD,
removeRegistry,
safeDockerLoginCommand,
updateRegistry,
} from "@dokploy/server";
import { db } from "@dokploy/server/db";
@@ -122,7 +123,11 @@ export const registryRouter = createTRPCRouter({
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
`echo ${input.password} | docker ${args.join(" ")}`,
safeDockerLoginCommand(
input.registryUrl,
input.username,
input.password,
),
);
} else {
await execFileAsync("docker", args, {
@@ -182,7 +187,11 @@ export const registryRouter = createTRPCRouter({
if (input.serverId && input.serverId !== "none") {
await execAsyncRemote(
input.serverId,
`echo ${registryData.password} | docker ${args.join(" ")}`,
safeDockerLoginCommand(
registryData.registryUrl,
registryData.username,
registryData.password,
),
);
} else {
await execFileAsync("docker", args, {
+51 -157
View File
@@ -13,6 +13,7 @@ import {
findMemberByUserId,
} from "@dokploy/server/services/permission";
import {
assertHostScheduleAccess,
createSchedule,
deleteSchedule,
findScheduleById,
@@ -31,6 +32,8 @@ export const scheduleRouter = createTRPCRouter({
create: protectedProcedure
.input(createScheduleSchema)
.mutation(async ({ input, ctx }) => {
await assertHostScheduleAccess(ctx, input.scheduleType, input.serverId);
const serviceId = input.applicationId || input.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
@@ -44,51 +47,14 @@ export const scheduleRouter = createTRPCRouter({
);
}
} else {
if (input.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
input.scheduleType === "server" ||
input.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
if (IS_CLOUD && input.scheduleType === "server" && input.serverId) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (input.scheduleType === "server" && input.serverId) {
const targetServer = await findServerById(input.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
if (IS_CLOUD) {
await assertScheduledJobLimit(
ctx.session.activeOrganizationId,
"server",
input.serverId,
);
}
}
}
const newSchedule = await createSchedule({
@@ -135,6 +101,22 @@ export const scheduleRouter = createTRPCRouter({
});
}
await assertHostScheduleAccess(
ctx,
existingSchedule.scheduleType,
existingSchedule.serverId,
);
if (
input.scheduleType &&
input.scheduleType !== existingSchedule.scheduleType
) {
await assertHostScheduleAccess(
ctx,
input.scheduleType,
input.serverId ?? existingSchedule.serverId,
);
}
const serviceId =
existingSchedule.applicationId || existingSchedule.composeId;
if (serviceId) {
@@ -142,47 +124,7 @@ export const scheduleRouter = createTRPCRouter({
schedule: ["update"],
});
} else {
if (existingSchedule.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["update"] });
if (
existingSchedule.scheduleType === "server" ||
existingSchedule.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (
existingSchedule.scheduleType === "server" &&
existingSchedule.serverId
) {
const targetServer = await findServerById(existingSchedule.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
const updatedSchedule = await updateSchedule(input);
@@ -222,50 +164,19 @@ export const scheduleRouter = createTRPCRouter({
.input(z.object({ scheduleId: z.string() }))
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
await assertHostScheduleAccess(
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["delete"],
});
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["delete"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
await deleteSchedule(input.scheduleId);
@@ -348,9 +259,23 @@ export const scheduleRouter = createTRPCRouter({
where: where[input.scheduleType],
orderBy: [asc(schedules.createdAt)],
with: {
application: true,
application: {
columns: {
applicationId: true,
appName: true,
name: true,
serverId: true,
},
},
server: true,
compose: true,
compose: {
columns: {
composeId: true,
appName: true,
name: true,
serverId: true,
},
},
deployments: {
orderBy: [desc(deployments.createdAt)],
},
@@ -389,50 +314,19 @@ export const scheduleRouter = createTRPCRouter({
.input(z.object({ scheduleId: z.string().min(1) }))
.mutation(async ({ input, ctx }) => {
const scheduleItem = await findScheduleById(input.scheduleId);
await assertHostScheduleAccess(
ctx,
scheduleItem.scheduleType,
scheduleItem.serverId,
);
const serviceId = scheduleItem.applicationId || scheduleItem.composeId;
if (serviceId) {
await checkServicePermissionAndAccess(ctx, serviceId, {
schedule: ["create"],
});
} else {
if (scheduleItem.scheduleType === "dokploy-server" && IS_CLOUD) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Host-level schedules are not available in the cloud version.",
});
}
await checkPermission(ctx, { schedule: ["create"] });
if (
scheduleItem.scheduleType === "server" ||
scheduleItem.scheduleType === "dokploy-server"
) {
const member = await findMemberByUserId(
ctx.user.id,
ctx.session.activeOrganizationId,
);
if (member.role !== "owner" && member.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message:
"Only owners and admins can manage server-level schedules.",
});
}
}
if (scheduleItem.scheduleType === "server" && scheduleItem.serverId) {
const targetServer = await findServerById(scheduleItem.serverId);
if (
targetServer.organizationId !== ctx.session.activeOrganizationId
) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You don't have access to this server.",
});
}
}
}
try {
await runCommand(input.scheduleId);
+8 -1
View File
@@ -413,6 +413,14 @@ export const serverRouter = createTRPCRouter({
.input(apiRemoveServer)
.mutation(async ({ input, ctx }) => {
try {
const currentServer = await findServerById(input.serverId);
if (currentServer.organizationId !== ctx.session.activeOrganizationId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You are not authorized to delete this server",
});
}
const activeServers = await haveActiveServices(input.serverId);
if (activeServers) {
@@ -421,7 +429,6 @@ export const serverRouter = createTRPCRouter({
message: "Server has active services, please delete them first",
});
}
const currentServer = await findServerById(input.serverId);
await audit(ctx, {
action: "delete",
resourceType: "server",
@@ -13,6 +13,8 @@ import { db } from "@dokploy/server/db";
import {
createVolumeBackupSchema,
updateVolumeBackupSchema,
VOLUME_NAME_MESSAGE,
VOLUME_NAME_REGEX,
volumeBackups,
} from "@dokploy/server/db/schema";
import { findDestinationById } from "@dokploy/server/services/destination";
@@ -55,14 +57,24 @@ export const volumeBackupsRouter = createTRPCRouter({
return await db.query.volumeBackups.findMany({
where: eq(volumeBackups[`${input.volumeBackupType}Id`], input.id),
with: {
application: true,
postgres: true,
mysql: true,
mariadb: true,
mongo: true,
redis: true,
compose: true,
libsql: true,
application: {
columns: { applicationId: true, appName: true, serverId: true },
},
postgres: {
columns: { postgresId: true, appName: true, serverId: true },
},
mysql: { columns: { mysqlId: true, appName: true, serverId: true } },
mariadb: {
columns: { mariadbId: true, appName: true, serverId: true },
},
mongo: { columns: { mongoId: true, appName: true, serverId: true } },
redis: { columns: { redisId: true, appName: true, serverId: true } },
compose: {
columns: { composeId: true, appName: true, serverId: true },
},
libsql: {
columns: { libsqlId: true, appName: true, serverId: true },
},
},
orderBy: [desc(volumeBackups.createdAt)],
});
@@ -275,7 +287,10 @@ export const volumeBackupsRouter = createTRPCRouter({
z.object({
backupFileName: z.string().min(1),
destinationId: z.string().min(1),
volumeName: z.string().min(1),
volumeName: z
.string()
.min(1)
.regex(VOLUME_NAME_REGEX, VOLUME_NAME_MESSAGE),
id: z.string().min(1),
serviceType: z.enum(["application", "compose"]),
serverId: z.string().optional(),
+89
View File
@@ -0,0 +1,89 @@
import { getAccessibleServerIds } from "@dokploy/server";
import {
checkServiceAccess,
findMemberByUserId,
hasPermission,
} from "@dokploy/server/services/permission";
type WssUser = { id: string } | null | undefined;
type WssSession = { activeOrganizationId?: string | null } | null | undefined;
const buildCtx = (user: { id: string }, activeOrganizationId: string) => ({
user: { id: user.id },
session: { activeOrganizationId },
});
// Authorizes docker/container operations opened over a WebSocket (container
// terminal, container logs, container stats). Requires the docker permission
// (owner/admin, or a member explicitly granted canAccessToDocker) and, for a
// remote server, that the server is accessible to the caller. Previously these
// handlers only checked session + organization, so any member could reach a
// root shell / logs of any container.
export const canAccessDockerOverWss = async (
user: WssUser,
session: WssSession,
serverId?: string | null,
serviceId?: string | null,
): Promise<boolean> => {
if (!user || !session?.activeOrganizationId) return false;
const ctx = buildCtx(user, session.activeOrganizationId);
// When the container belongs to a specific Dokploy service (opened from a
// service page, so serviceId is present), access to that service is the
// authoritative gate — matching the service tRPC endpoints (e.g.
// application.readLogs, which check service access only). A member granted
// the service can read its logs / open its terminal even without the broad
// "docker" permission or explicit access to the server it runs on.
if (serviceId) {
try {
await checkServiceAccess(ctx, serviceId, "read");
return true;
} catch {
return false;
}
}
// Generic Docker overview (no service context): mirror the docker tRPC router
// — require the docker permission and access to the target server.
if (!(await hasPermission(ctx, { docker: ["read"] }))) return false;
if (serverId && serverId !== "local") {
const accessible = await getAccessibleServerIds({
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
if (!accessible.has(serverId)) return false;
}
return true;
};
// Authorizes the host/server SSH terminal opened over a WebSocket. The local
// host terminal is a root shell on the control-plane host, so it is restricted
// to owner/admin. A remote server terminal is gated on server access.
export const canAccessTerminalOverWss = async (
user: WssUser,
session: WssSession,
serverId?: string | null,
): Promise<boolean> => {
if (!user || !session?.activeOrganizationId) return false;
if (serverId && serverId !== "local") {
const accessible = await getAccessibleServerIds({
userId: user.id,
activeOrganizationId: session.activeOrganizationId,
});
return accessible.has(serverId);
}
try {
const member = await findMemberByUserId(
user.id,
session.activeOrganizationId,
);
return member?.role === "owner" || member?.role === "admin";
} catch {
return false;
}
};
@@ -3,6 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import {
getShell,
isValidContainerId,
@@ -41,6 +42,7 @@ export const setupDockerContainerLogsWebSocketServer = (
const since = url.searchParams.get("since") ?? "all";
const serverId = url.searchParams.get("serverId");
const runType = url.searchParams.get("runType");
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req);
if (!containerId) {
@@ -74,6 +76,11 @@ export const setupDockerContainerLogsWebSocketServer = (
return;
}
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
// Set up keep-alive ping mechanism to prevent timeout
// Send ping every 45 seconds to keep connection alive
const pingInterval = setInterval(() => {
@@ -3,6 +3,7 @@ import { findServerById, IS_CLOUD, validateRequest } from "@dokploy/server";
import { spawn } from "node-pty";
import { Client } from "ssh2";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
import { isValidContainerId, isValidShell } from "./utils";
export const setupDockerContainerTerminalWebSocketServer = (
@@ -32,6 +33,7 @@ export const setupDockerContainerTerminalWebSocketServer = (
const containerId = url.searchParams.get("containerId");
const activeWay = url.searchParams.get("activeWay");
const serverId = url.searchParams.get("serverId");
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req);
if (!containerId) {
@@ -58,6 +60,11 @@ export const setupDockerContainerTerminalWebSocketServer = (
ws.close();
return;
}
if (!(await canAccessDockerOverWss(user, session, serverId, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
try {
if (serverId) {
const server = await findServerById(serverId);
+7
View File
@@ -9,6 +9,7 @@ import {
validateRequest,
} from "@dokploy/server";
import { WebSocketServer } from "ws";
import { canAccessDockerOverWss } from "./authorize";
export const setupDockerStatsMonitoringSocketServer = (
server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>,
@@ -44,6 +45,7 @@ export const setupDockerStatsMonitoringSocketServer = (
| "application"
| "stack"
| "docker-compose";
const serviceId = url.searchParams.get("serviceId");
const { user, session } = await validateRequest(req);
if (!appName) {
@@ -55,6 +57,11 @@ export const setupDockerStatsMonitoringSocketServer = (
ws.close();
return;
}
if (!(await canAccessDockerOverWss(user, session, null, serviceId))) {
ws.close(4003, "Not authorized");
return;
}
const intervalId = setInterval(async () => {
try {
// Special case: when monitoring "dokploy", get host system stats instead of container stats
+6
View File
@@ -9,6 +9,7 @@ import { publicIpv4, publicIpv6 } from "public-ip";
import { Client, type ConnectConfig } from "ssh2";
import { WebSocketServer } from "ws";
import { getDockerHost } from "../utils/docker";
import { canAccessTerminalOverWss } from "./authorize";
import { setupLocalServerSSHKey } from "./utils";
const COMMAND_TO_ALLOW_LOCAL_ACCESS = `
@@ -93,6 +94,11 @@ export const setupTerminalWebSocketServer = (
return;
}
if (!(await canAccessTerminalOverWss(user, session, serverId))) {
ws.close(4003, "Not authorized");
return;
}
let connectionDetails: ConnectConfig = {};
const isLocalServer = serverId === "local";
+6
View File
@@ -269,6 +269,12 @@
@apply bg-background text-foreground;
}
/* Cursor pointer on buttons */
button:not([disabled]),
[role="button"]:not([disabled]) {
cursor: pointer;
}
/* Custom scrollbar styling */
::-webkit-scrollbar {
width: 0.3125rem;
+35916 -24480
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -8,6 +8,7 @@ import {
timestamp,
} from "drizzle-orm/pg-core";
import { nanoid } from "nanoid";
import { network } from "./network";
import { projects } from "./project";
import { server } from "./server";
import { ssoProvider } from "./sso";
@@ -108,6 +109,7 @@ export const organizationRelations = relations(
references: [user.id],
}),
servers: many(server),
networks: many(network),
projects: many(projects),
members: many(member),
ssoProviders: many(ssoProvider),
+9
View File
@@ -54,6 +54,15 @@ export const apiUpdateAi = createSchema
})
.omit({ organizationId: true });
export const aiCustomProviderSchema = z.object({
name: z.string().min(1, { message: "Name is required" }),
apiUrl: z.string().url({ message: "Please enter a valid URL" }),
});
export const apiSaveAiCustomProviders = z.object({
providers: z.array(aiCustomProviderSchema),
});
export const deploySuggestionSchema = z.object({
environmentId: z.string().min(1),
id: z.string().min(1),
@@ -233,6 +233,10 @@ export const applications = pgTable("application", {
onDelete: "set null",
},
),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const applicationsRelations = relations(
@@ -374,6 +378,8 @@ const createSchema = createInsertSchema(applications, {
previewRequireCollaboratorPermissions: z.boolean().optional(),
watchPaths: z.array(z.string()).optional().optional(),
previewLabels: z.array(z.string()).optional(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
cleanCache: z.boolean().optional(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
+2 -1
View File
@@ -91,4 +91,5 @@ export type AuditResourceType =
| "cluster"
| "mount"
| "application"
| "compose";
| "compose"
| "network";
+26 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { boolean, integer, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import {
boolean,
integer,
jsonb,
pgEnum,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -113,6 +120,15 @@ export const compose = pgTable("compose", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
serviceNetworks: jsonb("serviceNetworks")
.$type<
Array<{
serviceName: string;
networkIds: string[];
detachDokployNetwork: boolean;
}>
>()
.default([]),
});
export const composeRelations = relations(compose, ({ one, many }) => ({
@@ -174,6 +190,15 @@ const createSchema = createInsertSchema(compose, {
.optional(),
triggerType: z.enum(["push", "tag"]).optional(),
composeStatus: z.enum(["idle", "running", "done", "error"]).optional(),
serviceNetworks: z
.array(
z.object({
serviceName: z.string(),
networkIds: z.array(z.string()),
detachDokployNetwork: z.boolean(),
}),
)
.optional(),
});
export const apiCreateCompose = createSchema.pick({
+1
View File
@@ -20,6 +20,7 @@ export * from "./mariadb";
export * from "./mongo";
export * from "./mount";
export * from "./mysql";
export * from "./network";
export * from "./notification";
export * from "./patch";
export * from "./port";
+6
View File
@@ -83,6 +83,10 @@ export const libsql = pgTable("libsql", {
stopGracePeriodSwarm: bigint("stopGracePeriodSwarm", { mode: "number" }),
endpointSpecSwarm: json("endpointSpecSwarm").$type<EndpointSpecSwarm>(),
replicas: integer("replicas").default(1).notNull(),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(libsql, {
networkSwarm: NetworkSwarmSchema.nullable(),
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateLibsql = createSchema
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -88,6 +95,10 @@ export const mariadb = pgTable("mariadb", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const mariadbRelations = relations(mariadb, ({ one, many }) => ({
@@ -148,6 +159,8 @@ const createSchema = createInsertSchema(mariadb, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateMariaDB = createSchema.pick({
+6
View File
@@ -92,6 +92,10 @@ export const mongo = pgTable("mongo", {
onDelete: "cascade",
}),
replicaSets: boolean("replicaSets").default(false),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const mongoRelations = relations(mongo, ({ one, many }) => ({
@@ -146,6 +150,8 @@ const createSchema = createInsertSchema(mongo, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateMongo = createSchema.pick({
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -86,6 +93,10 @@ export const mysql = pgTable("mysql", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const mysqlRelations = relations(mysql, ({ one, many }) => ({
@@ -145,6 +156,8 @@ const createSchema = createInsertSchema(mysql, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateMySql = createSchema.pick({
+125
View File
@@ -0,0 +1,125 @@
import { relations } from "drizzle-orm";
import { boolean, jsonb, pgEnum, pgTable, text } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
import { server } from "./server";
export const networkDriver = pgEnum("networkDriver", ["bridge", "overlay"]);
export const network = pgTable("network", {
networkId: text("networkId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
driver: networkDriver("driver").notNull().default("bridge"),
internal: boolean("internal").notNull().default(false),
attachable: boolean("attachable").notNull().default(false),
enableIPv4: boolean("enableIPv4").notNull().default(true),
enableIPv6: boolean("enableIPv6").notNull().default(false),
ipam: jsonb("ipam")
.$type<{
driver?: string;
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
}>()
.default({}),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
});
export const networkRelations = relations(network, ({ one }) => ({
organization: one(organization, {
fields: [network.organizationId],
references: [organization.id],
}),
server: one(server, {
fields: [network.serverId],
references: [server.serverId],
}),
}));
const createSchema = createInsertSchema(network, {
networkId: z.string().min(1),
name: z.string().min(1),
driver: z.enum(["bridge", "overlay"]).optional(),
internal: z.boolean().optional(),
attachable: z.boolean().optional(),
enableIPv4: z.boolean().optional(),
enableIPv6: z.boolean().optional(),
ipam: z
.object({
driver: z.string().optional(),
config: z
.array(
z.object({
subnet: z.string().optional(),
gateway: z.string().optional(),
ipRange: z.string().optional(),
}),
)
.optional(),
})
.optional(),
organizationId: z.string().min(1),
serverId: z.string().optional().nullable(),
});
const validateNetworkInput = (
input: {
enableIPv4?: boolean;
enableIPv6?: boolean;
ipam?: {
config?: Array<{ subnet?: string; gateway?: string; ipRange?: string }>;
} | null;
},
ctx: z.RefinementCtx,
) => {
if (input.enableIPv4 === false && input.enableIPv6 !== true) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["enableIPv4"],
message: "IPv4 or IPv6 must be enabled",
});
}
for (const [index, entry] of (input.ipam?.config ?? []).entries()) {
if (!entry.subnet && (entry.gateway || entry.ipRange)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["ipam", "config", index, "subnet"],
message: "Gateway and IP range require a subnet",
});
}
}
};
export const apiCreateNetwork = createSchema
.pick({
name: true,
driver: true,
internal: true,
attachable: true,
enableIPv4: true,
enableIPv6: true,
ipam: true,
serverId: true,
})
.partial()
.required({ name: true })
.superRefine(validateNetworkInput);
export const apiFindOneNetwork = z.object({
networkId: z.string().min(1),
});
export const apiRemoveNetwork = z.object({
networkId: z.string().min(1),
});
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -86,6 +93,10 @@ export const postgres = pgTable("postgres", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const postgresRelations = relations(postgres, ({ one, many }) => ({
@@ -140,6 +151,8 @@ const createSchema = createInsertSchema(postgres, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreatePostgres = createSchema.pick({
+14 -1
View File
@@ -1,5 +1,12 @@
import { relations } from "drizzle-orm";
import { bigint, integer, json, pgTable, text } from "drizzle-orm/pg-core";
import {
bigint,
boolean,
integer,
json,
pgTable,
text,
} from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
@@ -80,6 +87,10 @@ export const redis = pgTable("redis", {
serverId: text("serverId").references(() => server.serverId, {
onDelete: "cascade",
}),
networkIds: text("networkIds").array().default([]),
detachDokployNetwork: boolean("detachDokployNetwork")
.notNull()
.default(false),
});
export const redisRelations = relations(redis, ({ one, many }) => ({
@@ -129,6 +140,8 @@ const createSchema = createInsertSchema(redis, {
stopGracePeriodSwarm: z.number().nullable(),
endpointSpecSwarm: EndpointSpecSwarmSchema.nullable(),
ulimitsSwarm: UlimitsSwarmSchema.nullable(),
networkIds: z.array(z.string()).optional(),
detachDokployNetwork: z.boolean().optional(),
});
export const apiCreateRedis = createSchema.pick({
+4 -1
View File
@@ -57,6 +57,7 @@ export const schedules = pgTable("schedule", {
});
export type Schedule = typeof schedules.$inferSelect;
export type ScheduleType = Schedule["scheduleType"];
export const schedulesRelations = relations(schedules, ({ one, many }) => ({
application: one(applications, {
@@ -78,7 +79,9 @@ export const schedulesRelations = relations(schedules, ({ one, many }) => ({
deployments: many(deployments),
}));
export const createScheduleSchema = createInsertSchema(schedules);
export const createScheduleSchema = createInsertSchema(schedules, {
scheduleType: z.enum(["application", "compose", "server", "dokploy-server"]),
});
export const updateScheduleSchema = createScheduleSchema.extend({
scheduleId: z.string().min(1),
+2
View File
@@ -19,6 +19,7 @@ import { libsql } from "./libsql";
import { mariadb } from "./mariadb";
import { mongo } from "./mongo";
import { mysql } from "./mysql";
import { network } from "./network";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { schedules } from "./schedule";
@@ -125,6 +126,7 @@ export const serverRelations = relations(server, ({ one, many }) => ({
mysql: many(mysql),
postgres: many(postgres),
certificates: many(certificates),
networks: many(network),
organization: one(organization, {
fields: [server.organizationId],
references: [organization.id],
+6
View File
@@ -41,6 +41,12 @@ export const APP_NAME_REGEX = /^[a-zA-Z0-9._-]+$/;
export const APP_NAME_MESSAGE =
"App name can only contain letters, numbers, dots, underscores and hyphens";
/** Docker volume name: must start alphanumeric, then letters/numbers/._- only. Safe for shell. */
export const VOLUME_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
export const VOLUME_NAME_MESSAGE =
"Volume name must start with a letter or number and contain only letters, numbers, dots, underscores and hyphens";
/** Database password: blocks shell-dangerous characters like $ ! ' " \ / and spaces. */
export const DATABASE_PASSWORD_REGEX =
/^[a-zA-Z0-9@#%^&*()_+\-=[\]{}|;:,.<>?~`]*$/;
@@ -14,7 +14,11 @@ import { serviceType } from "./mount";
import { mysql } from "./mysql";
import { postgres } from "./postgres";
import { redis } from "./redis";
import { generateAppName } from "./utils";
import {
generateAppName,
VOLUME_NAME_MESSAGE,
VOLUME_NAME_REGEX,
} from "./utils";
export const volumeBackups = pgTable("volume_backup", {
volumeBackupId: text("volumeBackupId")
@@ -113,7 +117,9 @@ export const volumeBackupsRelations = relations(
}),
);
export const createVolumeBackupSchema = createInsertSchema(volumeBackups).omit({
export const createVolumeBackupSchema = createInsertSchema(volumeBackups, {
volumeName: z.string().regex(VOLUME_NAME_REGEX, VOLUME_NAME_MESSAGE),
}).omit({
volumeBackupId: true,
});
+1
View File
@@ -28,6 +28,7 @@ export * from "./services/mariadb";
export * from "./services/mongo";
export * from "./services/mount";
export * from "./services/mysql";
export * from "./services/network";
export * from "./services/notification";
export * from "./services/patch";
export * from "./services/patch-repo";
+1 -1
View File
@@ -419,7 +419,7 @@ const createBetterAuth = () =>
enableMetadata: true,
references: "user",
}),
sso(),
sso({ trustEmailVerified: true }),
scim({
beforeSCIMTokenGenerated: async ({ user }) => {
const dbUser = await db.query.user.findFirst({
+67 -1
View File
@@ -1,5 +1,6 @@
import { db } from "@dokploy/server/db";
import { ai } from "@dokploy/server/db/schema";
import { ai, organization } from "@dokploy/server/db/schema";
import { aiCustomProviderSchema } from "@dokploy/server/db/schema/ai";
import { selectAIProvider } from "@dokploy/server/utils/ai/select-ai-provider";
import { TRPCError } from "@trpc/server";
import { generateText, Output } from "ai";
@@ -48,9 +49,74 @@ export const getAiSettingById = async (aiId: string) => {
return aiSetting;
};
type AiCustomProvider = z.infer<typeof aiCustomProviderSchema>;
const parseOrgMetadata = (metadata: string | null) => {
try {
const parsed = JSON.parse(metadata || "{}");
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
};
export const getCustomAiProviders = async (organizationId: string) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
const metadata = parseOrgMetadata(org?.metadata ?? null);
const result = z
.array(aiCustomProviderSchema)
.safeParse(metadata.aiProviders);
return result.success ? result.data : [];
};
export const saveCustomAiProviders = async (
organizationId: string,
providers: AiCustomProvider[],
) => {
const org = await db.query.organization.findFirst({
where: eq(organization.id, organizationId),
});
if (!org) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Organization not found",
});
}
const metadata = parseOrgMetadata(org.metadata);
metadata.aiProviders = providers;
await db
.update(organization)
.set({ metadata: JSON.stringify(metadata) })
.where(eq(organization.id, organizationId));
return providers;
};
const normalizeApiUrl = (url: string) => url.trim().replace(/\/+$/, "");
export const saveAiSettings = async (organizationId: string, settings: any) => {
const aiId = settings.aiId;
if (settings.apiUrl) {
const customProviders = await getCustomAiProviders(organizationId);
if (customProviders.length > 0) {
const isAllowed = customProviders.some(
(provider) =>
normalizeApiUrl(provider.apiUrl) === normalizeApiUrl(settings.apiUrl),
);
if (!isAllowed) {
throw new TRPCError({
code: "FORBIDDEN",
message:
"This API URL is not in your organization's allowed AI providers",
});
}
}
}
return db
.insert(ai)
.values({
+6 -5
View File
@@ -9,6 +9,7 @@ import {
import { removeDirectoryIfExistsContent } from "@dokploy/server/utils/filesystem/directory";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import { quote } from "shell-quote";
import { stringify } from "yaml";
import type { z } from "zod";
import { encodeBase64 } from "../utils/docker/utils";
@@ -63,7 +64,7 @@ export const removeCertificateById = async (certificateId: string) => {
const certDir = path.join(CERTIFICATES_PATH, certificate.certificatePath);
if (certificate.serverId) {
await execAsyncRemote(certificate.serverId, `rm -rf ${certDir}`);
await execAsyncRemote(certificate.serverId, `rm -rf ${quote([certDir])}`);
} else {
await removeDirectoryIfExistsContent(certDir);
}
@@ -108,10 +109,10 @@ const createCertificateFiles = async (certificate: Certificate) => {
const certificateData = encodeBase64(certificate.certificateData);
const privateKey = encodeBase64(certificate.privateKey);
const command = `
mkdir -p ${certDir};
echo "${certificateData}" | base64 -d > "${crtPath}";
echo "${privateKey}" | base64 -d > "${keyPath}";
echo "${yamlConfig}" > "${configFile}";
mkdir -p ${quote([certDir])};
echo "${certificateData}" | base64 -d > ${quote([crtPath])};
echo "${privateKey}" | base64 -d > ${quote([keyPath])};
echo "${yamlConfig}" > ${quote([configFile])};
`;
await execAsyncRemote(certificate.serverId, command);
+11 -2
View File
@@ -83,8 +83,17 @@ export const findDeploymentById = async (deploymentId: string) => {
const deployment = await db.query.deployments.findFirst({
where: eq(deployments.deploymentId, deploymentId),
with: {
application: true,
compose: true,
application: {
columns: {
applicationId: true,
appName: true,
name: true,
serverId: true,
},
},
compose: {
columns: { composeId: true, appName: true, name: true, serverId: true },
},
schedule: true,
},
});
+9 -3
View File
@@ -80,7 +80,9 @@ export const findDomainById = async (domainId: string) => {
const domain = await db.query.domains.findFirst({
where: eq(domains.domainId, domainId),
with: {
application: true,
application: {
columns: { applicationId: true, appName: true, name: true },
},
},
});
if (!domain) {
@@ -96,7 +98,9 @@ export const findDomainsByApplicationId = async (applicationId: string) => {
const domainsArray = await db.query.domains.findMany({
where: eq(domains.applicationId, applicationId),
with: {
application: true,
application: {
columns: { applicationId: true, appName: true, name: true },
},
},
});
@@ -107,7 +111,9 @@ export const findDomainsByComposeId = async (composeId: string) => {
const domainsArray = await db.query.domains.findMany({
where: eq(domains.composeId, composeId),
with: {
compose: true,
compose: {
columns: { composeId: true, appName: true, name: true },
},
},
});

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